From 09860c05ee951cdfa85abfb4006c56ca577b28d3 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 11:02:50 +0200 Subject: [PATCH 01/24] feat(installer): add read-only dependency preflight --- docs/install-wizard.md | 104 +++++++++++++++++++++++ odd/tasks/browser-install-wizard.md | 75 +++++++++++++++++ scripts/installer-preflight.mjs | 126 ++++++++++++++++++++++++++++ tests/installer-preflight.test.ts | 112 +++++++++++++++++++++++++ 4 files changed, 417 insertions(+) create mode 100644 docs/install-wizard.md create mode 100644 odd/tasks/browser-install-wizard.md create mode 100644 scripts/installer-preflight.mjs create mode 100644 tests/installer-preflight.test.ts diff --git a/docs/install-wizard.md b/docs/install-wizard.md new file mode 100644 index 000000000..baafad674 --- /dev/null +++ b/docs/install-wizard.md @@ -0,0 +1,104 @@ +# Installation preflight (wizard groundwork) + +The read-only preflight module inventories prerequisites and returns an ordered +plan. **There is no working browser wizard or clean-machine bootstrap yet.** +For ordinary installation and terminal use, follow the [README](../README.md). + +## What is available + +`scripts/installer-preflight.mjs` exports three small integration surfaces: + +| API | Contract | +| --- | --- | +| `requirements` | Repository-derived Node/Pi minima, pnpm acquisition pin, package version, native installer pin and Windows Go minimum. | +| `collectInventory({ platform, arch, probes })` | Calls injected named read-only probes serially. Missing/failed probes become unknown; error text is not retained. | +| `planPreflight(inventory)` | Pure classification and ordered action intents; no commands, downloads, installation or setup execution. | + +Current requirements come from `package.json` and +[`gentle-ai-installer.mjs`](../scripts/gentle-ai-installer.mjs): Node ≥22.19.0, +Pi ≥0.99.1, pnpm acquisition 11.1.1, Gentle Shell package 4.0.0 and package-local +Gentle AI 4.0.0. Windows native source builds require Go ≥1.25.10 when the +required usable native binary is missing. A reusable binary does not require Go. +Pins change with the repository; the API reads the metadata rather than copying +these values into a second installer. + +## Probe adapter contract + +Supply `platform` as `linux`, `darwin` or `win32`, and `arch` as `x64` or `arm64`. +These are planning targets, **not evidence of native OS/libc support**. Other +pairs produce an unsupported-target blocker and no actions. Native lanes, +minimum OS versions, libc compatibility, artifact trust and elevation handling +remain to be verified in later units. + +Probes are named `node`, `pnpm`, `pi`, `shell`, `gentleAi`, `go`, `globalBin`, and +`setup`. There are deliberately no built-in host process probes yet. Adapters +must use bounded read-only checks, never launcher setup, postinstall, paid model +requests, credential inspection or home writes. The collector cannot enforce +purity of caller-provided functions; adapter implementations require review. + +- Tool observations use `{ available: true, version, usable: true }`; absence is + `{ available: false }`. Unknown availability, usability or unparseable versions + block planning. Versions are exact stable `major.minor.patch` strings (optional + `v` prefix); prereleases and raw command output are unknown, not silently reused. +- Shell additionally needs `global: true`, proving normal global ownership. + Compatible newer Node, Pi and Shell versions are reused, never downgraded. +- pnpm additionally needs `compatible: true`, based on its actual Node engine and + required global-install capabilities. The acquisition pin is not a minimum: + newer compatible pnpm is reusable. Do not infer compatibility from its version + alone; the later acquisition adapter must validate the pinned engine too. +- Gentle AI additionally needs `compatible: true`: evidence from the normal + package-local binary resolution/integrity contract, for the selected Shell + installation. Its exact installer pin is required; another version blocks + rather than being overwritten. Do not confuse an unrelated PATH binary with + the package's reusable native binary. For a newer reusable Shell, the adapter + must establish native-pin compatibility before presenting this evidence. +- Global-bin observations use `{ available: true, path, writable: true, onPath }`. + A known missing PATH entry yields an explicit setup intent. Unknown or + non-writable directories block; no directory is created or permission probed + by writing. No platform-specific bin path is invented. +- `setup` is a boolean evidence of normal Shell setup readiness. Unknown setup + on an existing stack blocks; a missing Shell/native binary needs normal setup. + +## Reading a plan + +Tool statuses distinguish `unavailable`, `unknown`, `incompatible`, `reusable`, +`needs-setup` and `not-required`. Any blocker suppresses all actions: repairing an +existing incompatible or uncertain component requires a later explicit decision, +not automatic replacement. `ready` means no acquisition/setup is indicated by +this inventory, **not that verification has executed**. + +A clean target receives these intents in dependency order: + +1. Acquire and verify Node, then pnpm; prepare the usable global-bin environment. +2. On Windows, acquire and verify Go before missing native provisioning. +3. Install Pi globally, then `gentle-pi` globally (its existing postinstall owns + native installation). For an existing Shell with missing native binary, call + the existing installer instead. +4. Run normal Shell setup and verify stack readiness. Verification is always + included, even when all components can be reused. + +Actions are structured `{ id, kind, target, version? }` descriptors, **not shell +commands**. Later runners must re-inventory after changes, verify prerequisite +versions and global-bin readiness before dependent steps, obtain explicit consent +and stop on mandatory failures. They must preserve existing versions/paths and +use normal global package ownership and default `~/.gentle-shell/agent` semantics. +No alternate product root, Engram database/server, credential migration or custom +companion installer belongs in this plan. + +## Remaining boundaries + +`npm:` registry sources are package references, not npm executable calls. Pi's +`npmCommand` supports pnpm, but independent upstream `npm exec` control remains +unverified. **This preflight does not establish an npm-free installation chain.** + +Downloads, bootstrap scripts, runner, local server, consent UI and native +acceptance evidence are future work in the +[feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected +inventory tests cover the planning matrix; they do not prove installation on +Windows, macOS or Linux. + +Focused verification: + +```sh +node --experimental-strip-types --test tests/installer-preflight.test.ts +``` diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md new file mode 100644 index 000000000..812048922 --- /dev/null +++ b/odd/tasks/browser-install-wizard.md @@ -0,0 +1,75 @@ +# Browser installation wizard + +## Objective +Install all prerequisites and the normal Gentle Shell stack through a local-browser wizard on Windows, macOS and Linux. The final behavior must match global `gentle-pi` installation and running `gentle-shell` in a terminal. + +## Problem and rationale +Current setup requires a usable Node/Pi runtime and Windows native provisioning requires Go. A browser wizard should acquire missing prerequisites, explain installation progress and verify readiness without duplicating the existing provider/companion installation logic. + +## Authorized scope and constraints +- User authorized implementation after approving the corrected design. +- Preserve the default `~/.gentle-shell/agent`, existing Engram data/server semantics, shared integrations and existing setup/update ownership. +- Reuse compatible prerequisites and install missing ones, including Pi. +- Prefer pnpm without assuming npm, pnpm, Corepack, Node or Go is already installed. +- `npm:` registry references are allowed; they are not npm executable invocations. Pi's `npmCommand` supports pnpm, but upstream independent `npm exec` remains unverified. +- Do not invent package-manager flags, an npm-impersonating wrapper or a duplicate companion installer. +- No custom product root, separate Engram database/server, credential copying/migration, security exclusions or silent changes to existing installations. +- Use a dependency-free Node local HTTP host and bundled static browser UI. The wizard is temporary; ordinary terminal `gentle-shell` remains the runtime. +- Keep source writes single-threaded. No push, PR, release, remote execution or authenticated remote access is authorized. +- Initial target matrix: Windows/macOS/Linux desktop x64 and arm64; exact supported OS/libc/runtime versions must be validated rather than claimed. + +## Delivery and routing +- Branch: `feat/browser-install-wizard`; verified default reference: `origin/main`. +- Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. +- Forecast: 2,350–3,610 authored changed lines, additions plus deletions, generated files excluded. +- Running committed authored lines: 0. +- Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. +- No publishing is implied by a delivery-strategy choice. +- Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer. +- Task size around 400 lines is advisory, not a cap. Never compress code or omit checks to meet it. +- Tests first where deterministic expected behavior exists: observed RED, GREEN, then checked refactoring. Report unavailable or non-applicable checks honestly. +- RDD currently on globally; review candidates are work-unit commits or review slices, never checkboxes. Native review does not replace functional verification. + +## Tasks +- [ ] T1 — Dependency inventory and ordered preflight plan. Status: in progress; writer and independent verifier passed 21/21; work-unit commit and native candidate disposition pending. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. +- [ ] T2 — POSIX clean-machine bootstrap. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused POSIX bootstrap tests and native macOS/Linux lanes; verify integrity, spaces and environment refresh. +- [ ] T3 — Windows clean-machine bootstrap. Route: delegated direct. Surfaces: `scripts/bootstrap.ps1`, `scripts/installer-downloads.mjs`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused Windows bootstrap tests and native Windows lane; compatible Go acquisition, quoted paths and inherited environment. +- [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. +- [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. +- [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. +- [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. + +## Acceptance criteria +- A supported clean machine can acquire all prerequisites including Pi without manual dependency installation. +- Normal global package ownership and default terminal-launch isolation remain unchanged. +- Engram/shared companion behavior is reused, not duplicated or migrated. +- Mandatory readiness failures never produce a successful result. +- Missing provider authentication is distinguished from installation failure; no paid model request is required for basic readiness. +- Downloads are integrity-verified; installation actions are fixed and explicitly consented to. +- Interrupted/failing steps are recoverable without deleting existing data or exposing secrets. +- Native OS support is claimed only after corresponding execution evidence, not syntax/mock tests alone. + +## Verification policy +Risk: high (installers, executable acquisition, shared configuration, local mutation API). Functional focused checks are mandatory; applicable independent verification and native review follow the runtime/controller evidence. Use disposable homes and fake download/process adapters for deterministic tests. No real installs into the operator's home. + +Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runtime-modules`, `node scripts/verify-package-files.mjs`, and `pnpm run test:packed-package`. The last uses real installation/network and requires a side-effect forecast/disposable environment. Typecheck is an existing ratchet, not a zero-diagnostic guarantee. + +## Open evidence and boundaries +- Upstream independent `npm exec` cannot be redirected by Pi's `npmCommand`; inspect the exact pinned public contract before asserting an npm-free setup. +- Bootstrap publication URLs, artifact trust/integrity, OS support and elevation behavior need validated implementation contracts; do not invent published artifacts. +- Existing launcher setup dry-run is not a whole-launcher no-write guarantee; preflight must not execute setup or postinstall. +- Browser functional verification remains pending implementation. +- T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. Native candidate assessment/review, full suites, builds, actual installs and packaging checks remain pending. + +## Progress and next step +Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. Close the work-unit commit and native candidate disposition before advancing to T2. + +## Work-unit evidence +### T1 (not closed) +- Writer: `muqq4ay0-4-dazo`; authorized files only; 342 authored additions. +- RED: focused test command failed with `ERR_MODULE_NOT_FOUND` before implementation. +- GREEN/refactor: `node --experimental-strip-types --test tests/installer-preflight.test.ts` passed 21/21; writer corrected a setup-action test expectation before final GREEN. +- Writer whitespace: `git diff --check` passed; new-file no-index checks had no whitespace findings. +- Native ASSESS: unassessable on undeclared untracked scope, independent verifier required. INSPECT later declared intended paths and returned ready, no lineage created. +- Independent verification: task `muqqewpj-5-xgbi` passed 21/21 focused tests and whitespace checks, without edits; no severe deterministic candidate-caused findings. Parent spot check reran the focused command and passed 21/21. +- Commit/review: pending; no claims of native OS install proof. diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs new file mode 100644 index 000000000..deccd1bb0 --- /dev/null +++ b/scripts/installer-preflight.mjs @@ -0,0 +1,126 @@ +import { readFileSync } from "node:fs"; +import { + INSTALLER_VERSION, + GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION, +} from "./gentle-ai-installer.mjs"; + +// Read package metadata only: never import the launcher or execute postinstall. +const metadata = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); +function minimum(range) { + if (!/^>=\d+\.\d+\.\d+$/.test(range)) throw new Error("Unsupported preflight requirement range"); + return range.slice(2); +} +export const requirements = Object.freeze({ + node: minimum(metadata.engines.node), + pi: minimum(metadata.peerDependencies["@earendil-works/pi-coding-agent"]), + pnpm: metadata.packageManager.replace(/^pnpm@/, ""), + shell: metadata.version, + gentleAi: INSTALLER_VERSION, + go: GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION, +}); + +const probeNames = ["node", "pnpm", "pi", "shell", "gentleAi", "go", "globalBin", "setup"]; + +/** Collect with caller-owned read-only probes; no default process/home adapters. + * A tool probe returns { available, version, usable }; missing is available:false. + * pnpm also needs compatible:true (runtime/capability evidence), shell global:true, + * and Gentle AI compatible:true (normal binary resolver/integrity evidence). + * globalBin returns { available, path, writable, onPath }; setup returns boolean. + * Version values are exact stable versions, not raw arbitrary command output. + */ +export async function collectInventory({ platform, arch, probes = {} }) { + const inventory = { platform, arch }; + for (const name of probeNames) { + try { + inventory[name] = probes[name] ? await probes[name]() : { available: null }; + } catch { + // Do not retain probe errors: they can contain private paths or credentials. + inventory[name] = { available: null }; + } + } + return inventory; +} + +function versionParts(version) { + if (typeof version !== "string" || !/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return null; + const parts = version.replace(/^v/, "").split(".").map(Number); + return parts.every(Number.isSafeInteger) ? parts : null; +} +function compareVersions(left, right) { + for (let i = 0; i < 3; i += 1) { + if (left[i] !== right[i]) return left[i] > right[i] ? 1 : -1; + } + return 0; +} +function classify(observation, required, extraCheck = () => true, exact = false) { + if (observation?.available === false) return "unavailable"; + if (observation?.available !== true) return "unknown"; + const version = versionParts(observation.version); + if (!version || observation.usable !== true) return "unknown"; + const order = compareVersions(version, versionParts(required)); + if (order < 0 || (exact && order !== 0)) return "incompatible"; + const extra = extraCheck(observation); + if (extra === false) return "incompatible"; + return extra === true ? "reusable" : "unknown"; +} + +/** Pure ordered intent plan, not executable commands or a readiness certificate. + * Blocked plans contain no actions: never overwrite an incompatible/unknown tool. + * Compatible newer Node/Pi/Shell versions remain owned by their existing install. + */ +export function planPreflight(inventory) { + const tools = {}; + const blockers = []; + const actions = []; + const { platform, arch } = inventory; + const supportedTarget = ["linux", "darwin", "win32"].includes(platform) && ["x64", "arm64"].includes(arch); + if (!supportedTarget) { + return { tools, blockers: [{ code: "unsupported-target", tool: "target" }], actions, ready: false }; + } + function record(name, status, required) { + tools[name] = { status, ...(required ? { required } : {}) }; + if (status === "unknown" || status === "incompatible") { + blockers.push({ code: `${status}-tool`, tool: name }); + } + } + for (const name of ["node", "pi", "shell"]) { + record(name, classify(inventory[name], requirements[name], name === "shell" ? (o) => o.global : undefined), requirements[name]); + } + // packageManager pins the acquisition choice, not a minimum supported pnpm. + // Existing versions require explicit engine/capability evidence from the probe. + record("pnpm", classify(inventory.pnpm, "0.0.0", (o) => o.compatible), requirements.pnpm); + record("gentleAi", classify(inventory.gentleAi, requirements.gentleAi, (o) => o.compatible, true), requirements.gentleAi); + const needsNative = tools.gentleAi.status === "unavailable"; + record("go", platform === "win32" && needsNative ? classify(inventory.go, requirements.go) : "not-required", requirements.go); + const bin = inventory.globalBin; + const binKnown = bin?.available === true && typeof bin.path === "string" && bin.path.trim().length > 0 && + bin.writable === true && typeof bin.onPath === "boolean"; + record("globalBin", bin?.available === false ? "unavailable" : binKnown ? (bin.onPath ? "reusable" : "needs-setup") : "unknown"); + const missingShell = tools.shell.status === "unavailable"; + let setupStatus = "unknown"; + if (missingShell || needsNative || inventory.setup === false) setupStatus = "needs-setup"; + else if (inventory.setup === true) setupStatus = "reusable"; + record("setup", setupStatus); + if (blockers.length) return { tools, blockers, actions, ready: false }; + + function action(id, kind, target, version) { + actions.push({ id, kind, target, ...(version ? { version } : {}) }); + } + function acquire(name) { + if (tools[name].status !== "unavailable") return; + action(`acquire-${name}`, "acquire", name, requirements[name]); + action(`verify-${name}`, "verify", name); + } + acquire("node"); + acquire("pnpm"); + if (tools.globalBin.status !== "reusable") action("setup-global-bin", "setup", "globalBin"); + acquire("go"); + if (tools.pi.status === "unavailable") action("install-pi", "install-global", "pi", requirements.pi); + if (missingShell) action("install-shell", "install-global", "shell", requirements.shell); + // Global gentle-pi postinstall owns native provisioning when Shell is missing. + // Otherwise the later runner must reuse that existing installer, not duplicate it. + if (needsNative && !missingShell) action("provision-native", "existing-installer", "gentleAi", requirements.gentleAi); + if (tools.setup.status !== "reusable") action("setup-shell", "normal-setup", "shell"); + action("verify-readiness", "verify", "stack"); + return { tools, blockers, actions, ready: actions.length === 1 }; +} diff --git a/tests/installer-preflight.test.ts b/tests/installer-preflight.test.ts new file mode 100644 index 000000000..6365e8693 --- /dev/null +++ b/tests/installer-preflight.test.ts @@ -0,0 +1,112 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { collectInventory, planPreflight, requirements } from "../scripts/installer-preflight.mjs"; + +const absent = { available: false }; +const tool = (version: string) => ({ available: true, version, usable: true }); +function clean(platform = "linux", arch = "x64") { + return { platform, arch, node: absent, pnpm: absent, pi: absent, shell: absent, + gentleAi: absent, go: absent, globalBin: absent, setup: false }; +} +function installed(platform = "linux") { + return { ...clean(platform), node: tool("24.1.0"), + pnpm: { ...tool("12.0.0"), compatible: true }, pi: tool("1.2.0"), + shell: { ...tool("5.0.0"), global: true }, + gentleAi: { ...tool(requirements.gentleAi), compatible: true }, + globalBin: { available: true, path: "/disposable/bin", writable: true, onPath: true }, setup: true }; +} +const ids = (inventory: object) => planPreflight(inventory).actions.map((action: { id: string }) => action.id); + +test("requirements follow repository metadata and native installer pin", () => { + const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); + assert.equal(requirements.node, pkg.engines.node.slice(2)); + assert.equal(requirements.pi, pkg.peerDependencies["@earendil-works/pi-coding-agent"].slice(2)); + assert.equal(requirements.pnpm, pkg.packageManager.split("@")[1]); + assert.equal(requirements.shell, pkg.version); + assert.equal(requirements.go, "1.25.10"); +}); + +for (const platform of ["linux", "darwin", "win32"]) { + for (const arch of ["x64", "arm64"]) { + test(`clean ${platform}/${arch} has dependency-ordered actions`, () => { + const plan = planPreflight(clean(platform, arch)); + assert.equal(plan.blockers.length, 0); + assert.equal(plan.ready, false); + assert.deepEqual(ids(clean(platform, arch)), ["acquire-node", "verify-node", "acquire-pnpm", "verify-pnpm", + "setup-global-bin", ...(platform === "win32" ? ["acquire-go", "verify-go"] : []), + "install-pi", "install-shell", "setup-shell", "verify-readiness"]); + }); + } + test(`compatible ${platform} reuses newer global tools without acquiring Go`, () => { + const inventory = installed(platform); + const before = structuredClone(inventory); + const plan = planPreflight(inventory); + assert.equal(plan.ready, true); + assert.deepEqual(ids(inventory), ["verify-readiness"]); + assert.deepEqual(inventory, before); + assert.equal(plan.tools.node.status, "reusable"); + assert.equal(plan.tools.go.status, "not-required"); + }); +} + +test("Windows requires compatible Go only before a missing native binary", () => { + const inventory = { ...installed("win32"), gentleAi: absent, go: tool("1.26.0") }; + assert.equal(planPreflight(inventory).tools.go.status, "reusable"); + assert.deepEqual(ids(inventory), ["provision-native", "setup-shell", "verify-readiness"]); + assert.ok(planPreflight({ ...inventory, go: tool("1.25.9") }).blockers.some((b: { tool: string }) => b.tool === "go")); +}); + +for (const [name, version] of [["node", "22.18.0"], ["pi", "0.99.0"], ["shell", "3.9.0"], ["node", "banana"], ["pi", "1.0.0-rc.1"]]) { + test(`${name} ${version} blocks rather than replacing an existing tool`, () => { + const inventory = { ...installed(), [name]: tool(version) }; + const plan = planPreflight(inventory); + assert.ok(plan.blockers.some((b: { tool: string }) => b.tool === name)); + assert.deepEqual(plan.actions, []); + assert.equal(plan.ready, false); + }); +} + +test("missing Windows Go is acquired before reprovisioning an existing Shell", () => { + const inventory = { ...installed("win32"), gentleAi: absent }; + assert.deepEqual(ids(inventory), ["acquire-go", "verify-go", "provision-native", "setup-shell", "verify-readiness"]); + for (const go of [tool("devel"), { ...tool("1.26.0"), usable: false }]) { + assert.equal(planPreflight({ ...inventory, go }).tools.go.status, "unknown"); + assert.deepEqual(ids({ ...inventory, go }), []); + } +}); + +test("unknown probes and pnpm compatibility fail closed", () => { + for (const change of [{ node: undefined }, { pnpm: tool("11.1.1") }, { setup: undefined }, + { gentleAi: { ...tool("5.0.0"), compatible: false } }, { shell: { ...tool("4.0.0"), global: false } }]) { + assert.ok(planPreflight({ ...installed(), ...change }).blockers.length > 0); + } +}); + +test("global-bin must be writable and reachable; known PATH repair is explicit", () => { + const inventory = installed(); + assert.deepEqual(ids({ ...inventory, globalBin: { ...inventory.globalBin, onPath: false } }), + ["setup-global-bin", "verify-readiness"]); + for (const globalBin of [{ ...inventory.globalBin, writable: false }, { available: true }]) { + assert.deepEqual(planPreflight({ ...inventory, globalBin }).actions, []); + assert.equal(planPreflight({ ...inventory, globalBin }).tools.globalBin.status, "unknown"); + } +}); + +test("unsupported targets never offer installation", () => { + for (const inventory of [clean("freebsd"), clean("linux", "ia32")]) { + assert.equal(planPreflight(inventory).blockers[0].code, "unsupported-target"); + assert.deepEqual(ids(inventory), []); + } +}); + +test("collector invokes only injected named probes, catches failures without logging details", async () => { + const calls: string[] = []; + const probes = Object.fromEntries(["node", "pnpm", "pi", "shell", "gentleAi", "go", "globalBin", "setup"].map((name) => + [name, async () => { calls.push(name); if (name === "node") throw new Error("private diagnostic"); return absent; }])); + const inventory = await collectInventory({ platform: "linux", arch: "x64", probes }); + assert.deepEqual(calls, Object.keys(probes)); + assert.deepEqual(inventory.node, { available: null }); + assert.equal(JSON.stringify(inventory).includes("private diagnostic"), false); + assert.ok(planPreflight(inventory).blockers.length > 0); +}); From 03e770f75d5ce8a02f16d125b590e41c743c8101 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 12:52:22 +0200 Subject: [PATCH 02/24] feat(installer): bootstrap verified POSIX prerequisites --- docs/install-wizard.md | 149 +++++++- odd/tasks/browser-install-wizard.md | 21 +- scripts/bootstrap.sh | 146 ++++++++ scripts/installer-downloads.mjs | 255 +++++++++++++ tests/installer-posix-bootstrap.test.ts | 470 ++++++++++++++++++++++++ 5 files changed, 1025 insertions(+), 16 deletions(-) create mode 100644 scripts/bootstrap.sh create mode 100644 scripts/installer-downloads.mjs create mode 100644 tests/installer-posix-bootstrap.test.ts diff --git a/docs/install-wizard.md b/docs/install-wizard.md index baafad674..180beb18a 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1,7 +1,8 @@ -# Installation preflight (wizard groundwork) +# Installation wizard groundwork -The read-only preflight module inventories prerequisites and returns an ordered -plan. **There is no working browser wizard or clean-machine bootstrap yet.** +The preflight planner and POSIX prerequisite bootstrap are implemented. +**There is no working browser wizard or complete installation path yet.** +The bootstrap stops explicitly when the future wizard entry is absent. For ordinary installation and terminal use, follow the [README](../README.md). ## What is available @@ -91,14 +92,146 @@ companion installer belongs in this plan. `npmCommand` supports pnpm, but independent upstream `npm exec` control remains unverified. **This preflight does not establish an npm-free installation chain.** -Downloads, bootstrap scripts, runner, local server, consent UI and native -acceptance evidence are future work in the +Windows bootstrap, standard installation driver, local server, consent UI, +distribution packaging and native acceptance evidence remain future work in the [feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected -inventory tests cover the planning matrix; they do not prove installation on -Windows, macOS or Linux. +tests do not prove clean-machine installation on Windows, macOS or Linux. Focused verification: ```sh -node --experimental-strip-types --test tests/installer-preflight.test.ts +node --experimental-strip-types --test tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts +sh -n scripts/bootstrap.sh ``` + +## POSIX bootstrap: bundle-local tooling only + +Run `sh scripts/bootstrap.sh` from a trusted extracted installation bundle or +checkout. There is **no published bundle URL or remote-pipe installer contract**. +The bundle must include `package.json`, both bootstrap modules and the future +`bin/gentle-shell-install.mjs` (T5). Today that entry is absent: the script reports +it before downloads or home writes. T7 owns packaging and distribution proof. + +With that entry available, the fixed sequence is: + +1. Probe existing Node against the bundle's repository requirement. Unknown, + prerelease or incompatible versions block; they are never replaced. +2. If missing, select a fixed native Node archive, download with TLS and bounded + size/time, verify its hardcoded SHA256 using stock shell utilities, extract + only its regular `bin/node`, and check the exact executable version before + publishing it. Neither npm nor Corepack is acquired or invoked. +3. Reuse pnpm only after stable version, package engine and read-only global + `add`/`bin` help-capability evidence. Engines support only simple `>=x.y` or + `>=x.y.z` lower bounds; comparison fills an omitted patch with zero. Actual + Node versions must remain exact stable versions; other ranges block rather + than guess. + Missing pnpm is acquired from a fixed registry tarball, SHA512-SRI verified, + checked for unsafe paths/links, extracted and probed before publication. +4. Start the fixed bundle entry with the refreshed child environment. A mandatory + acquisition/probe/child failure is an error, never installation success. + +### Ownership and failure boundaries + +Acquisition uses a mode-0700, uniquely created +`$HOME/.gentle-shell-bootstrap-tools.` directory, with an explicit +`.bootstrap-owned` marker. This is **prerequisite tooling, not a product home**. +HOME must be absolute, owned by the current user, not group/world writable and +free of symlink ancestors. Staging and destinations reject conflicts and +symlinks. Unrelated similarly named directories are not scanned, reused or +removed. Failed attempts clean only their own private directories; successful +acquisition removes its temporary archives/staging and retains verified tools +so child processes can continue using them. +A new attempt reuses tools only if already visible and proven on its PATH; it +does not discover or garbage-collect previous private attempts. + +No profile, global PATH, existing executable installation, product agent home, +Engram state or companion installation is changed. Added paths affect only the +bootstrap and its child. T4 must implement standard global installation and the +persistent **ordinary terminal** handoff; these private wrappers are not that +handoff. Existing unverifiable pnpm wrappers/binaries block instead of being +silently replaced. No sudo or security exclusions are requested. + +Stock utilities are prerequisites, not silently installed: POSIX sh, awk, +dirname, uname, mkdir, mktemp, chmod, mv, rm, sleep, wc, id and ls; missing Node +also needs curl, tar and sha256sum or shasum. Linux acquisition additionally +requires getconf evidence of glibc >=2.28. Missing pnpm requires tar. Missing +utilities are reported. Shell executable-version probes have a 10-second +watchdog, hash/archive probes 30 seconds; curl has a 10-second connection and +120-second total limit with a 100-MiB artifact cap. A subprocess-only `ulimit -f` +adds a hard disk ceiling (at most 200 MiB depending on shell block units) for +older curl implementations; failure to establish it blocks acquisition. +The JavaScript transport rejects +redirects, caps pnpm at 32 MiB and aborts after 60 seconds; process checks have +15-second/1-MiB bounds. At a prerequisite-process deadline, the shell watchdog +and Node process adapter send SIGKILL to their directly spawned child, rather +than catchable SIGTERM. A killed probe is a failure even if it printed valid +output before hanging. Shell watchdog cancellation also kills and waits for its +owned sleeper. The interactive wizard child intentionally has no total runtime +timeout. Raw downloader/process error text is not logged. + +### Artifact trust and shared helper API + +| Artifact | Acquisition pin and provenance | +| --- | --- | +| Node native darwin/linux x64/arm64 | 24.21.0; parent-verified SHA256 entries from [official SHASUMS256](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt), applied to fixed [v24.21.0 archive URLs](https://nodejs.org/dist/v24.21.0/). | +| pnpm JavaScript CLI | 11.1.1; parent-verified raw engine `>=22.13` (comparison minimum `22.13.0`), tarball and SHA512 SRI from [registry version metadata](https://registry.npmjs.org/pnpm/11.1.1); [fixed tarball](https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz). | + +The exact integrity values live in `installer-downloads.mjs`; the shell copies +only Node's acquisition pin/hashes because first acquisition cannot depend on +Node. Tests cross-check shell selection against the shared descriptors. Changing +these pins requires renewed primary-source integrity evidence and updating both +Node representations together. There is no live latest resolution. The pnpm pin retains the exact raw upstream +engine string for metadata identity checks; only version comparisons normalize +its supported partial minimum. Product versions/minima remain repository-derived; +the pnpm pin is not a compatibility +minimum. Registry-hosted artifacts do not imply using the npm executable. + +`scripts/installer-downloads.mjs` exports `artifactFor(name, platform, arch)` +(frozen allowlisted descriptors), `verifiedDownload(name, adapters, platform, +arch)` (verified bytes), `compatibleEngine(range, version)`, +`ensurePnpm({ tools, env, nodeVersion, adapters })` and +`launchWizard({ bundle, env })`. Only known artifact names are accepted, not +caller URLs/checksums/commands. Trusted local test adapters inject byte download, +digest and process checks; they are not exposed through any browser interface. +The caller owns a private `tools` directory. `ensurePnpm` returns `{ env, +acquired }`, leaving the supplied environment unchanged. Future T3 can reuse +descriptors/integrity verification; Windows descriptors, process semantics and +wrapper publication are intentionally not implemented by this POSIX unit. + +### Evidence, not platform certification + +Native shell execution was exercised on this Linux host with disposable Unicode +and whitespace paths, fake OS/download/hash/archive utilities and injected +JavaScript acquisition/process adapters. No real network or host prerequisite +installation was performed. Darwin selections are simulated, **not native +macOS execution**. Native macOS, Linux clean-machine/loader and minimum macOS +version acceptance remain T7 work. Linux acquisition is native glibc-only: +musl/Alpine and emulation/Rosetta compatibility are not claimed. Node's +[upstream build/platform requirements](https://github.com/nodejs/node/blob/v24.21.0/BUILDING.md) +remain the platform reference; successful descriptor planning alone proves no +OS/kernel/libc support. + +### Subprocess deadline evidence + +The regression fixtures exercise production process paths without shortened +production timeouts or a mocked process adapter: + +| Path | Observed Linux fixture behavior | +| --- | --- | +| Shell acquired-Node version probe | A single Node process prints the expected version, ignores TERM and waits without busy-looping. The real 10-second watchdog kills/reaps it, rejects acquisition and removes owned tooling while preserving an unrelated HOME file. | +| Node helper tar probe | Fake verified bytes feed `ensurePnpm`, but its default process adapter runs a real single-process TERM-ignoring tar stand-in. The real 15-second deadline kills/reaps it, rejects acquisition and removes owned staging while preserving an unrelated file. Ordinary nonzero tar exit also rejects and cleans staging. | + +Before correction, both paths exceeded their deadlines and needed the test +harness's independent outer SIGKILL guard (13 seconds for shell, 18 for Node). +The guard kills only each freshly created detached fixture group and also +cleans residual fixture processes on exit. Production does **not** kill groups, +match process names or use an external timeout utility. After correction, both +probe PIDs are reaped before their parents return; neither outer guard fires. +Ordinary success remains covered by the existing reuse/acquisition tests. + +These are **direct-child, non-forking probe** guarantees, not process-tree +cancellation evidence. Unknown programs that fork descendants retaining stdio +may keep pipes open; this unit does not claim bounded return or descendant +cleanup for those programs. The fixtures prove neither native macOS behavior +nor live artifact acquisition. Independent verification and native review remain +separate parent-owned gates. diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index 812048922..bc5becfe0 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -21,8 +21,9 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Delivery and routing - Branch: `feat/browser-install-wizard`; verified default reference: `origin/main`. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. -- Forecast: 2,350–3,610 authored changed lines, additions plus deletions, generated files excluded. -- Running committed authored lines: 0. +- Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; correction size still pending. +- Running committed authored lines: 417 (T1 includes its feature task document). +- Last reviewed boundary: `09860c05ee951cdfa85abfb4006c56ca577b28d3`. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. - Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer. @@ -31,8 +32,8 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - RDD currently on globally; review candidates are work-unit commits or review slices, never checkboxes. Native review does not replace functional verification. ## Tasks -- [ ] T1 — Dependency inventory and ordered preflight plan. Status: in progress; writer and independent verifier passed 21/21; work-unit commit and native candidate disposition pending. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. -- [ ] T2 — POSIX clean-machine bootstrap. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused POSIX bootstrap tests and native macOS/Linux lanes; verify integrity, spaces and environment refresh. +- [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. +- [ ] T2 — POSIX clean-machine bootstrap. Status: in progress; user-authorized deadline correction returned 55/55 POSIX and 76/76 combined tests. Final independent verification is pending; native review/commit remain pending. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused POSIX bootstrap tests and native macOS/Linux lanes; verify integrity, spaces and environment refresh. - [ ] T3 — Windows clean-machine bootstrap. Route: delegated direct. Surfaces: `scripts/bootstrap.ps1`, `scripts/installer-downloads.mjs`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused Windows bootstrap tests and native Windows lane; compatible Go acquisition, quoted paths and inherited environment. - [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. @@ -59,17 +60,21 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - Bootstrap publication URLs, artifact trust/integrity, OS support and elevation behavior need validated implementation contracts; do not invent published artifacts. - Existing launcher setup dry-run is not a whole-launcher no-write guarantee; preflight must not execute setup or postinstall. - Browser functional verification remains pending implementation. -- T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. Native candidate assessment/review, full suites, builds, actual installs and packaging checks remain pending. +- T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. Close the work-unit commit and native candidate disposition before advancing to T2. +Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. T1 is closed in commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`. Its native medium reliability review `review-eda68029d737a4f8` approved and acknowledgement burned authority. T2 writer `muqsi390-6-1ko6` returned 860 authored lines: 50 POSIX tests/71 combined tests, shell syntax and whitespace passed. Parent read back the shell/helper and found a real upstream metadata spelling mismatch (`>=22.13` versus `>=22.13.0`) that would reject legitimate pnpm. Bounded correction `muqt9gtx-7-ddg0` completed: regression RED 49/52 (three intended failures), GREEN 52/52 and combined 73/73. Literal upstream metadata now differs safely from normalized comparison; integrity/identity unchanged. Parent staged the five intended files and native ASSESS measured 902 lines/high risk (shell/process boundary), requiring independent verification. Verifier `muqte4m0-8-szun` passed 73 tests/syntax/whitespace, but its exactly authorized disposable probe reported `bounded(1) still running after 2500ms: true`, then outer SIGKILL ended the fresh detached child group. Shell sends TERM once and waits without escalation; helper spawnSync has an analogous static-only concern, not dynamically reproduced. Ledger MM drift was attributed to parent-only status edits; source/docs/tests remained stable. T2 remains open. User explicitly selected `fix_timeouts` over pause. Deadline writer `muqts0u4-9-0imq` returned the bounded correction (153 authored lines): RED 53/55 required external guards, GREEN shell failed at 10.03s/Node at 15.07s; 55/55 POSIX and 76/76 combined passed. Parent read back the changed watchdog/process/test paths and restaged the five intended files; ASSESS remains high (1041 lines), requiring final independent verification. Guarantees cover owned direct, non-forking probes only; descendant stdio/process-tree cancellation is not established. No automatic further correction loop is allowed. Native review/work-unit commit remain pending. Bootstrap targets an extracted/checkout bundle; no published bundle URL is invented. ## Work-unit evidence -### T1 (not closed) +### T1 (closed) - Writer: `muqq4ay0-4-dazo`; authorized files only; 342 authored additions. - RED: focused test command failed with `ERR_MODULE_NOT_FOUND` before implementation. - GREEN/refactor: `node --experimental-strip-types --test tests/installer-preflight.test.ts` passed 21/21; writer corrected a setup-action test expectation before final GREEN. - Writer whitespace: `git diff --check` passed; new-file no-index checks had no whitespace findings. - Native ASSESS: unassessable on undeclared untracked scope, independent verifier required. INSPECT later declared intended paths and returned ready, no lineage created. - Independent verification: task `muqqewpj-5-xgbi` passed 21/21 focused tests and whitespace checks, without edits; no severe deterministic candidate-caused findings. Parent spot check reran the focused command and passed 21/21. -- Commit/review: pending; no claims of native OS install proof. +- Commit: `09860c05ee951cdfa85abfb4006c56ca577b28d3` (`feat(installer): add read-only dependency preflight`), 417 additions including the feature task document. +- Committed native ASSESS: medium, 4 paths/417 lines, `reviewDue=true` (`slice_budget_reached`). +- Review: `review-eda68029d737a4f8`, reliability capture approved; `acknowledge-approved` returned `native-approved-acknowledgement-completed`, authority burned for target `sha256:062d3e347226b65d79754b215d1b128677da7ef06c434611f4b04a7e793d45a9`. +- First START consent expired without invocation/lineage/mutation; native continuation explicitly allowed fresh START. Second START consent succeeded. No burned authority is reused. +- No claims of native OS install proof. diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 000000000..b8c5864ab --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,146 @@ +#!/bin/sh +# Run from an extracted/checkout bundle, not from a remote pipe. +# Only prerequisite tooling is acquired here; standard installation belongs to T4. +set -eu +umask 077 +fail() { printf '%s\n' "Bootstrap: $1" >&2; exit 1; } +need() { command -v "$1" >/dev/null 2>&1 || fail "Required utility missing: $1"; } +for utility in dirname awk; do need "$utility"; done +bundle=$(CDPATH= cd -P "$(dirname "$0")/.." && pwd) || fail 'Cannot locate installation bundle' +entry=$bundle/bin/gentle-shell-install.mjs +[ -f "$entry" ] && [ ! -L "$entry" ] || fail 'Future wizard entry is missing; this bundle cannot start a live wizard' +[ -f "$bundle/scripts/installer-downloads.mjs" ] || fail 'Download helper missing from bundle' +for utility in uname mkdir mktemp chmod mv rm sleep wc id ls; do need "$utility"; done +case ${HOME:-} in /*) ;; *) fail 'HOME must be an absolute per-user directory';; esac +# Refuse symlink ancestors and group/world-writable acquisition parents. +check_home=$HOME +while [ "$check_home" != / ]; do + [ ! -L "$check_home" ] || fail 'Unsafe symlink in HOME path' + check_home=$(dirname "$check_home") +done +[ -d "$HOME" ] || fail 'HOME directory is missing' +uid=$(id -u) +ls -nd "$HOME" | awk -v uid="$uid" '$3 == uid && substr($1,6,1) != "w" && substr($1,9,1) != "w" {ok=1} END {exit !ok}' || fail 'Unsafe HOME ownership or permissions' +minimum=$(awk -F '"' '{for (i=2; i<=NF-2; i+=2) if ($i == "node" && $(i+2) ~ /^>=[0-9]+\.[0-9]+\.[0-9]+$/) {value=$(i+2); sub(/^>=/, "", value); count++}} END {if (count == 1) print value}' "$bundle/package.json") +[ -n "$minimum" ] || fail 'Cannot establish repository Node minimum' +version_ok() { + awk -v value="$1" -v minimum="$2" 'BEGIN { + sub(/^v/, "", value) + if (value !~ /^[0-9]+\.[0-9]+\.[0-9]+$/) exit 1 + split(value, a, "."); split(minimum, b, ".") + for (i=1; i<=3; i++) if (length(a[i]) > 9 || (length(a[i]) > 1 && substr(a[i],1,1) == "0")) exit 1 + for (i=1; i<=3; i++) { + if (a[i]+0 > b[i]+0) exit 0 + if (a[i]+0 < b[i]+0) exit 1 + } + }' +} +# A stock POSIX watchdog avoids assuming GNU timeout on macOS. +# These fixed prerequisite probes get an unignorable deadline, not graceful TERM. +# Signal only our direct child PID; do not kill inherited process groups. +bounded() { + seconds=$1 + shift + "$@" & + version_pid=$! + ( + sleep "$seconds" & + sleeper=$! + trap 'kill -s KILL "$sleeper" 2>/dev/null || :; wait "$sleeper" 2>/dev/null || :; exit 0' TERM + wait "$sleeper" || : + kill -s KILL "$version_pid" 2>/dev/null || : + ) >/dev/null 2>&1 & + watchdog=$! + result=0 + wait "$version_pid" || result=$? + kill "$watchdog" 2>/dev/null || : + wait "$watchdog" 2>/dev/null || : + return "$result" +} +tools= +owned=0 +cleanup() { + if [ "$owned" = 1 ] && [ -n "$tools" ] && [ ! -L "$tools" ] && [ -d "$tools/.claim" ] && [ ! -L "$tools/.claim" ]; then + rm -rf "$tools" + fi +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM +make_tools() { + tools=$(mktemp -d "$HOME/.gentle-shell-bootstrap-tools.XXXXXXXX") || fail 'Cannot create private tooling directory' + case "$tools" in "$HOME"/.gentle-shell-bootstrap-tools.*) ;; *) fail 'Conflicting tooling destination';; esac + [ -d "$tools" ] && [ ! -L "$tools" ] || fail 'Unsafe tooling destination' + ls -nd "$tools" | awk -v uid="$uid" '$3 == uid && $1 == "drwx------" {ok=1} END {exit !ok}' || fail 'Unsafe tooling ownership' + # No existing destination is ever reused or replaced. + mkdir "$tools/.claim" || fail 'Conflicting tooling destination' + owned=1 + printf '%s\n' 'gentle-pi prerequisite tooling only' > "$tools/.bootstrap-owned" || fail 'Cannot mark tooling ownership' +} +if command -v node >/dev/null 2>&1; then + node=$(command -v node) + version=$(bounded 10 "$node" --version 2>/dev/null) || fail 'Existing Node version probe failed' + version_ok "$version" "$minimum" || fail 'Existing Node is incompatible or unknown; refusing replacement' +else + os=$(uname -s) + arch=$(uname -m) + case "$os" in Darwin) os=darwin;; Linux) os=linux;; *) fail 'Unsupported POSIX platform';; esac + case "$arch" in x86_64|amd64) arch=x64;; aarch64|arm64) arch=arm64;; *) fail 'Unsupported architecture';; esac + if [ "$os" = linux ]; then + need getconf + libc=$(getconf GNU_LIBC_VERSION 2>/dev/null) || fail 'Cannot establish native glibc compatibility; musl is not supported' + case "$libc" in 'glibc '*) ;; *) fail 'Unsupported libc; only native glibc acquisition is planned';; esac + version_ok "${libc#glibc }.0" '2.28.0' || fail 'Native Node requires glibc >=2.28; musl is not supported' + fi + # Primary source: https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt + # Exact release URLs are fixed, even though the provenance page is moving. + case "$os-$arch" in + darwin-arm64) hash=bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057;; + darwin-x64) hash=1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097;; + linux-arm64) hash=724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5;; + linux-x64) hash=6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff;; + esac + need curl + need tar + if command -v sha256sum >/dev/null 2>&1; then hasher=sha256sum + elif command -v shasum >/dev/null 2>&1; then hasher=shasum + else fail 'Required SHA256 utility missing: sha256sum or shasum'; fi + make_tools + stem=node-v24.21.0-$os-$arch + archive=$tools/node.tar.gz + # Also bound disk use on older curl versions lacking streamed-size enforcement. + # POSIX uses 512-byte blocks; shells using KiB still impose a 200-MiB ceiling. + ( + ulimit -f 204800 || exit 1 + curl --fail --silent --show-error --proto '=https' --tlsv1.2 --connect-timeout 10 --max-time 120 --max-filesize 104857600 --output "$archive" "https://nodejs.org/dist/v24.21.0/$stem.tar.gz" + ) 2>/dev/null || fail 'Node download failed, was truncated, or its file-size bound is unavailable' + size=$(wc -c < "$archive") + [ "$size" -gt 0 ] && [ "$size" -le 104857600 ] || fail 'Node download size is invalid' + if [ "$hasher" = sha256sum ]; then actual=$(bounded 30 sha256sum "$archive" 2>/dev/null) || fail 'SHA256 process failed' + else actual=$(bounded 30 shasum -a 256 "$archive" 2>/dev/null) || fail 'SHA256 process failed'; fi + actual=${actual%% *} + # Hash tools prefix escaped filenames (e.g. newline/backslash) with a backslash. + actual=${actual#\\} + [ "$actual" = "$hash" ] || fail 'Node checksum integrity mismatch' + mkdir "$tools/staging" || fail 'Conflicting staging destination' + # Extract only the trusted regular executable, not npm/Corepack or archive links. + member=$stem/bin/node + listing=$(bounded 30 tar -tvzf "$archive" "$member" 2>/dev/null) || fail 'Node archive is invalid' + case "$listing" in -*) ;; *) fail 'Node archive executable is not a regular file';; esac + bounded 30 tar -xzf "$archive" -C "$tools/staging" "$member" 2>/dev/null || fail 'Node extraction failed' + candidate=$tools/staging/$member + [ -f "$candidate" ] && [ ! -L "$candidate" ] || fail 'Node executable missing or unsafe' + version=$(bounded 10 "$candidate" --version 2>/dev/null) || fail 'Acquired Node cannot execute on this native platform' + [ "$version" = v24.21.0 ] && version_ok "$version" "$minimum" || fail 'Acquired Node version rejected' + bounded 10 chmod 700 "$candidate" 2>/dev/null || fail 'Cannot secure acquired Node executable' + [ ! -e "$tools/node" ] && [ ! -L "$tools/node" ] || fail 'Conflicting Node destination' + mv "$tools/staging/$stem" "$tools/node" || fail 'Cannot publish verified Node' + rm -f "$archive" || fail 'Cannot clean owned Node archive' + rm -rf "$tools/staging" || fail 'Cannot clean owned Node staging' + node=$tools/node/bin/node + PATH=$tools/node/bin:$PATH + export PATH +fi +# The helper creates an equally private tooling directory only if pnpm is absent. +# Do not exec: retain ownership cleanup on any helper/child failure. +"$node" "$bundle/scripts/installer-downloads.mjs" --bootstrap "$bundle" "$tools" || fail 'Prerequisite acquisition or wizard child failed' +owned=0 diff --git a/scripts/installer-downloads.mjs b/scripts/installer-downloads.mjs new file mode 100644 index 000000000..134c19855 --- /dev/null +++ b/scripts/installer-downloads.mjs @@ -0,0 +1,255 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { lstatSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, renameSync, rmSync } from "node:fs"; +import { dirname, join, resolve, delimiter } from "node:path"; +import { spawnSync, spawn } from "node:child_process"; +import { pathToFileURL } from "node:url"; + +// Acquisition pins, not compatibility minima. Sources verified before pinning: +// https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt +// https://registry.npmjs.org/pnpm/11.1.1 (dist.integrity and engines.node) +const nodeHashes = Object.freeze({ + "darwin-arm64": "bed7eea5325e1108f32ce5228ddd6a5f0f08a499ee42aa7442aea583702f6057", + "darwin-x64": "1462cb3b3046b815cf8ea436d3da450ec1a9f11dac7e5a46b0ada5305d7e8097", + "linux-arm64": "724282c3b43aec998aa9527380465b45d229e021b58035f5f4f63095eabfe5d5", + "linux-x64": "6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff", +}); +const pnpm = Object.freeze({ + name: "pnpm", version: "11.1.1", engine: ">=22.13", + url: "https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz", + integrity: "sha512-0f319zxhe2T6GlaoHDyN/g6WbjOmAQqiVrUXrne+Idk+Ba/8DeGoOw5PKdVp9otEaujwaM1yR8C7PfD7TXvfmg==", + maxBytes: 32 * 1024 * 1024, +}); + +/** Fixed allowlist; callers cannot supply download URLs, checksums or commands. + * Node descriptors are reusable by T3; this unit intentionally has no Windows pin. + */ +export function artifactFor(name, platform, arch) { + if (name === "pnpm") return pnpm; + const hash = nodeHashes[`${platform}-${arch}`]; + if (name !== "node" || !hash) throw new Error("Unsupported acquisition target"); + return Object.freeze({ name, version: "24.21.0", platform, arch, + url: `https://nodejs.org/dist/v24.21.0/node-v24.21.0-${platform}-${arch}.tar.gz`, + integrity: `sha256-${hash}`, maxBytes: 100 * 1024 * 1024 }); +} + +async function download(descriptor) { + const response = await fetch(descriptor.url, { redirect: "error", signal: AbortSignal.timeout(60000) }); + if (!response.ok || !response.body) throw new Error("Download failed"); + const advertised = response.headers.get("content-length"); + if (advertised && (!/^\d+$/.test(advertised) || Number(advertised) > descriptor.maxBytes)) { + await response.body.cancel(); + throw new Error("Download size rejected"); + } + const reader = response.body.getReader(); + const chunks = []; + let size = 0; + try { + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > descriptor.maxBytes) throw new Error("Download size rejected"); + chunks.push(Buffer.from(value)); + } + if (advertised && Number(advertised) !== size) throw new Error("Download truncated"); + return Buffer.concat(chunks, size); + } finally { + await reader.cancel().catch(() => {}); + } +} + +/** Pure descriptor selection + injected byte transport/digest for deterministic tests. + * Production always hashes actual bytes; test adapters are trusted local code only. + * No extraction or execution is allowed before this function succeeds. + */ +export async function verifiedDownload(name, adapters = {}, platform, arch) { + const descriptor = artifactFor(name, platform, arch); + let bytes; + try { bytes = await (adapters.download ?? download)(descriptor); } + catch { throw new Error("Download failed"); } + if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > descriptor.maxBytes) throw new Error("Download size rejected"); + const [algorithm, encoded] = descriptor.integrity.split("-"); + const expected = Buffer.from(encoded, algorithm === "sha256" ? "hex" : "base64"); + const actual = (adapters.digest ?? ((data, hash) => createHash(hash).update(data).digest()))(bytes, algorithm); + if (!Buffer.isBuffer(actual) || actual.length !== expected.length || !timingSafeEqual(actual, expected)) { + throw new Error("Download integrity mismatch"); + } + return bytes; +} + +function parts(version) { + if (typeof version !== "string" || !/^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(version)) return null; + const values = version.replace(/^v/, "").split(".").map(Number); + return values.every(Number.isSafeInteger) ? values : null; +} +export function compatibleEngine(range, version) { + if (typeof range !== "string") return false; + // Preserve raw pin metadata; normalize only simple >=major.minor[.patch] bounds. + const bound = /^>=(0|[1-9]\d*)\.(0|[1-9]\d*)(?:\.(0|[1-9]\d*))?$/.exec(range); + if (!bound) return false; + const required = parts(`${bound[1]}.${bound[2]}.${bound[3] ?? "0"}`); + const actual = parts(version); + if (!required || !actual) return false; + for (let index = 0; index < 3; index += 1) { + if (actual[index] !== required[index]) return actual[index] > required[index]; + } + return true; +} +function stat(path) { + try { return lstatSync(path); } + catch (error) { if (error.code === "ENOENT") return null; throw error; } +} +function regular(path) { + const info = stat(path); + return info?.isFile() && !info.isSymbolicLink(); +} +function processCheck(command, args, env) { + // TERM can be ignored, leaving spawnSync blocked beyond its timeout. Kill only + // the spawned prerequisite child; this is not process-tree cancellation. + const result = spawnSync(command, args, { env, encoding: "utf8", timeout: 15000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, windowsHide: true }); + if (result.error || result.status !== 0) throw new Error("Prerequisite process check failed"); + return result.stdout.trim(); +} +function findExecutable(name, env) { + for (const directory of (env.PATH ?? "").split(delimiter)) { + // Empty/relative PATH entries cannot prove executable ownership. + if (!directory.startsWith("/")) throw new Error("Unknown prerequisite PATH compatibility"); + const path = join(directory, name); + if (stat(path)) return path; + } + return null; +} +function packageFor(command) { + let directory = dirname(realpathSync(command)); + for (let depth = 0; depth < 4; depth += 1) { + const file = join(directory, "package.json"); + if (regular(file)) { + const metadata = JSON.parse(readFileSync(file, "utf8")); + if (metadata.name === "pnpm") return metadata; + } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + throw new Error("Existing pnpm compatibility is unknown: package engine evidence missing"); +} +function provePnpm(command, prefix, metadata, nodeVersion, env, processAdapter) { + if (!parts(metadata.version) || !compatibleEngine(metadata.engines?.node, nodeVersion)) { + throw new Error("pnpm compatibility is unknown or incompatible; refusing replacement"); + } + if (processAdapter(command, [...prefix, "--version"], env) !== metadata.version) throw new Error("pnpm version rejected"); + // Read-only CLI capability checks; do not execute add/bin or write global config. + for (const capability of ["add", "bin"]) { + if (!/(?:^|[\s,])--global(?:[\s,=]|$)/.test(processAdapter(command, [...prefix, "help", capability], env))) { + throw new Error("pnpm global-install capability evidence missing"); + } + } +} +function privateTools(home) { + if (!home || !home.startsWith("/") || realpathSync(home) !== resolve(home)) throw new Error("Unsafe tooling HOME"); + const info = lstatSync(home); + if (!info.isDirectory() || info.uid !== process.getuid() || (info.mode & 0o022)) throw new Error("Unsafe tooling HOME ownership"); + const tools = mkdtempSync(join(home, ".gentle-shell-bootstrap-tools.")); + try { + writeFileSync(join(tools, ".bootstrap-owned"), "gentle-pi prerequisite tooling only\n", { mode: 0o600, flag: "wx" }); + return tools; + } catch { + rmSync(tools, { recursive: true, force: true }); + throw new Error("Unsafe tooling ownership marker could not be created"); + } +} + +/** Reuse only proven engines/capabilities; missing pnpm gets verified registry bytes. + * Local test adapters cover transport/hash/process. There is no remote command API. + * `tools` must be a private directory owned by the invoking bootstrap/controller. + */ +export async function ensurePnpm({ tools, env, nodeVersion, adapters = {} }) { + const processAdapter = adapters.process ?? processCheck; + const existing = findExecutable("pnpm", env); + if (existing) { + provePnpm(existing, [], packageFor(existing), nodeVersion, env, processAdapter); + return { env, acquired: false }; + } + const info = stat(tools); + if (!info?.isDirectory() || info.isSymbolicLink() || realpathSync(tools) !== resolve(tools) || info.uid !== process.getuid() || (info.mode & 0o077)) { + throw new Error("Unsafe private tooling destination"); + } + const destination = join(tools, "pnpm"); + if (stat(destination)) throw new Error("Conflicting pnpm destination"); + const tar = findExecutable("tar", env); + if (!tar) throw new Error("Required utility missing: tar"); + const stage = mkdtempSync(join(tools, "pnpm-stage-")); + let published = false; + try { + const archive = join(stage, "pnpm.tgz"); + writeFileSync(archive, await verifiedDownload("pnpm", adapters), { flag: "wx", mode: 0o600 }); + const names = processAdapter(tar, ["-tzf", archive], env).split("\n"); + if (!names.length || names.some((name) => !name.startsWith("package/") || name.split("/").includes("..") || name.includes("\\"))) { + throw new Error("Unsafe pnpm archive paths"); + } + const types = processAdapter(tar, ["-tvzf", archive], env).split("\n"); + if (types.some((line) => !/^[d-]/.test(line))) throw new Error("Unsafe pnpm archive links"); + processAdapter(tar, ["-xzf", archive, "-C", stage], env); + const pkg = join(stage, "package"); + const entry = join(pkg, "bin/pnpm.mjs"); + if (!regular(entry) || !regular(join(pkg, "package.json"))) throw new Error("pnpm archive entry missing"); + const metadata = JSON.parse(readFileSync(join(pkg, "package.json"), "utf8")); + if (metadata.name !== "pnpm" || metadata.version !== pnpm.version || metadata.engines?.node !== pnpm.engine) throw new Error("pnpm pin metadata rejected"); + provePnpm(process.execPath, [entry], metadata, nodeVersion, env, processAdapter); + // mkdir is the atomic no-clobber claim (rename alone can replace directories). + mkdirSync(destination, { mode: 0o700 }); + published = true; + renameSync(pkg, join(destination, "package")); + mkdirSync(join(destination, "bin"), { mode: 0o700 }); + const quote = (value) => `'${value.replaceAll("'", "'\\''")}'`; + writeFileSync(join(destination, "bin/pnpm"), `#!/bin/sh\nexec ${quote(process.execPath)} ${quote(join(destination, "package/bin/pnpm.mjs"))} "$@"\n`, { flag: "wx", mode: 0o700 }); + return { acquired: true, env: { ...env, PATH: `${join(destination, "bin")}${delimiter}${env.PATH}` } }; + } catch { + if (published) rmSync(destination, { recursive: true, force: true }); + throw new Error("pnpm verified acquisition failed; no installation completed"); + } finally { + rmSync(stage, { recursive: true, force: true }); + } +} + +/** No shell command or URL from the caller is executed: entry is fixed in bundle. */ +export async function launchWizard({ bundle, env }) { + const entry = join(bundle, "bin/gentle-shell-install.mjs"); + if (!regular(entry)) throw new Error("Future wizard entry is missing; no live wizard is available"); + await new Promise((resolveChild, reject) => { + const child = spawn(process.execPath, [entry], { env, stdio: "inherit", shell: false }); + child.once("error", () => reject(new Error("Wizard child could not start"))); + child.once("exit", (code) => code === 0 ? resolveChild() : reject(new Error("Wizard child failed"))); + }); +} + +async function bootstrap(bundle, suppliedTools) { + const entry = join(bundle, "bin/gentle-shell-install.mjs"); + if (!regular(entry)) throw new Error("Future wizard entry is missing"); + const metadata = JSON.parse(readFileSync(join(bundle, "package.json"), "utf8")); + if (!compatibleEngine(metadata.engines?.node, process.versions.node)) throw new Error("Node requirement rejected"); + if (metadata.packageManager !== `pnpm@${pnpm.version}`) throw new Error("pnpm acquisition pin differs from repository; update verified descriptors first"); + let tools = suppliedTools; + let created = false; + try { + if (!findExecutable("pnpm", process.env) && !tools) { tools = privateTools(process.env.HOME); created = true; } + const result = await ensurePnpm({ tools, env: process.env, nodeVersion: process.versions.node }); + await launchWizard({ bundle, env: result.env }); + } catch (error) { + if (created) rmSync(tools, { recursive: true, force: true }); + throw error; + } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + if (process.argv.length !== 5 || process.argv[2] !== "--bootstrap") { + console.error("Use the bundle's POSIX bootstrap script"); + process.exitCode = 1; + } else { + bootstrap(process.argv[3], process.argv[4]).catch((error) => { + // Only controlled errors are emitted; never print URL/process adapter errors. + const safe = /^(Future wizard|Node requirement|pnpm |Existing pnpm|Unknown prerequisite|Unsafe tooling|Wizard child|Prerequisite process|Required utility)/.test(error.message); + console.error(safe ? error.message : "Prerequisite bootstrap failed"); + process.exitCode = 1; + }); + } +} diff --git a/tests/installer-posix-bootstrap.test.ts b/tests/installer-posix-bootstrap.test.ts new file mode 100644 index 000000000..399c77b71 --- /dev/null +++ b/tests/installer-posix-bootstrap.test.ts @@ -0,0 +1,470 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, readdirSync, rmSync, lstatSync, unlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import { createHash } from "node:crypto"; +import test from "node:test"; +import { artifactFor, verifiedDownload, compatibleEngine, ensurePnpm, launchWizard } from "../scripts/installer-downloads.mjs"; + +const script = resolve("scripts/bootstrap.sh"); +const helper = resolve("scripts/installer-downloads.mjs"); +const node = process.execPath; +function fixture() { + const root = mkdtempSync(join(tmpdir(), "bootstrap space ü-")); + const bin = join(root, "utilities"); + const home = join(root, "home ü"); + const bundle = join(root, "bundle ü"); + for (const dir of [bin, home, join(bundle, "scripts"), join(bundle, "bin")]) mkdirSync(dir, { recursive: true, mode: 0o700 }); + writeFileSync(join(bundle, "scripts/bootstrap.sh"), readFileSync(script)); + writeFileSync(join(bundle, "scripts/installer-downloads.mjs"), readFileSync(helper)); + writeFileSync(join(bundle, "package.json"), JSON.stringify({ engines: { node: ">=22.19.0" }, packageManager: "pnpm@11.1.1" })); + for (const name of ["dirname", "pwd", "awk", "mkdir", "mktemp", "chmod", "mv", "rm", "sleep", "wc", "cat", "id", "ls"]) + symlinkSync(`/usr/bin/${name}`, join(bin, name)); + const executable = (name: string, body: string) => { + const path = join(bin, name); + if (existsSync(path) && lstatSync(path).isSymbolicLink()) unlinkSync(path); + writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); + }; + executable("uname", 'case "$1" in -s) printf "%s\\n" "${FAKE_OS:-Linux}";; -m) printf "%s\\n" "${FAKE_ARCH:-x86_64}";; esac'); + executable("getconf", 'printf "%s\\n" "${FAKE_LIBC:-glibc 2.36}"'); + executable("curl", 'while [ "$#" -gt 0 ]; do if [ "$1" = "--output" ]; then shift; out=$1; fi; shift; done\nprintf archive > "$out"\nexit "${DOWNLOAD_STATUS:-0}"'); + executable("sha256sum", 'printf "%s archive\\n" "${FAKE_HASH:-6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff}"'); + const nodeBody = `#!/bin/sh\nif [ "$1" = --version ]; then\nif [ -n "\${NODE_PROBE_SCRIPT:-}" ]; then exec '${node}' "$NODE_PROBE_SCRIPT"; fi\nprintf '%s\\n' "\${NODE_VERSION:-v24.21.0}"; else exec '${node}' "$@"; fi\n`; + executable("tar", `if [ "$1" = -tzf ]; then printf '%s\\n' "$3"; elif [ "$1" = -tvzf ]; then printf '%s\\n' '-rwxr-xr-x node'; else\nwhile [ "$#" -gt 0 ]; do if [ "$1" = -C ]; then shift; target=$1; fi; member=$1; shift; done\nmkdir -p "$target/\${member%/node}"\ncat > "$target/$member" <<'NODE'\n${nodeBody}NODE\nchmod 700 "$target/$member"\nfi`); + const addNode = () => writeFileSync(join(bin, "node"), nodeBody, { mode: 0o755 }); + const addPnpm = (version = "11.1.1", engine = ">=22.13.0") => { + const pkg = join(root, "pnpm package"); + mkdirSync(join(pkg, "bin"), { recursive: true }); + writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "pnpm", version, engines: { node: engine } })); + writeFileSync(join(pkg, "bin/pnpm.mjs"), `#!/bin/sh\ncase "$1" in --version) echo '${version}';; help) echo ' --global '; esac\n`, { mode: 0o755 }); + symlinkSync(join(pkg, "bin/pnpm.mjs"), join(bin, "pnpm")); + }; + const wizard = () => writeFileSync(join(bundle, "bin/gentle-shell-install.mjs"), "console.log('wizard-child:' + process.env.PATH);\n"); + const run = (extra: Record = {}) => spawnSync("/bin/sh", [join(bundle, "scripts/bootstrap.sh")], { + env: { PATH: bin, HOME: home, ...extra }, encoding: "utf8", timeout: 15000, + }); + return { root, bin, home, bundle, executable, addNode, addPnpm, wizard, run, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +for (const [os, arch, target] of [["darwin", "x64", "darwin-x64"], ["darwin", "arm64", "darwin-arm64"], ["linux", "x64", "linux-x64"], ["linux", "arm64", "linux-arm64"]]) { + test(`fixed Node descriptor for ${target}`, () => { + const artifact = artifactFor("node", os, arch); + assert.match(artifact.url, new RegExp(`^https://nodejs.org/dist/v24\\.21\\.0/node-v24\\.21\\.0-${target}\\.tar\\.gz$`)); + assert.match(artifact.integrity, /^sha256-[a-f0-9]{64}$/); + }); +} +test("unknown targets and caller URLs are rejected", () => { + assert.throws(() => artifactFor("node", "linux", "ia32"), /Unsupported/); + assert.throws(() => artifactFor("https://untrusted.example"), /Unsupported/); +}); +test("engine evidence supports stable lower bounds, not guessed ranges", () => { + assert.equal(compatibleEngine(">=22.13.0", "24.21.0"), true); + assert.equal(compatibleEngine(">=25.0.0", "24.21.0"), false); + assert.equal(compatibleEngine("*", "24.21.0"), false); + assert.equal(compatibleEngine(">=22.13.0", "24.0.0-rc.1"), false); +}); +test("pnpm descriptor preserves the literal upstream Node engine", () => { + assert.equal(artifactFor("pnpm").engine, ">=22.13"); +}); +test("simple partial engine minima normalize only for stable version comparison", () => { + assert.equal(compatibleEngine(">=22.13", "22.13.0"), true); + assert.equal(compatibleEngine(">=22.13", "24.21.0"), true); + for (const version of ["22.12.0", "22.12.99", "22.13.0-rc.1", "24.0.0-rc.1", "unknown", "22.13"]) { + assert.equal(compatibleEngine(">=22.13", version), false, version); + } + for (const range of ["*", ">=22", ">=22.13 || >=24", ">=22.13 <25", "^22.13", ">=22.x", ">=22.13-rc.1", ">=022.13", ">=22.013", ">=22.13.00", ">=9007199254740992.13"]) { + assert.equal(compatibleEngine(range, "24.21.0"), false, range); + } +}); +test("download helper fails closed on integrity, empty body and adapter failure", async () => { + for (const download of [async () => Buffer.from("truncated"), async () => Buffer.alloc(0), async () => { throw Error("failure"); }]) { + await assert.rejects(verifiedDownload("pnpm", { download }), /Download|integrity/); + } +}); +test("download helper validates bytes before returning them", async () => { + const bytes = Buffer.from("verified fixture"); + let observed = ""; + const result = await verifiedDownload("pnpm", { + download: async (descriptor: { url: string }) => { observed = descriptor.url; return bytes; }, + digest: () => Buffer.from(artifactFor("pnpm").integrity.slice(7), "base64"), + }); + assert.equal(result, bytes); + assert.equal(observed, "https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz"); + assert.notEqual(createHash("sha512").update(bytes).digest("base64"), artifactFor("pnpm").integrity.slice(7)); +}); + +test("compatible existing tools are reused with whitespace and Unicode paths", () => { + const f = fixture(); + try { + f.addNode(); f.addPnpm("12.0.0"); f.wizard(); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /wizard-child:/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("missing wizard fails explicitly without acquiring or claiming installation", () => { + const f = fixture(); + try { + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /wizard entry.*missing/i); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("no initial Node acquires verified native binary and refreshes child PATH", () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + const result = f.run(); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, /wizard-child:.*bootstrap-tools.*node\/bin/); + assert.equal(readdirSync(f.home).length, 1); + } finally { f.cleanup(); } +}); +for (const extra of [{ FAKE_HASH: "0".repeat(64) }, { DOWNLOAD_STATUS: "18" }, { NODE_VERSION: "v21.0.0" }, { NODE_VERSION: "unknown" }, { FAKE_LIBC: "musl" }]) { + test(`acquisition fails closed: ${JSON.stringify(extra)}`, () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + const result = f.run(extra); + assert.notEqual(result.status, 0); + assert.doesNotMatch(result.stdout, /wizard-child/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } + }); +} +for (const version of ["v22.18.0", "v24.1.0-rc.1", "v25.00.1", "banana"]) { + test(`existing Node ${version} is rejected without replacement`, () => { + const f = fixture(); + try { + f.addNode(); f.addPnpm(); f.wizard(); + const result = f.run({ NODE_VERSION: version }); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Node.*incompatible|Node.*unknown/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } + }); +} +test("existing pnpm with unknown engine blocks instead of acquisition", () => { + const f = fixture(); + try { + f.addNode(); f.addPnpm("12.0.0", "*"); f.wizard(); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /pnpm.*compatibility/i); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("missing required shell utility is named", () => { + const f = fixture(); + try { + f.wizard(); + unlinkSync(join(f.bin, "curl")); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Required utility missing: curl/); + } finally { f.cleanup(); } +}); +test("symlink HOME and conflicting staging are refused without touching targets", () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + const link = join(f.root, "linked-home"); symlinkSync(f.home, link); + assert.match(f.run({ HOME: link }).stderr, /symlink|unsafe/i); + f.executable("mktemp", `printf '%s\\n' '${f.bin}'`); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.ok(existsSync(join(f.bin, "pnpm"))); + } finally { f.cleanup(); } +}); +test("pnpm acquisition refuses conflicting and symlink destinations before download", async () => { + const f = fixture(); + try { + mkdirSync(join(f.home, "pnpm")); + let downloads = 0; + await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: { + download: async () => { downloads += 1; return Buffer.alloc(0); }, + } }), /destination/); + assert.equal(downloads, 0); + } finally { f.cleanup(); } +}); +test("wizard child failure is propagated and missing entry is explicit", async () => { + await assert.rejects(launchWizard({ bundle: "/missing-bundle", env: {} }), /wizard entry.*missing/i); + const f = fixture(); + try { + writeFileSync(join(f.bundle, "bin/gentle-shell-install.mjs"), "process.exit(7);\n"); + await assert.rejects(launchWizard({ bundle: f.bundle, env: {} }), /Wizard child failed/); + f.addPnpm(); + assert.notEqual(f.run().status, 0); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); + +for (const [os, arch, platform, architecture] of [["Darwin", "arm64", "darwin", "arm64"], ["Darwin", "x86_64", "darwin", "x64"], ["Linux", "aarch64", "linux", "arm64"]]) { + test(`shell artifact selection ${os}/${arch} (simulated, not native proof)`, () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + const hash = artifactFor("node", platform, architecture).integrity.slice(7); + assert.equal(f.run({ FAKE_OS: os, FAKE_ARCH: arch, FAKE_HASH: hash }).status, 0); + } finally { f.cleanup(); } + }); +} +for (const extra of [{ FAKE_ARCH: "riscv64" }, { FAKE_OS: "FreeBSD" }, { FAKE_LIBC: "glibc 2.27" }]) { + test(`unsupported acquisition blocks before download: ${JSON.stringify(extra)}`, () => { + const f = fixture(); + try { + f.wizard(); + assert.notEqual(f.run(extra).status, 0); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } + }); +} +for (const [utility, body, message] of [ + ["curl", 'while [ "$#" -gt 0 ]; do if [ "$1" = --output ]; then shift; out=$1; fi; shift; done; : > "$out"', /download/i], + ["sha256sum", "exit 2", /SHA256 process failed/], + ["tar", "exit 2", /archive is invalid/], + ["tar", "echo 'lrwxrwxrwx malicious-link'", /not a regular file/], +]) { + test(`native acquisition process rejection: ${utility}/${message}`, () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); f.executable(utility, body); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, message); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } + }); +} + +interface AcquisitionOptions { + names?: string; + types?: string; + version?: string; + help?: string; + failProcess?: boolean; +} +function acquisitionAdapters(options: AcquisitionOptions = {}) { + const calls: string[] = []; + return { + calls, + download: async () => Buffer.from("verified fixture"), + digest: () => Buffer.from(artifactFor("pnpm").integrity.slice(7), "base64"), + process: (command: string, args: string[]) => { + calls.push(`${command}:${args[0]}`); + if (options.failProcess) throw Error("injected process failure"); + if (args[0] === "-tzf") return options.names ?? "package/\npackage/package.json\npackage/bin/pnpm.mjs"; + if (args[0] === "-tvzf") return options.types ?? "drwx------ package/\n-rw------- package/package.json\n-rwx------ package/bin/pnpm.mjs"; + if (args[0] === "-xzf") { + const pkg = join(args[3], "package"); + mkdirSync(join(pkg, "bin"), { recursive: true, mode: 0o700 }); + // Literal parent-verified upstream metadata, intentionally not descriptor-derived. + writeFileSync(join(pkg, "package.json"), JSON.stringify({ name: "pnpm", version: "11.1.1", engines: { node: ">=22.13" } })); + writeFileSync(join(pkg, "bin/pnpm.mjs"), "console.log(process.argv.includes('--version') ? '11.1.1' : '--global');\n"); + return ""; + } + if (args.includes("--version")) return options.version ?? "11.1.1"; + return options.help ?? "--global"; + }, + }; +} +test("verified pnpm with literal upstream >=22.13 engine publishes its owned wrapper", async () => { + const f = fixture(); + try { + const adapters = acquisitionAdapters(); + const result = await ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters }); + assert.equal(result.acquired, true); + assert.ok(result.env.PATH.startsWith(join(f.home, "pnpm/bin"))); + assert.deepEqual(readdirSync(f.home), ["pnpm"]); + const wrapper = join(f.home, "pnpm/bin/pnpm"); + assert.equal(spawnSync(wrapper, ["--version"], { encoding: "utf8" }).stdout.trim(), "11.1.1"); + assert.ok(adapters.calls.every((call) => call.startsWith(join(f.bin, "tar")) || call.startsWith(node))); + } finally { f.cleanup(); } +}); +for (const options of [{ names: "package/../escape" }, { names: "/absolute/path" }, { types: "lrwxrwxrwx package/link" }, { types: "hrw------- package/hardlink" }, { version: "11.1.0" }, { help: "--global-bin-dir" }, { failProcess: true }]) { + test(`pnpm extraction/validation fails without publication: ${JSON.stringify(options)}`, async () => { + const f = fixture(); + try { + await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: acquisitionAdapters(options) }), /acquisition failed/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } + }); +} +test("pnpm symlink conflict does not touch its target", async () => { + const f = fixture(); + try { + symlinkSync(f.bin, join(f.home, "pnpm")); + await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: acquisitionAdapters() }), /destination/); + assert.ok(existsSync(join(f.bin, "curl"))); + } finally { f.cleanup(); } +}); +test("native Node destination symlink is refused without following its target", () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + const tar = readFileSync(join(f.bin, "tar"), "utf8"); + f.executable("tar", tar.replace('mkdir -p "$target/', `/usr/bin/ln -s '${f.bin}' "$target/../node"\nmkdir -p "$target/`)); + const result = f.run(); + assert.notEqual(result.status, 0); + assert.match(result.stderr, /Conflicting Node destination/); + assert.ok(existsSync(join(f.bin, "pnpm"))); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("existing Node failed probe is not repaired", () => { + const f = fixture(); + try { + f.addNode(); f.wizard(); f.executable("node", "exit 4"); + assert.match(f.run().stderr, /Existing Node version probe failed/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("pnpm without required global capabilities is not replaced", () => { + const f = fixture(); + try { + f.addNode(); f.addPnpm(); f.wizard(); + writeFileSync(join(f.bin, "pnpm"), "#!/bin/sh\n[ \"$1\" != --version ] || echo 11.1.1\n", { mode: 0o755 }); + assert.match(f.run().stderr, /capability evidence missing/); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); +test("missing tar blocks pnpm before any download", async () => { + const f = fixture(); + try { + unlinkSync(join(f.bin, "tar")); + let downloaded = false; + await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: { + download: async () => { downloaded = true; return Buffer.alloc(0); }, + } }), /Required utility missing: tar/); + assert.equal(downloaded, false); + } finally { f.cleanup(); } +}); +test("oversized verified-download input is rejected before digest", async () => { + let hashed = false; + await assert.rejects(verifiedDownload("pnpm", { + download: async () => Buffer.alloc(artifactFor("pnpm").maxBytes + 1), + digest: () => { hashed = true; return Buffer.alloc(0); }, + }), /size rejected/); + assert.equal(hashed, false); +}); +interface GuardedResult { + status: number | null; + signal: string | null; + stdout: string; + stderr: string; + guardKilled: boolean; + elapsed: number; +} + +// Independent asynchronous guard: even a blocked spawnSync inside the fixture +// cannot block this test process. Only this fresh detached fixture group is killed. +async function guardedFixture(command: string, args: string[], env: Record, limit: number): Promise { + const start = Date.now(); + const child = spawn(command, args, { env, detached: true, stdio: ["ignore", "pipe", "pipe"] }); + const group = child.pid; + let stdout = ""; + let stderr = ""; + let guardKilled = false; + const killOwnedGroup = () => { + if (group === undefined) return; + try { process.kill(-group, "SIGKILL"); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } + }; + const timer = setTimeout(() => { guardKilled = true; killOwnedGroup(); }, limit); + child.stdout.on("data", (data: Buffer) => { stdout += data.toString(); }); + child.stderr.on("data", (data: Buffer) => { stderr += data.toString(); }); + try { + return await new Promise((resolveChild, reject) => { + child.once("error", reject); + child.once("close", (status, signal) => resolveChild({ status, signal, stdout, stderr, guardKilled, elapsed: Date.now() - start })); + }); + } finally { + clearTimeout(timer); + // Also clean residual fixture descendants if the group leader exits early. + killOwnedGroup(); + } +} +function termIgnoringProbe(root: string, output: string) { + const probe = join(root, "term-ignoring-probe.mjs"); + const pid = join(root, "owned-probe.pid"); + writeFileSync(probe, `import { writeFileSync } from 'node:fs';\nprocess.on('SIGTERM', () => {});\nwriteFileSync(process.env.PROBE_PID, String(process.pid));\nconsole.log(${JSON.stringify(output)});\nsetInterval(() => {}, 1000);\n`); + return { probe, pid }; +} +function assertProbeReaped(path: string) { + const pid = Number(readFileSync(path, "utf8")); + assert.ok(Number.isSafeInteger(pid) && pid > 0); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, "owned probe must be reaped before the parent returns"); +} + +test("production shell deadline rejects TERM-ignored acquired Node and cleans owned tooling", async () => { + const f = fixture(); + try { + f.addPnpm(); f.wizard(); + writeFileSync(join(f.home, "unrelated.txt"), "preserve"); + const { probe, pid } = termIgnoringProbe(f.root, "v24.21.0"); + const result = await guardedFixture("/bin/sh", [join(f.bundle, "scripts/bootstrap.sh")], { + PATH: f.bin, HOME: f.home, NODE_PROBE_SCRIPT: probe, PROBE_PID: pid, + }, 13000); + assert.equal(result.guardKilled, false, `shell required outer SIGKILL after ${result.elapsed}ms`); + assert.equal(result.status, 1); + assert.equal(result.signal, null); + assert.match(result.stderr, /Acquired Node cannot execute/); + assert.doesNotMatch(result.stdout, /wizard-child/); + assertProbeReaped(pid); + assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]); + assert.equal(readFileSync(join(f.home, "unrelated.txt"), "utf8"), "preserve"); + } finally { f.cleanup(); } +}); + +function productionPnpmRunner(root: string, home: string, bin: string) { + const runner = join(root, "production-pnpm-runner.mjs"); + // Inject only verified-byte transport/digest, not the production process adapter. + writeFileSync(runner, `import { artifactFor, ensurePnpm } from ${JSON.stringify(pathToFileURL(helper).href)};\ntry {\nawait ensurePnpm({ tools: ${JSON.stringify(home)}, env: { PATH: ${JSON.stringify(bin)}, PROBE_PID: process.env.PROBE_PID }, nodeVersion: '24.21.0', adapters: {\ndownload: async () => Buffer.from('verified fixture'),\ndigest: () => Buffer.from(artifactFor('pnpm').integrity.slice(7), 'base64'),\n} });\nconsole.log('unexpected acquisition success');\n} catch { console.error('acquisition rejected'); process.exitCode = 1; }\n`); + return runner; +} +test("production Node process deadline rejects TERM-ignored tar and cleans owned staging", async () => { + const f = fixture(); + try { + writeFileSync(join(f.home, "unrelated.txt"), "preserve"); + const { probe, pid } = termIgnoringProbe(f.root, "package/"); + f.executable("tar", `exec '${node}' '${probe}'`); + const result = await guardedFixture(node, [productionPnpmRunner(f.root, f.home, f.bin)], { PROBE_PID: pid }, 18000); + assert.equal(result.guardKilled, false, `Node process check required outer SIGKILL after ${result.elapsed}ms`); + assert.equal(result.status, 1); + assert.equal(result.signal, null); + assert.match(result.stderr, /acquisition rejected/); + assert.doesNotMatch(result.stdout, /unexpected acquisition success/); + assertProbeReaped(pid); + assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]); + assert.equal(readFileSync(join(f.home, "unrelated.txt"), "utf8"), "preserve"); + } finally { f.cleanup(); } +}); +test("production Node process adapter propagates ordinary nonzero tar exit with cleanup", async () => { + const f = fixture(); + try { + writeFileSync(join(f.home, "unrelated.txt"), "preserve"); + f.executable("tar", "exit 7"); + const result = await guardedFixture(node, [productionPnpmRunner(f.root, f.home, f.bin)], {}, 3000); + assert.equal(result.guardKilled, false); + assert.equal(result.status, 1); + assert.match(result.stderr, /acquisition rejected/); + assert.deepEqual(readdirSync(f.home), ["unrelated.txt"]); + } finally { f.cleanup(); } +}); + +test("failed pnpm download never reaches archive/process adapters", async () => { + const f = fixture(); + try { + let processes = 0; + await assert.rejects(ensurePnpm({ tools: f.home, env: { PATH: f.bin }, nodeVersion: "24.21.0", adapters: { + download: async () => Buffer.from("truncated"), + process: () => { processes += 1; return ""; }, + } }), /acquisition failed/); + assert.equal(processes, 0); + assert.deepEqual(readdirSync(f.home), []); + } finally { f.cleanup(); } +}); From 07e4971d7d447ec7b7541ddd03e7472c75e41404 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 13:02:37 +0200 Subject: [PATCH 03/24] docs(odd): record verified POSIX bootstrap work unit --- odd/tasks/browser-install-wizard.md | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index bc5becfe0..e688fc40a 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -21,9 +21,9 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Delivery and routing - Branch: `feat/browser-install-wizard`; verified default reference: `origin/main`. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. -- Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; correction size still pending. -- Running committed authored lines: 417 (T1 includes its feature task document). -- Last reviewed boundary: `09860c05ee951cdfa85abfb4006c56ca577b28d3`. +- Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. +- Running committed authored lines: 1,458 (T1 417 + T2 1,041, both including their task-document changes). +- Last reviewed source boundary: `03e770f75d5ce8a02f16d125b590e41c743c8101`; its exact staged tree matched the approved frozen candidate. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. - Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer. @@ -33,8 +33,8 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Tasks - [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. -- [ ] T2 — POSIX clean-machine bootstrap. Status: in progress; user-authorized deadline correction returned 55/55 POSIX and 76/76 combined tests. Final independent verification is pending; native review/commit remain pending. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused POSIX bootstrap tests and native macOS/Linux lanes; verify integrity, spaces and environment refresh. -- [ ] T3 — Windows clean-machine bootstrap. Route: delegated direct. Surfaces: `scripts/bootstrap.ps1`, `scripts/installer-downloads.mjs`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused Windows bootstrap tests and native Windows lane; compatible Go acquisition, quoted paths and inherited environment. +- [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. +- [ ] T3 — Windows clean-machine bootstrap. Status: read-only preparation; challenge the unsigned PowerShell/default-policy premise before choosing the native entry, without weakening security controls. Route: delegated direct. Surfaces: `scripts/bootstrap.ps1`, `scripts/installer-downloads.mjs`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused Windows bootstrap tests and native Windows lane; compatible Go acquisition, quoted paths and inherited environment. - [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. @@ -63,7 +63,7 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. T1 is closed in commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`. Its native medium reliability review `review-eda68029d737a4f8` approved and acknowledgement burned authority. T2 writer `muqsi390-6-1ko6` returned 860 authored lines: 50 POSIX tests/71 combined tests, shell syntax and whitespace passed. Parent read back the shell/helper and found a real upstream metadata spelling mismatch (`>=22.13` versus `>=22.13.0`) that would reject legitimate pnpm. Bounded correction `muqt9gtx-7-ddg0` completed: regression RED 49/52 (three intended failures), GREEN 52/52 and combined 73/73. Literal upstream metadata now differs safely from normalized comparison; integrity/identity unchanged. Parent staged the five intended files and native ASSESS measured 902 lines/high risk (shell/process boundary), requiring independent verification. Verifier `muqte4m0-8-szun` passed 73 tests/syntax/whitespace, but its exactly authorized disposable probe reported `bounded(1) still running after 2500ms: true`, then outer SIGKILL ended the fresh detached child group. Shell sends TERM once and waits without escalation; helper spawnSync has an analogous static-only concern, not dynamically reproduced. Ledger MM drift was attributed to parent-only status edits; source/docs/tests remained stable. T2 remains open. User explicitly selected `fix_timeouts` over pause. Deadline writer `muqts0u4-9-0imq` returned the bounded correction (153 authored lines): RED 53/55 required external guards, GREEN shell failed at 10.03s/Node at 15.07s; 55/55 POSIX and 76/76 combined passed. Parent read back the changed watchdog/process/test paths and restaged the five intended files; ASSESS remains high (1041 lines), requiring final independent verification. Guarantees cover owned direct, non-forking probes only; descendant stdio/process-tree cancellation is not established. No automatic further correction loop is allowed. Native review/work-unit commit remain pending. Bootstrap targets an extracted/checkout bundle; no published bundle URL is invented. +Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. T1 is closed in commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`. Its native medium reliability review `review-eda68029d737a4f8` approved and acknowledgement burned authority. T2 writer `muqsi390-6-1ko6` returned 860 authored lines: 50 POSIX tests/71 combined tests, shell syntax and whitespace passed. Parent read back the shell/helper and found a real upstream metadata spelling mismatch (`>=22.13` versus `>=22.13.0`) that would reject legitimate pnpm. Bounded correction `muqt9gtx-7-ddg0` completed: regression RED 49/52 (three intended failures), GREEN 52/52 and combined 73/73. Literal upstream metadata now differs safely from normalized comparison; integrity/identity unchanged. Parent staged the five intended files and native ASSESS measured 902 lines/high risk (shell/process boundary), requiring independent verification. Verifier `muqte4m0-8-szun` passed 73 tests/syntax/whitespace, but its exactly authorized disposable probe reported `bounded(1) still running after 2500ms: true`, then outer SIGKILL ended the fresh detached child group. Shell sends TERM once and waits without escalation; helper spawnSync has an analogous static-only concern, not dynamically reproduced. Ledger MM drift was attributed to parent-only status edits; source/docs/tests remained stable. User explicitly selected `fix_timeouts` over pause. Deadline writer `muqts0u4-9-0imq` returned the bounded correction (153 authored lines): RED 53/55 required external guards, GREEN shell failed at 10.03s/Node at 15.07s; 55/55 POSIX and 76/76 combined passed. Parent read back the changed watchdog/process/test paths and restaged the five intended files; ASSESS remains high (1041 lines), requiring final independent verification. Guarantees cover owned direct, non-forking probes only; descendant stdio/process-tree cancellation is not established. No automatic further correction loop is allowed. Final independent verification `muqu6ajy-a-8eae` passed 76/76 and both syntax/whitespace checks; parent reran 76/76 and syntax after native acknowledgement. High four-lens review `review-5cbf4ce0e04bf1de` approved and exact acknowledgement burned authority for target `sha256:731a554acdf55eecf79248e120b15ec8d0de6423dce9512dd8308280578ca4da`. Staged tree `4182921e24ed6836e5f067bb5a4d9c0ff9fb98ce` exactly matched the frozen review tree; source work unit committed as `03e770f75d5ce8a02f16d125b590e41c743c8101`. T2 bounded implementation is closed; T3 preparation is next. Bootstrap targets an extracted/checkout bundle; no published bundle URL is invented. ## Work-unit evidence ### T1 (closed) @@ -78,3 +78,12 @@ Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User sele - Review: `review-eda68029d737a4f8`, reliability capture approved; `acknowledge-approved` returned `native-approved-acknowledgement-completed`, authority burned for target `sha256:062d3e347226b65d79754b215d1b128677da7ef06c434611f4b04a7e793d45a9`. - First START consent expired without invocation/lineage/mutation; native continuation explicitly allowed fresh START. Second START consent succeeded. No burned authority is reused. - No claims of native OS install proof. + +### T2 (bounded implementation closed) +- Commit: `03e770f75d5ce8a02f16d125b590e41c743c8101`, 1,041 authored lines. Integrity/security fixtures and docs retained as one cohesive POSIX prerequisite unit rather than compressed. +- Final independent verifier: `muqu6ajy-a-8eae`, 76/76 (55 POSIX + 21 preflight), no failures/skips/cancellations, 26.156s; syntax, staged/worktree whitespace checks passed. Source/docs/tests stable; ledger MM parent-owned. +- Production deadline cases: shell 10,025.533ms; Node 15,061.966ms; neither outer guard fired. Direct PIDs reaped, owned cleanup, unrelated-file preservation asserted. Normal nonzero tar failure covered. +- Parent spot check after acknowledgement: 76/76, 26.133s; `sh -n` and post-restaging `git diff --cached --check` passed. Full staged tree equality proved commit preserved reviewed bytes. +- Native: high risk, 4 lenses; `review-5cbf4ce0e04bf1de` approved, `native-approved-acknowledgement-completed`, authority burned for the exact frozen workspace target. No STATUS issued after burn. +- Nonblocking advisories: `R2-engine-contract` (`scripts/bootstrap.sh:24-25`), `R3-001` (`scripts/installer-downloads.mjs:122-134`), `R3-002` (`tests/installer-posix-bootstrap.test.ts:23-24`). These do not open correction or invalidate approval; separate later work only, no automatic acceptance/fix. +- Limitations: Linux fake-acquisition fixtures only; native macOS, clean-machine/network acquisition, full suites/build/package checks, forked-descendant cancellation remain unproved. Direct non-forking probe guarantee only. Missing T5 wizard entry intentionally prevents real bootstrap acquisition. From ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 16:00:55 +0200 Subject: [PATCH 04/24] feat(installer): add verified Windows bootstrap --- docs/install-wizard.md | 175 +++++- odd/tasks/browser-install-wizard.md | 18 +- scripts/bootstrap.cmd | 199 +++++++ scripts/installer-downloads.mjs | 19 +- scripts/installer-windows-artifacts.json | 15 + scripts/installer-windows.mjs | 277 ++++++++++ tests/installer-windows-bootstrap.test.ts | 634 ++++++++++++++++++++++ 7 files changed, 1322 insertions(+), 15 deletions(-) create mode 100644 scripts/bootstrap.cmd create mode 100644 scripts/installer-windows-artifacts.json create mode 100644 scripts/installer-windows.mjs create mode 100644 tests/installer-windows-bootstrap.test.ts diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 180beb18a..1c0a058a0 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1,6 +1,7 @@ # Installation wizard groundwork -The preflight planner and POSIX prerequisite bootstrap are implemented. +The preflight planner, POSIX bootstrap and Windows prerequisite foundation are +implemented. Windows native validation remains unavailable locally. **There is no working browser wizard or complete installation path yet.** The bootstrap stops explicitly when the future wizard entry is absent. For ordinary installation and terminal use, follow the [README](../README.md). @@ -92,15 +93,15 @@ companion installer belongs in this plan. `npmCommand` supports pnpm, but independent upstream `npm exec` control remains unverified. **This preflight does not establish an npm-free installation chain.** -Windows bootstrap, standard installation driver, local server, consent UI, -distribution packaging and native acceptance evidence remain future work in the +Standard installation driver, local server, consent UI, distribution packaging +and native acceptance evidence remain future work in the [feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected tests do not prove clean-machine installation on Windows, macOS or Linux. Focused verification: ```sh -node --experimental-strip-types --test tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts +node --experimental-strip-types --test tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts sh -n scripts/bootstrap.sh ``` @@ -176,7 +177,8 @@ timeout. Raw downloader/process error text is not logged. | Node native darwin/linux x64/arm64 | 24.21.0; parent-verified SHA256 entries from [official SHASUMS256](https://nodejs.org/dist/latest-v24.x/SHASUMS256.txt), applied to fixed [v24.21.0 archive URLs](https://nodejs.org/dist/v24.21.0/). | | pnpm JavaScript CLI | 11.1.1; parent-verified raw engine `>=22.13` (comparison minimum `22.13.0`), tarball and SHA512 SRI from [registry version metadata](https://registry.npmjs.org/pnpm/11.1.1); [fixed tarball](https://registry.npmjs.org/pnpm/-/pnpm-11.1.1.tgz). | -The exact integrity values live in `installer-downloads.mjs`; the shell copies +POSIX/pnpm integrity values live in `installer-downloads.mjs`; Windows Node +values live in `installer-windows-artifacts.json`. The shell copies only Node's acquisition pin/hashes because first acquisition cannot depend on Node. Tests cross-check shell selection against the shared descriptors. Changing these pins requires renewed primary-source integrity evidence and updating both @@ -194,9 +196,8 @@ arch)` (verified bytes), `compatibleEngine(range, version)`, caller URLs/checksums/commands. Trusted local test adapters inject byte download, digest and process checks; they are not exposed through any browser interface. The caller owns a private `tools` directory. `ensurePnpm` returns `{ env, -acquired }`, leaving the supplied environment unchanged. Future T3 can reuse -descriptors/integrity verification; Windows descriptors, process semantics and -wrapper publication are intentionally not implemented by this POSIX unit. +acquired }`, leaving the supplied environment unchanged. The Windows adapter reuses descriptors/integrity verification without changing +POSIX process semantics, defaults or pins. ### Evidence, not platform certification @@ -235,3 +236,161 @@ may keep pipes open; this unit does not claim bounded return or descendant cleanup for those programs. The fixtures prove neither native macOS behavior nor live artifact acquisition. Independent verification and native review remain separate parent-owned gates. + +## Windows foundation: fixed commands, no policy repair + +Run `scripts\bootstrap.cmd` from a trusted extracted bundle or checkout. Like +POSIX, it stops before acquisition or home writes when T5's entry or any +Windows-dependent helper/metadata file is missing. There is no published bundle +URL, remote-pipe contract or working installation wizard yet. + +| Step | Windows contract | +| --- | --- | +| Entry | Small CMD entry invokes fixed stock Windows PowerShell commands with no profile. Paths are environment data, not interpolated PowerShell source. Delayed CMD expansion is disabled. | +| Storage | Claim a new random-named prerequisite directory below LOCALAPPDATA, never reuse an existing destination. Verify each path component's reparse attributes, owner and role-specific ACL rights. Protect the claimed directory's DACL for the invoking SID, SYSTEM and Administrators, and read it back. | +| Node | Reuse a proven stable existing Node ≥24.3.0 and the repository minimum. Otherwise acquire only the fixed official Node 24.21.0 Windows x64/arm64 ZIP, with no redirects and bounded transport, verify SHA256 before opening the archive, validate the whole namespace and extract only regular `node.exe`. | +| pnpm | Reuse only a fully recognized npm CMD shim with package identity, bin target, stable CLI version, compatible engine and global add/bin help evidence. Preserve its sibling-Node preference or prove its inherited cwd/PATH/PATHEXT Node selection. Never execute the shim via cmd.exe. Unknown wrappers block without replacement. | +| Missing pnpm | Shared pnpm 11.1.1 URL/SRI and raw `>=22.13` engine identity are unchanged. Parse bounded gzip/USTAR bytes, reject unsupported extensions, links and unsafe Windows namespaces before no-clobber publication. Return a direct Node + JS-entry invocation; do not fabricate a wrapper. | +| Handoff | Existing Node helper starts the fixed future `bin/gentle-shell-install.mjs`. Only child PATH is refreshed. No persistent PATH, global installation, product root or companion installation is created here. | + +Windows Node SHA256 provenance is the parent's fresh primary-source read of +[24.21.0 SHASUMS256](https://nodejs.org/dist/v24.21.0/SHASUMS256.txt). A narrow +JSON metadata file lets pre-Node PowerShell and the shared Node helper consume +the same Windows pins without mixing the POSIX archive format or copying pnpm +integrity into another runtime. Pin changes still require fresh primary evidence. + +### T4/T5 integration contract + +`scripts/installer-windows.mjs` exports: + +- `ensureWindowsPnpm({ tools, env, node?, adapters? })` → `{ acquired, env, + command, prefix }`. Invoke `[command, ...prefix, ...args]` with `shell:false`; + keep the returned environment. `tools` must be a private attempt-owned root. + Test adapters are trusted local code only, never a browser API. +- `bootstrapWindows({ bundle, tools, env })` validates fixed bundle files and + requirements, acquires/reuses pnpm, then delegates to shared `launchWizard`. + CLI entry is `installer-downloads.mjs --bootstrap-windows BUNDLE TOOLS`. +- T5 receives `GENTLE_INSTALL_PNPM_NODE` and `GENTLE_INSTALL_PNPM_ENTRY` as data + for that proven direct invocation. T4 must re-inventory before installation; + these values are not an arbitrary command API or persistent terminal repair. +- Storage, namespace, wrapper, tar and bounded process functions are exported + for focused verification. Production storage verification requires native + Windows and fixed stock PowerShell ACL commands, never POSIX mode/UID evidence. + +Go preparation is deliberately **not implemented or invoked** in this unit. +T4 must first prove whether its actual standard operation needs a compiler: +package-native Gentle AI manifest/hash reuse can avoid Go, but the current +installer resolves Go before source-bundle reuse. Do not reinstall just to test +reuse, treat an explicit override as native-package evidence, or assume a source +bundle bypasses Go. If required, T4 must use the existing companion installer, +a lazy verified prerequisite with Go ≥1.25.10, and `GOTOOLCHAIN=local`. + +### Policy, bounds and evidence limits + +No unsigned `.ps1` file, script-file evaluation, execution-policy relaxation, +file unblocking, certificate/TLS bypass, elevation, security exclusion or profile +change is used. Restricted permits individual commands; it is not every Windows +client's default. [Execution policies](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies) +do not override [AppLocker CMD/BAT rules](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/script-rules-in-applocker) +or [managed App Control constraints](https://learn.microsoft.com/en-us/powershell/scripting/security/app-control/how-app-control-works). +ConstrainedLanguage and any download/execution denial stop with an error. There +is no fallback intended to evade those controls; if CMD itself is denied, the +OS owns the denial diagnostic before our entry can run. + +Node transport is capped at 100 MiB with a 60-second request/total elapsed check +and 10-second blocking-read timeout. A blocking read can consume up to that last +read timeout beyond the elapsed check. ZIP metadata caps expanded size at 512 MiB +and the selected executable at 150 MiB. Namespace checks reject traversal, +backslashes, links/reparse attributes, duplicate/case-aliased names, conflicting +file/directory parents, devices, ADS and trailing dots/spaces. Shared pnpm +transport remains 32 MiB/60 seconds; tar expansion is capped at 128 MiB. +Unsupported archive extensions fail closed. **Actual pnpm 11.1.1 TGZ acceptance +is unproved:** it requires an integrity-backed offline copy of the pinned +artifact, not a synthetic USTAR fixture or an injected digest. No such evidence +was supplied and no acquisition is authorized in this correction. + +The pre-Node probe drains both pipes with a combined 1-MiB cap and kills only +its direct child at 10 seconds, with a bounded one-second final wait. Node helper +probes use the shared 15-second/1-MiB-style bounds and an unignorable direct-child +kill. Valid output followed by a hang is a failure. Production never kills +process groups or unrelated processes. Forked descendants retaining stdio are +outside the guarantee; the interactive wizard has no total deadline. + +### Target-versus-ancestor ACL boundary + +For untrusted SIDs, the fixed predicates allow ReadAndExecute plus Synchronize +(`0x1200a9`), with one narrow directory-specific distinction: + +| Role | Mutation boundary | +| --- | --- | +| Actual file/executable or owned tooling root (depth 0) | No write, deletion, child-deletion, ACL or ownership rights. | +| Immediate parent (depth 1) | Same strict protection, including no CreateDirectories/AppendData. A new tooling claim starts checking its parent at this depth. | +| Distant **existing directory** (depth ≥2) | May additionally allow CreateDirectories/AppendData (`4`) for sibling-only creation. WriteData/CreateFiles, WriteExtendedAttributes, WriteAttributes, Delete, DeleteSubdirectoriesAndFiles, WriteDACL, TakeOwnership, generic/unknown rights still reject. | + +Directory CreateDirectories and file AppendData share an enum bit; a distant +ancestor here must actually be an existing directory. This is not permission to +append to an executable. Reparse checks, trusted ownership and every actual path +component remain mandatory. An inheritable ACE is checked again where it becomes +effective on the descendant; InheritOnly does not grant rights on its current +object. Effective unsafe allow ACEs block even when deny ACEs might otherwise +limit them: this is a conservative mutation boundary, not a general Windows +ACL/access-check framework. Trusted SIDs and protected-DACL readback are unchanged. +The portable fixture model checks parity with all three fixed production +predicates; it does not execute Windows ACL APIs or claim prevalence on Windows. + +Failure cleanup targets only the directory claimed by the actual attempt; +collisions and unrelated storage are not removed. Successful prerequisite tools +remain available to children. ACL/ancestor checks are conservative and may +reject managed/nonstandard layouts rather than relax security. They do not +claim protection from a malicious process running as the same principal or an +administrator, nor eliminate same-principal time-of-check/time-of-use races. + +### Implemented fixtures versus missing execution evidence + +The native gates now contain runnable assertions, not empty or always-skipped +callbacks. They run on Windows without a pending-fixture override: + +- Actual production claim/check predicates: protected-root creation, collision + preservation, harmless distant sibling creation, dangerous-right refusal, + strict target/immediate-parent checks and junction rejection. Junction creation + alone can be capability-skipped after an actual permission/unsupported error. + DACL changes are limited to new disposable fixture-owned objects. +- Actual fixed PowerShell ZIP namespace/publication stage: local ZIPs with the + fixed archive/member identity; valid publication and traversal, aliases, + symlink/reparse attributes, ADS, devices, trailing names and parent conflicts. + The valid member contains the available fixture Node's unchanged bytes and is + never executed. Collision tests preserve unrelated published storage. +- Actual pre-Node process primitive: approved Node executes local fixture JS; + nonzero exit, both output limits, quiet/stdout/stderr/both-pipe hangs and valid + output followed by a hang are rejected. Production drain/deadline/validation/ + cleanup lines are unchanged. An independent hard guard checks recorded process + creation ticks and only kills fresh fixture-owned handles. A guard firing or + finding a residual recorded child is a test failure, not successful deadline + evidence; fixture cleanup cannot mask a missing production reap. +- Complete local sentinel composition exercises CMD continuation/quoting, + bundle checks, claim, existing-Node resolution/probe, real helper pnpm handoff + and late cleanup with spaces, Unicode and CMD metacharacters. Only the approved + available Node's unchanged bytes are cloned into the disposable fixture PATH, + excluding operator npm/Corepack/pnpm shims. A local JS pnpm fixture provides + read-only identity/help responses. The trusted fixture helper copy has a fixed, + fail-only fetch guard: accidental acquisition throws before any network call; + it never supplies bytes/digests or adds a production integrity-bypass switch. + No network/install occurs. + +The fixtures preserve the exact production command lines and insert only +fixture-owned process observations. They compose primitives in a disposable +`.cmd`, not a loaded/evaluated PowerShell script file. Native AppLocker and +ConstrainedLanguage denials still apply, with no policy relaxation. This is +**stage/composition evidence, not uninstrumented whole-entry certification**: +transport/integrity gates and the real pinned artifacts require separate proof. +The original missing-entry test also exercises the unchanged whole entry. + +All these native fixtures are **implemented but unrun on this Linux host**: +Windows PowerShell and a Windows runner are unavailable. Portable descriptor, +ACL-predicate model, tar, wrapper and process fixtures are not native Windows +proof. Native OS/ACL/CMD/loader acceptance remains T7 evidence work; the actual +pinned pnpm TGZ offline acceptance remains an explicitly unfinished evidence +item. No live artifact was downloaded/executed, operator-home installation +performed, operator/global ACL or policy changed, or Windows OS minimum/support +claimed. No same-principal-adversary or forked-descendant guarantee is made. +T7 must establish those facts before advertising clean-machine support. diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index e688fc40a..d270f5e59 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -20,9 +20,10 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Delivery and routing - Branch: `feat/browser-install-wizard`; verified default reference: `origin/main`. +- Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. - Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. -- Running committed authored lines: 1,458 (T1 417 + T2 1,041, both including their task-document changes). +- Running committed authored lines: 1,479 (T1 417 + T2 1,041 + closure ledger 21). - Last reviewed source boundary: `03e770f75d5ce8a02f16d125b590e41c743c8101`; its exact staged tree matched the approved frozen candidate. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. @@ -34,7 +35,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Tasks - [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. -- [ ] T3 — Windows clean-machine bootstrap. Status: read-only preparation; challenge the unsigned PowerShell/default-policy premise before choosing the native entry, without weakening security controls. Route: delegated direct. Surfaces: `scripts/bootstrap.ps1`, `scripts/installer-downloads.mjs`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused Windows bootstrap tests and native Windows lane; compatible Go acquisition, quoted paths and inherited environment. +- [ ] T3 — Windows clean-machine bootstrap foundation. Status: correction source recovered after worker timeout into the authorized isolated worktree. Depth-sensitive ACL logic and real native fixtures are present. Fresh writer and independent verification each passed 98 tests with 13 genuinely unavailable native Windows cases; no failures. The real pinned pnpm archive passed integrity, format and metadata checks. Native review and source commit remain pending; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. - [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. @@ -63,7 +64,11 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User selected one future PR. T1 writer `muqq4ay0-4-dazo` returned the three authorized files (342 additions) with 21/21 focused tests passing. Parent read back the full bounded changes. ASSESS was unassessable because untracked scope was undeclared and required an independent verifier. INSPECT explicitly selected the four intended untracked files and returned ready without creating a lineage. Independent verifier `muqqewpj-5-xgbi` completed: 21/21 tests and whitespace checks passed, no severe deterministic findings. Parent reran the same focused command as its bounded spot check. T1 is closed in commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`. Its native medium reliability review `review-eda68029d737a4f8` approved and acknowledgement burned authority. T2 writer `muqsi390-6-1ko6` returned 860 authored lines: 50 POSIX tests/71 combined tests, shell syntax and whitespace passed. Parent read back the shell/helper and found a real upstream metadata spelling mismatch (`>=22.13` versus `>=22.13.0`) that would reject legitimate pnpm. Bounded correction `muqt9gtx-7-ddg0` completed: regression RED 49/52 (three intended failures), GREEN 52/52 and combined 73/73. Literal upstream metadata now differs safely from normalized comparison; integrity/identity unchanged. Parent staged the five intended files and native ASSESS measured 902 lines/high risk (shell/process boundary), requiring independent verification. Verifier `muqte4m0-8-szun` passed 73 tests/syntax/whitespace, but its exactly authorized disposable probe reported `bounded(1) still running after 2500ms: true`, then outer SIGKILL ended the fresh detached child group. Shell sends TERM once and waits without escalation; helper spawnSync has an analogous static-only concern, not dynamically reproduced. Ledger MM drift was attributed to parent-only status edits; source/docs/tests remained stable. User explicitly selected `fix_timeouts` over pause. Deadline writer `muqts0u4-9-0imq` returned the bounded correction (153 authored lines): RED 53/55 required external guards, GREEN shell failed at 10.03s/Node at 15.07s; 55/55 POSIX and 76/76 combined passed. Parent read back the changed watchdog/process/test paths and restaged the five intended files; ASSESS remains high (1041 lines), requiring final independent verification. Guarantees cover owned direct, non-forking probes only; descendant stdio/process-tree cancellation is not established. No automatic further correction loop is allowed. Final independent verification `muqu6ajy-a-8eae` passed 76/76 and both syntax/whitespace checks; parent reran 76/76 and syntax after native acknowledgement. High four-lens review `review-5cbf4ce0e04bf1de` approved and exact acknowledgement burned authority for target `sha256:731a554acdf55eecf79248e120b15ec8d0de6423dce9512dd8308280578ca4da`. Staged tree `4182921e24ed6836e5f067bb5a4d9c0ff9fb98ce` exactly matched the frozen review tree; source work unit committed as `03e770f75d5ce8a02f16d125b590e41c743c8101`. T2 bounded implementation is closed; T3 preparation is next. Bootstrap targets an extracted/checkout bundle; no published bundle URL is invented. +T1 and T2 bounded source units are closed with evidence below. T3's initial writer and independent verifier passed 94 portable tests with four skips, but verification found three empty always-skipped native placeholders and ancestor-ACL over-rejection. One bounded correction worker (`muqwpdkq-e-gfyf`) timed out after editing. Read-only incident inspection found depth-sensitive masks and substantial real native callbacks; no post-correction test result or RED/GREEN history is available. + +The shared worktree had switched to `fix/bridge-only-agent-wake` with unrelated commit/work. The user authorized isolating the installer, transferring exactly seven installer files and cleaning only their pending originals after copy verification. All seven byte hashes/modes were verified. The main branch/commit and its unrelated pending ledger were preserved; the installer now uses its own worktree on `feat/browser-install-wizard` at `07e4971d`. Product/runtime/Engram roots are unchanged. + +Fresh independent verification (`muqyo9ni-g-ocro`) passed 111 total/98 passed/13 skipped in 26.014s; final read-only writer self-verification (`muqz9ufr-h-dlq0`) matched those counts in 26.140s. Both passed syntax/whitespace and source stability checks. Human-authorized archive proof (`muqziidu-i-sv6f`) made one public GET, verified the pinned SHA512 before the existing parser accepted 448 entries from 4,223,291 bytes; actual package identity, version, raw engine and bin matched. No artifact execution/extraction/installation occurred and owned temporary storage was cleaned. Next: bounded parent spot check and exact native review of the stable T3 source unit. Native Windows remains unavailable. Go preparation and persistent terminal readiness remain T4; the missing T5 wizard entry intentionally prevents real acquisition. No T3 native START/lineage or commit exists. Final normalized work-unit review must target this isolated worktree, never the accumulated feature branch. ## Work-unit evidence ### T1 (closed) @@ -87,3 +92,10 @@ Exploration completed by `gentle-ai-explore` (task `muqpt2zf-3-54b7`). User sele - Native: high risk, 4 lenses; `review-5cbf4ce0e04bf1de` approved, `native-approved-acknowledgement-completed`, authority burned for the exact frozen workspace target. No STATUS issued after burn. - Nonblocking advisories: `R2-engine-contract` (`scripts/bootstrap.sh:24-25`), `R3-001` (`scripts/installer-downloads.mjs:122-134`), `R3-002` (`tests/installer-posix-bootstrap.test.ts:23-24`). These do not open correction or invalidate approval; separate later work only, no automatic acceptance/fix. - Limitations: Linux fake-acquisition fixtures only; native macOS, clean-machine/network acquisition, full suites/build/package checks, forked-descendant cancellation remain unproved. Direct non-forking probe guarantee only. Missing T5 wizard entry intentionally prevents real bootstrap acquisition. + +### T3 (source verification complete; native review pending) +- Final writer self-check: `muqz9ufr-h-dlq0`, 111 total/98 passed/13 native skips, zero failures, 26.140s; shell syntax and whitespace passed, all seven hashes stable. +- Independent verifier: `muqyo9ni-g-ocro`, same counts, 26.014s; no confirmed severe candidate-caused finding. Thirteen native Windows callbacks are implemented but unexecuted on Linux. +- Actual pinned pnpm archive: `muqziidu-i-sv6f`, one HTTPS GET, 4,223,291 bytes; exact SHA512 matched literal and repository pin before `readWindowsPnpmArchive` accepted 448 entries. Metadata `pnpm`/`11.1.1`/`>=22.13`/`bin/pnpm.mjs` matched. No extraction or execution; owned temporary directory cleaned; source/index unchanged. +- Post-correction RED history remains unknown after worker timeout; functional GREEN is observed, not reconstructed TDD evidence. +- Native Windows execution, whole-entry certification, clean-machine installation and remaining feature checks are still pending. No source commit or native lineage yet. diff --git a/scripts/bootstrap.cmd b/scripts/bootstrap.cmd new file mode 100644 index 000000000..cc4460fdc --- /dev/null +++ b/scripts/bootstrap.cmd @@ -0,0 +1,199 @@ +@echo off +setlocal DisableDelayedExpansion +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. +set "GENTLE_BOOTSTRAP_BUNDLE=%~dp0.." +set "GENTLE_BOOTSTRAP_TOOLS=%LOCALAPPDATA%\.gentle-shell-bootstrap-tools.%RANDOM%.%RANDOM%.%RANDOM%" +set "GENTLE_BOOTSTRAP_OWNED=" +set "GENTLE_BOOTSTRAP_PS=%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe" +if not exist "%GENTLE_BOOTSTRAP_PS%" goto unavailable + +rem Bundle/dependency checks precede ALL acquisition and per-user writes. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$bundle = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_BUNDLE);" ^ + "foreach ($file in @('bin/gentle-shell-install.mjs','package.json','scripts/installer-downloads.mjs','scripts/installer-windows.mjs','scripts/installer-windows-artifacts.json')) {" ^ + " $item = Get-Item -LiteralPath (Join-Path $bundle $file) -Force;" ^ + " if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe bundle file' };" ^ + "};" ^ + "$metadata = Get-Content -LiteralPath (Join-Path $bundle 'package.json') -Raw | ConvertFrom-Json;" ^ + "if ($metadata.engines.node -notmatch '^>=[0-9]+\.[0-9]+\.[0-9]+$' -or $metadata.packageManager -ne 'pnpm@11.1.1') { throw 'Repository prerequisite unknown' };" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: future wizard/dependent bundle file missing, unsupported metadata, or managed policy denial. No acquisition attempted.'); exit 1 } }" +if errorlevel 1 goto failed + +rem Claim an absent unique directory beneath a verified local per-user parent. +rem No security changes outside this new prerequisite directory. +rem Strict target/parent rights: ReadAndExecute + Synchronize (0x1200a9). +rem Distant existing ancestors additionally permit sibling CreateDirectories (4). +rem WriteData/reparse-affecting writes, deletion and ACL/owner changes still fail. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { $claimed = $false; try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$me = [Security.Principal.WindowsIdentity]::GetCurrent().User;" ^ + "$trusted = @($me.Value,'S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464');" ^ + "$tools = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_TOOLS); $path = [IO.Directory]::GetParent($tools).FullName;" ^ + "if (-not [IO.Path]::IsPathRooted($env:LOCALAPPDATA) -or $tools.StartsWith('\\') -or $path -ne [IO.Path]::GetFullPath($env:LOCALAPPDATA)) { throw 'Unsafe storage path' };" ^ + "$depth = 1;" ^ + "$homeAcl = Get-Acl -LiteralPath $path; if ($homeAcl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'Unknown per-user owner' };" ^ + "while ($path) {" ^ + " $item = Get-Item -LiteralPath $path -Force; if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe ancestor' };" ^ + " $acl = Get-Acl -LiteralPath $path; if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw 'Unknown ancestor owner' };" ^ + " $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad };" ^ + " foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) {" ^ + " if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw 'Unsafe ancestor ACL' };" ^ + " }; $parent = [IO.Directory]::GetParent($path); if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;" ^ + "};" ^ + "$null = New-Item -ItemType Directory -Path $tools; $claimed = $true;" ^ + "$acl = New-Object Security.AccessControl.DirectorySecurity; $acl.SetOwner($me); $acl.SetAccessRuleProtection($true,$false);" ^ + "foreach ($sid in @($me.Value,'S-1-5-18','S-1-5-32-544')) {" ^ + " $identity = New-Object Security.Principal.SecurityIdentifier($sid);" ^ + " $rule = New-Object Security.AccessControl.FileSystemAccessRule($identity,'FullControl','ContainerInherit,ObjectInherit','None','Allow'); $acl.AddAccessRule($rule);" ^ + "}; Set-Acl -LiteralPath $tools -AclObject $acl;" ^ + "$verified = Get-Acl -LiteralPath $tools; if (-not $verified.AreAccessRulesProtected -or $verified.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'Private ACL rejected' };" ^ + "foreach ($rule in $verified.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -ne 'Allow' -or @($me.Value,'S-1-5-18','S-1-5-32-544') -notcontains $rule.IdentityReference.Value) { throw 'Private ACL readback rejected' } };" ^ + "$null = New-Item -ItemType File -Path (Join-Path $tools '.bootstrap-owned') -Value 'gentle-pi prerequisite tooling only';" ^ + "} catch { if ($claimed) { Remove-Item -LiteralPath $env:GENTLE_BOOTSTRAP_TOOLS -Recurse -Force -ErrorAction SilentlyContinue }; [Console]::Error.WriteLine('Bootstrap: private storage ACL/reparse/ownership claim failed or policy denied it.'); exit 1 } }" +if errorlevel 1 goto failed +set "GENTLE_BOOTSTRAP_OWNED=1" + +rem Select an existing native Node without invoking any command interpreter shim. +rem Absence alone authorizes pinned acquisition; incompatibility never replaces. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$command = $null; try { $command = Get-Command node.exe -CommandType Application -ErrorAction Stop | Select-Object -First 1 } catch [Management.Automation.CommandNotFoundException] { $command = $null };" ^ + "if ($null -ne $command) { $null = New-Item -ItemType File -Path (Join-Path $env:GENTLE_BOOTSTRAP_TOOLS '.node-target') -Value $command.Source };" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: existing Node resolution failed or policy denied it.'); exit 1 } }" +if errorlevel 1 goto failed + +rem Fixed official ZIP transport: no redirects, bounded bytes/time, no TLS changes. +rem Integrity is verified before ZIP processing or executable publication. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; if (Test-Path -LiteralPath (Join-Path $tools '.node-target')) { exit 0 };" ^ + "$architecture = $env:PROCESSOR_ARCHITECTURE; if ($env:PROCESSOR_ARCHITEW6432) { $architecture = $env:PROCESSOR_ARCHITEW6432 };" ^ + "switch ($architecture) { 'AMD64' { $arch = 'x64' } 'ARM64' { $arch = 'arm64' } default { throw 'Unsupported architecture' } };" ^ + "$pins = Get-Content -LiteralPath (Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'scripts/installer-windows-artifacts.json') -Raw | ConvertFrom-Json; $pin = $pins.node.$arch;" ^ + "$stem = 'node-v24.21.0-win-' + $arch;" ^ + "if ($pins.node.version -ne '24.21.0' -or $pin.url -ne ('https://nodejs.org/dist/v24.21.0/' + $stem + '.zip') -or $pin.sha256 -notmatch '^[a-f0-9]{64}$' -or $pins.node.maxBytes -ne 104857600) { throw 'Artifact pin rejected' };" ^ + "$request = [Net.HttpWebRequest]::Create($pin.url); $request.AllowAutoRedirect = $false; $request.Timeout = 60000; $request.ReadWriteTimeout = 10000;" ^ + "$watch = [Diagnostics.Stopwatch]::StartNew(); $response = $null; $output = $null; $input = $null;" ^ + "try {" ^ + " $response = $request.GetResponse(); if ([int]$response.StatusCode -ne 200 -or $response.ContentLength -gt 104857600) { throw 'Download rejected' };" ^ + " $input = $response.GetResponseStream(); $output = [IO.File]::Open((Join-Path $tools 'node.zip'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None);" ^ + " $buffer = New-Object byte[] 65536; $total = 0;" ^ + " while (($count = $input.Read($buffer,0,$buffer.Length)) -gt 0) { $total += $count; if ($total -gt 104857600 -or $watch.ElapsedMilliseconds -gt 60000) { throw 'Download bound exceeded' }; $output.Write($buffer,0,$count) };" ^ + " if ($total -eq 0 -or ($response.ContentLength -ge 0 -and $response.ContentLength -ne $total) -or $watch.ElapsedMilliseconds -gt 60000) { throw 'Download truncated or timed out' };" ^ + "} finally { if ($output) { $output.Dispose() }; if ($input) { $input.Dispose() }; if ($response) { $response.Dispose() } };" ^ + "$actual = (Get-FileHash -LiteralPath (Join-Path $tools 'node.zip') -Algorithm SHA256).Hash; if ($actual -ne $pin.sha256) { throw 'Integrity mismatch' };" ^ + "$null = New-Item -ItemType File -Path (Join-Path $tools '.node-stem') -Value $stem;" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: Node download, bounds, integrity or policy check failed.'); exit 1 } }" +if errorlevel 1 goto failed + +rem Inspect the entire ZIP namespace before extracting ONLY its regular node.exe. +rem Reject links, Windows device/ADS names, traversal and case aliases. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; if (Test-Path -LiteralPath (Join-Path $tools '.node-target')) { exit 0 };" ^ + "Add-Type -AssemblyName System.IO.Compression.FileSystem;" ^ + "$stem = (Get-Content -LiteralPath (Join-Path $tools '.node-stem') -Raw).Trim();" ^ + "$zip = [IO.Compression.ZipFile]::OpenRead((Join-Path $tools 'node.zip'));" ^ + "try {" ^ + " if ($zip.Entries.Count -eq 0 -or $zip.Entries.Count -gt 50000) { throw 'ZIP count rejected' };" ^ + " $explicit = @{}; $names = @{}; $types = @{}; $selected = $null; $expanded = 0;" ^ + " foreach ($entry in $zip.Entries) {" ^ + " $name = $entry.FullName; $directory = $name.EndsWith('/'); if ($directory) { $name = $name.Substring(0,$name.Length - 1) };" ^ + " $unixType = ($entry.ExternalAttributes -shr 16) -band 61440;" ^ + " if (($entry.ExternalAttributes -band 1024) -or ($unixType -ne 0 -and $unixType -ne 32768 -and $unixType -ne 16384) -or ($unixType -eq 16384 -and -not $directory) -or ($directory -and $entry.Length -ne 0)) { throw 'ZIP links/types rejected' };" ^ + " if ($entry.FullName.Contains('\') -or $explicit.ContainsKey($name)) { throw 'ZIP duplicate/path rejected' }; $explicit[$name] = $true;" ^ + " $parts = $name.Split('/'); $path = '';" ^ + " for ($index = 0; $index -lt $parts.Length; $index++) {" ^ + " $part = $parts[$index];" ^ + " if (-not $part -or $part -eq '.' -or $part -eq '..' -or $part -match '[<>:\x22|?*\x00-\x1f\x7f]' -or $part -match '[. ]$' -or $part -match '^(CON|PRN|AUX|NUL|COM[1-9\u00b9\u00b2\u00b3]|LPT[1-9\u00b9\u00b2\u00b3])(\.|$)') { throw 'ZIP Windows name rejected' };" ^ + " if ($path) { $path += '/' }; $path += $part; $isDirectory = $directory -or $index -lt ($parts.Length - 1);" ^ + " if ($names.ContainsKey($path) -and ($names[$path] -cne $path -or $types[$path] -ne $isDirectory)) { throw 'ZIP case/type alias rejected' }; $names[$path] = $path; $types[$path] = $isDirectory;" ^ + " };" ^ + " $expanded += $entry.Length; if ($expanded -gt 536870912 -or ($name -ne $stem -and -not $name.StartsWith($stem + '/',[StringComparison]::Ordinal))) { throw 'ZIP root/size rejected' };" ^ + " if ($name -ceq ($stem + '/node.exe')) { if ($directory -or $entry.Length -eq 0 -or $entry.Length -gt 157286400) { throw 'Node member rejected' }; $selected = $entry };" ^ + " }; if ($null -eq $selected) { throw 'Node member missing' };" ^ + " $destination = Join-Path $tools 'node'; $null = New-Item -ItemType Directory -Path $destination;" ^ + " $target = Join-Path $destination 'node.exe'; $output = [IO.File]::Open($target,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); $input = $selected.Open();" ^ + " try { $input.CopyTo($output) } finally { $input.Dispose(); $output.Dispose() };" ^ + " $null = New-Item -ItemType File -Path (Join-Path $tools '.node-target') -Value $target;" ^ + "} finally { $zip.Dispose() };" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: Node ZIP namespace, extraction or policy check failed.'); exit 1 } }" +if errorlevel 1 goto failed + +rem Production direct non-forking Node probe: 10s deadline and combined 1-MiB cap. +rem Drain both pipes asynchronously; valid output followed by a hang still fails. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { $child = $null; $started = $false; try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $node = (Get-Content -LiteralPath (Join-Path $tools '.node-target') -Raw).TrimEnd([char]13,[char]10);" ^ + "$item = Get-Item -LiteralPath $node -Force; if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Unsafe Node target' };" ^ + "if (-not [IO.Path]::IsPathRooted($node) -or $node.StartsWith('\\')) { throw 'Unsafe Node path' };" ^ + "$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; $trusted = @($me,'S-1-5-18','S-1-5-32-544','S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'); $path = $node;" ^ + "$depth = 0;" ^ + "while ($path) {" ^ + " $item = Get-Item -LiteralPath $path -Force; if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Node reparse ancestor' }; $acl = Get-Acl -LiteralPath $path;" ^ + " if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw 'Unknown Node owner' };" ^ + " $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad };" ^ + " foreach ($rule in $acl.GetAccessRules($true,$true,[Security.Principal.SecurityIdentifier])) { if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw 'Unsafe Node ACL' } };" ^ + " $parent = [IO.Directory]::GetParent($path); if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;" ^ + "};" ^ + "$start = New-Object Diagnostics.ProcessStartInfo; $start.FileName = $node; $start.Arguments = '--version'; $start.UseShellExecute = $false; $start.CreateNoWindow = $true; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true;" ^ + "$child = New-Object Diagnostics.Process; $child.StartInfo = $start; if (-not $child.Start()) { throw 'Node cannot start' }; $started = $true;" ^ + "$streams = @($child.StandardOutput.BaseStream,$child.StandardError.BaseStream); $buffers = @((New-Object byte[] 4096),(New-Object byte[] 4096));" ^ + "$tasks = @($streams[0].ReadAsync($buffers[0],0,4096),$streams[1].ReadAsync($buffers[1],0,4096)); $closed = @($false,$false); $text = ''; $total = 0; $watch = [Diagnostics.Stopwatch]::StartNew();" ^ + "while (-not ($closed[0] -and $closed[1] -and $child.HasExited)) {" ^ + " if ($watch.ElapsedMilliseconds -ge 10000) { throw 'Node deadline' };" ^ + " for ($index = 0; $index -lt 2; $index++) { if (-not $closed[$index] -and $tasks[$index].IsCompleted) {" ^ + " $count = $tasks[$index].GetAwaiter().GetResult(); if ($count -eq 0) { $closed[$index] = $true } else {" ^ + " $total += $count; if ($total -gt 1048576) { throw 'Node output limit' };" ^ + " if ($index -eq 0) { $text += [Text.Encoding]::UTF8.GetString($buffers[$index],0,$count) };" ^ + " $tasks[$index] = $streams[$index].ReadAsync($buffers[$index],0,4096);" ^ + " }" ^ + " } }; Start-Sleep -Milliseconds 10;" ^ + "}; if ($child.ExitCode -ne 0) { throw 'Node nonzero exit' };" ^ + "$version = $text.Trim(); if ($version -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { throw 'Unknown Node version' };" ^ + "$actual = [Version]$version.Substring(1); $metadata = Get-Content -LiteralPath (Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'package.json') -Raw | ConvertFrom-Json;" ^ + "if ($actual -lt [Version]'24.3.0' -or $actual -lt [Version]$metadata.engines.node.Substring(2)) { throw 'Incompatible Node; refusing replacement' };" ^ + "if ((Test-Path -LiteralPath (Join-Path $tools '.node-stem')) -and $version -ne 'v24.21.0') { throw 'Acquired Node version rejected' };" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: Node version, execution, deadline, output bound or policy check failed; refusing replacement.'); exit 1 }" ^ + "finally { try { if ($child) { if ($started -and -not $child.HasExited) { $child.Kill(); if (-not $child.WaitForExit(1000)) { throw 'Child termination unconfirmed' } }; $child.Dispose() } } catch { [Console]::Error.WriteLine('Bootstrap: direct-child termination could not be confirmed.'); exit 1 } } }" +if errorlevel 1 goto failed + +rem Launch the existing Node helper with data arguments; no shell evaluation. +rem Interactive wizard duration is intentionally unbounded. Only child PATH changes. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $node = (Get-Content -LiteralPath (Join-Path $tools '.node-target') -Raw).TrimEnd([char]13,[char]10);" ^ + "$env:PATH = [IO.Path]::GetDirectoryName($node) + ';' + $env:PATH;" ^ + "$helper = Join-Path $env:GENTLE_BOOTSTRAP_BUNDLE 'scripts/installer-downloads.mjs';" ^ + "if (Test-Path -LiteralPath (Join-Path $tools 'node.zip')) { Remove-Item -LiteralPath (Join-Path $tools 'node.zip') };" ^ + "& $node $helper '--bootstrap-windows' $env:GENTLE_BOOTSTRAP_BUNDLE $tools; if ($LASTEXITCODE -ne 0) { throw 'Helper failed' };" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: prerequisite helper/wizard failed or managed policy denied execution.'); exit 1 } }" +if errorlevel 1 goto failed +endlocal & exit /b 0 + +:unavailable +>&2 echo Bootstrap: stock Windows PowerShell is unavailable. No acquisition attempted. +:failed +rem Never scan/reuse/clean other attempts. Only our successfully claimed root. +if not defined GENTLE_BOOTSTRAP_OWNED goto finishfailure +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $item = Get-Item -LiteralPath $tools -Force;" ^ + "if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Cleanup target rejected' };" ^ + "$marker = Get-Item -LiteralPath (Join-Path $tools '.bootstrap-owned') -Force;" ^ + "if ($marker.PSIsContainer -or ($marker.Attributes -band [IO.FileAttributes]::ReparsePoint) -or (Get-Content -LiteralPath $marker.FullName -Raw) -ne 'gentle-pi prerequisite tooling only') { throw 'Cleanup marker rejected' };" ^ + "Remove-Item -LiteralPath $tools -Recurse -Force;" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: attempt-owned cleanup unavailable; no other storage touched.'); exit 1 } }" +:finishfailure +endlocal & exit /b 1 diff --git a/scripts/installer-downloads.mjs b/scripts/installer-downloads.mjs index 134c19855..caf51c379 100644 --- a/scripts/installer-downloads.mjs +++ b/scripts/installer-downloads.mjs @@ -20,11 +20,17 @@ const pnpm = Object.freeze({ maxBytes: 32 * 1024 * 1024, }); -/** Fixed allowlist; callers cannot supply download URLs, checksums or commands. - * Node descriptors are reusable by T3; this unit intentionally has no Windows pin. - */ +/** Fixed allowlist; callers cannot supply download URLs, checksums or commands. */ export function artifactFor(name, platform, arch) { if (name === "pnpm") return pnpm; + if (name === "node" && platform === "win32" && ["x64", "arm64"].includes(arch)) { + const pin = JSON.parse(readFileSync(new URL("./installer-windows-artifacts.json", import.meta.url), "utf8")).node; + const target = pin[arch]; + if (pin.version !== "24.21.0" || target.url !== `https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-${arch}.zip` || !/^[a-f0-9]{64}$/.test(target.sha256) || pin.maxBytes !== 104857600) { + throw new Error("Windows artifact metadata rejected"); + } + return Object.freeze({ name, version: pin.version, platform, arch, url: target.url, integrity: `sha256-${target.sha256}`, maxBytes: pin.maxBytes }); + } const hash = nodeHashes[`${platform}-${arch}`]; if (name !== "node" || !hash) throw new Error("Unsupported acquisition target"); return Object.freeze({ name, version: "24.21.0", platform, arch, @@ -241,7 +247,12 @@ async function bootstrap(bundle, suppliedTools) { } } if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { - if (process.argv.length !== 5 || process.argv[2] !== "--bootstrap") { + if (process.argv.length === 5 && process.argv[2] === "--bootstrap-windows") { + import("./installer-windows.mjs").then(({ bootstrapWindows }) => bootstrapWindows({ bundle: process.argv[3], tools: process.argv[4], env: process.env })).catch(() => { + console.error("Windows bootstrap failed; policy, prerequisite or bundle evidence rejected. No installation completed."); + process.exitCode = 1; + }); + } else if (process.argv.length !== 5 || process.argv[2] !== "--bootstrap") { console.error("Use the bundle's POSIX bootstrap script"); process.exitCode = 1; } else { diff --git a/scripts/installer-windows-artifacts.json b/scripts/installer-windows-artifacts.json new file mode 100644 index 000000000..2f844af2c --- /dev/null +++ b/scripts/installer-windows-artifacts.json @@ -0,0 +1,15 @@ +{ + "node": { + "version": "24.21.0", + "source": "https://nodejs.org/dist/v24.21.0/SHASUMS256.txt", + "maxBytes": 104857600, + "x64": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-x64.zip", + "sha256": "158f7685b44de51f6c0df1d153526cbcd3e1bc739a8dfc607721cef75de9e541" + }, + "arm64": { + "url": "https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-arm64.zip", + "sha256": "8779b1bde1d39f8d420e3b57aa657b39891af434d3de44a919044cec06785921" + } + } +} diff --git a/scripts/installer-windows.mjs b/scripts/installer-windows.mjs new file mode 100644 index 000000000..49745a232 --- /dev/null +++ b/scripts/installer-windows.mjs @@ -0,0 +1,277 @@ +import { lstatSync, readFileSync, realpathSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve, win32 } from "node:path"; +import { spawnSync } from "node:child_process"; +import { gunzipSync } from "node:zlib"; +import { artifactFor, verifiedDownload, compatibleEngine, launchWizard } from "./installer-downloads.mjs"; + +export const windowsNodeFloor = ">=24.3.0"; +const maxExpandedBytes = 128 * 1024 * 1024; + +/** Validate the whole namespace, including implicit parents, before any writes. + * Windows paths are case-insensitive here even on portable fixture hosts. + */ +export function validateWindowsEntries(entries) { + if (!Array.isArray(entries) || entries.length === 0 || entries.length > 50000) throw new Error("Unsafe archive entry count"); + const explicit = new Set(); + const namespace = new Map(); + for (const entry of entries) { + if (typeof entry.name !== "string" || entry.link || entry.name.includes("\\")) throw new Error("Unsafe archive entry"); + const name = entry.directory && entry.name.endsWith("/") ? entry.name.slice(0, -1) : entry.name; + const parts = name.split("/"); + if (parts.some((part) => !part || part === "." || part === ".." || /[<>:"|?*\x00-\x1f\x7f]/.test(part) || /[. ]$/.test(part) || /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/i.test(part))) { + throw new Error("Unsafe archive path"); + } + const key = name.toLowerCase(); + if (explicit.has(key)) throw new Error("Unsafe duplicate archive path"); + explicit.add(key); + for (let index = 1; index <= parts.length; index += 1) { + const path = parts.slice(0, index).join("/"); + const directory = index < parts.length || Boolean(entry.directory); + const previous = namespace.get(path.toLowerCase()); + if (previous && (previous.path !== path || previous.directory !== directory)) throw new Error("Unsafe archive alias"); + namespace.set(path.toLowerCase(), { path, directory }); + } + } +} + +// Complete known npm cmd-shim template. EndLocal restores PATH/PATHEXT before +// the child starts; no Node flags or extra environment assignments are accepted. +const shimStart = `@ECHO off +GOTO start +:find_dp0 +SET dp0=%~dp0 +EXIT /b +:start +SETLOCAL +CALL :find_dp0 + +IF EXIST "%dp0%\\node.exe" ( + SET "_prog=%dp0%\\node.exe" +) ELSE ( + SET "_prog=node" + SET PATHEXT=%PATHEXT:;.JS;=;% +) + +endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\`; +export function proveWindowsWrapper(text) { + if (typeof text !== "string") throw new Error("Unknown pnpm wrapper"); + const normalized = text.replaceAll("\r\n", "\n"); + for (const entry of ["node_modules\\pnpm\\bin\\pnpm.cjs", "node_modules\\pnpm\\bin\\pnpm.mjs"]) { + if (normalized === `${shimStart}${entry}" %*\n`) { + return { entry, localNode: "node.exe", fallbackNode: "node", flags: [], environment: "inherited" }; + } + } + throw new Error("Unknown pnpm wrapper; refusing execution or replacement"); +} + +function regular(path) { + try { const info = lstatSync(path); return info.isFile() && !info.isSymbolicLink(); } + catch (error) { if (error.code === "ENOENT") return false; throw error; } +} +function exists(path) { + try { lstatSync(path); return true; } + catch (error) { if (error.code === "ENOENT") return false; throw error; } +} +export function windowsProcessCheck(command, args, env) { + // Owned direct children only, not a tree/global kill. Descendant-held stdio + // is outside this guarantee. Never execute .cmd through a command interpreter. + if (/\.(?:cmd|bat)$/i.test(command)) throw new Error("Unproven command interpreter target"); + const result = spawnSync(command, args, { env, shell: false, encoding: "utf8", timeout: 15000, killSignal: "SIGKILL", maxBuffer: 1024 * 1024, windowsHide: true }); + if (result.error || result.status !== 0) throw new Error("Windows prerequisite process failed"); + return result.stdout.trim(); +} + +/** Bounded fixture model of the fixed native predicate, not an ACL evaluator. + * ReadAndExecute + Synchronize are harmless for this mutation boundary. Only + * distant existing directories may also allow sibling CreateDirectories (4). + * Effective inherited ACEs are checked again on every actual path component. + */ +export function windowsAclRuleUnsafe({ rights, depth, allow = true, inheritOnly = false, trusted = false }) { + if (!Number.isInteger(rights) || rights < 0 || rights > 0xffffffff || !Number.isInteger(depth) || depth < 0) throw new Error("Unknown Windows ACL fixture"); + if (!allow || inheritOnly || trusted) return false; + const allowedRights = depth >= 2 ? 0x1200ad : 0x1200a9; + return (rights & ~allowedRights) !== 0; +} + +// Fixed stock PowerShell intrinsics, not a loaded/evaluated PS script. Paths +// travel only as environment data. Managed constraints/denials fail closed. +const aclCheck = String.raw` +$ErrorActionPreference = 'Stop'; +if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Policy constrained' }; +$me = [Security.Principal.WindowsIdentity]::GetCurrent().User.Value; +$trusted = @($me, 'S-1-5-18', 'S-1-5-32-544', 'S-1-5-80-956008885-3418522649-1831038044-1853292631-2271478464'); +$path = [IO.Path]::GetFullPath($env:GENTLE_WINDOWS_CHECK); +$depth = 0; +while ($path) { + $item = Get-Item -LiteralPath $path -Force; + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Reparse path' }; + $acl = Get-Acl -LiteralPath $path; + if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw 'Unknown owner' }; + $allowedRights = 0x1200a9; if ($depth -ge 2) { $allowedRights = 0x1200ad }; + foreach ($rule in $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])) { + if ($rule.AccessControlType -eq 'Allow' -and -not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -and ([long]$rule.FileSystemRights -band (-bnot [long]$allowedRights)) -and $trusted -notcontains $rule.IdentityReference.Value) { throw 'Unsafe ACL' }; + }; + $parent = [IO.Directory]::GetParent($path); + if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++; +}; +'safe' +`; +export function verifyWindowsStorage(path, env, processAdapter = windowsProcessCheck) { + if (process.platform !== "win32") throw new Error("Native Windows storage verification unavailable"); + if (!win32.isAbsolute(path) || path.startsWith("\\\\")) throw new Error("Unsafe Windows storage path"); + if (processAdapter(join(env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", aclCheck], { ...env, GENTLE_WINDOWS_CHECK: path }) !== "safe") { + throw new Error("Windows ACL evidence rejected"); + } +} + +/** Strict bounded tar reader: no system tar, archive links or archive execution. + * Unsupported GNU/PAX extensions stop rather than extract using guessed semantics. + */ +export function readWindowsPnpmArchive(bytes) { + const tar = gunzipSync(bytes, { maxOutputLength: maxExpandedBytes }); + const entries = []; + let offset = 0; + let ended = false; + const text = (block, start, length) => { + const value = block.subarray(start, start + length); + const zero = value.indexOf(0); + const data = zero === -1 ? value : value.subarray(0, zero); + const result = data.toString("utf8"); + if (!Buffer.from(result).equals(data)) throw new Error("Unsafe tar text encoding"); + return result; + }; + const octal = (value) => { + if (!/^[0-7]+$/.test(value.trim())) throw new Error("Unsafe tar number"); + return Number.parseInt(value.trim(), 8); + }; + while (offset + 512 <= tar.length) { + const block = tar.subarray(offset, offset + 512); + if (block.every((value) => value === 0)) { + if (tar.length < offset + 1024 || !tar.subarray(offset).every((value) => value === 0)) throw new Error("Unsafe tar termination"); + ended = true; + break; + } + const expected = octal(text(block, 148, 8)); + let checksum = 0; + for (let index = 0; index < 512; index += 1) checksum += index >= 148 && index < 156 ? 32 : block[index]; + if (checksum !== expected || text(block, 257, 6) !== "ustar") throw new Error("Unsafe tar header"); + const size = octal(text(block, 124, 12)); + const prefix = text(block, 345, 155); + const name = `${prefix ? `${prefix}/` : ""}${text(block, 0, 100)}`; + const type = block[156]; + if (![0, 48, 53].includes(type) || text(block, 157, 100) || (type === 53 && size !== 0)) throw new Error("Unsafe tar entry type"); + const next = offset + 512 + Math.ceil(size / 512) * 512; + if (next > tar.length) throw new Error("Unsafe truncated tar"); + entries.push({ name, directory: type === 53, bytes: tar.subarray(offset + 512, offset + 512 + size) }); + if (entries.length > 50000) throw new Error("Unsafe tar entry count"); + offset = next; + } + if (!ended) throw new Error("Unsafe tar termination"); + validateWindowsEntries(entries); + if (entries.some((entry) => !entry.name.startsWith("package/") && entry.name !== "package")) throw new Error("Unsafe pnpm package root"); + return entries; +} + +function findWindowsCommand(env, name = "pnpm") { + // Alternate CMD cwd-search policy is not silently flattened into our model. + if (Object.keys(env).some((key) => key.toLowerCase() === "nodefaultcurrentdirectoryinexepath")) throw new Error("Unknown Windows cwd-search semantics"); + const value = (name) => Object.entries(env).find(([key]) => key.toLowerCase() === name)?.[1]; + const path = value("path"); + const pathExt = value("pathext"); + if (typeof path !== "string" || typeof pathExt !== "string") throw new Error("Unknown Windows PATH/PATHEXT"); + const extensions = pathExt.toLowerCase().split(";"); + if (!extensions.length || new Set(extensions).size !== extensions.length || extensions.some((extension) => !/^\.(com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc)$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics"); + for (const directory of [process.cwd(), ...path.split(";")]) { + if (!win32.isAbsolute(directory) || directory.startsWith("\\\\") || directory.includes('"')) throw new Error("Unknown Windows PATH"); + // CMD searches cwd too. An extensionless/other target must block rather + // than be silently skipped; honor the wrapper's inherited PATHEXT order. + if (exists(join(directory, name))) throw new Error("Unknown extensionless Windows prerequisite"); + for (const extension of extensions) { + const command = join(directory, `${name}${extension}`); + if (exists(command)) return command; + } + } + return null; +} +function proveCli(node, entry, metadata, env, processAdapter) { + const version = processAdapter(node, ["--version"], env); + if (metadata.name !== "pnpm" || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(metadata.version) || !compatibleEngine(windowsNodeFloor, version) || !compatibleEngine(metadata.engines?.node, version)) { + throw new Error("Windows pnpm engine or identity rejected"); + } + if (processAdapter(node, [entry, "--version"], env) !== metadata.version) throw new Error("Windows pnpm version rejected"); + for (const capability of ["add", "bin"]) { + if (!/(?:^|[\s,])--global(?:[\s,=]|$)/.test(processAdapter(node, [entry, "help", capability], env))) throw new Error("Windows pnpm global capability rejected"); + } +} + +/** Returns a direct invocation, NOT a fabricated npm/pnpm executable shim. + * T4 must call command + prefix with shell:false and retain this child env. + */ +export async function ensureWindowsPnpm({ tools, env, node = process.execPath, adapters = {} }) { + const processAdapter = adapters.process ?? windowsProcessCheck; + const storage = adapters.storage ?? verifyWindowsStorage; + const existing = (adapters.findCommand ?? findWindowsCommand)(env); + if (existing) { + storage(existing, env); + if (!/\.cmd$/i.test(existing) || !regular(existing)) throw new Error("Unknown pnpm wrapper; refusing replacement"); + const proof = proveWindowsWrapper(readFileSync(existing, "utf8")); + const directory = dirname(existing); + const entry = join(directory, ...proof.entry.split("\\")); + const localNode = join(directory, proof.localNode); + // npm cmd-shim selects its sibling node.exe before PATH node. Preserve it. + const selectedNode = exists(localNode) ? localNode : (adapters.findNode ?? ((env) => findWindowsCommand(env, "node")))(env); + if (!selectedNode || !/\.exe$/i.test(selectedNode)) throw new Error("Unknown wrapper-selected Node"); + const metadataPath = join(directory, "node_modules", "pnpm", "package.json"); + for (const path of [selectedNode, entry, metadataPath]) { + storage(path, env); + if (!regular(path)) throw new Error("Unsafe pnpm wrapper target"); + } + const metadata = JSON.parse(readFileSync(metadataPath, "utf8")); + if (metadata.bin?.pnpm !== `bin/${proof.entry.endsWith("pnpm.cjs") ? "pnpm.cjs" : "pnpm.mjs"}`) throw new Error("Windows pnpm package target rejected"); + proveCli(selectedNode, entry, metadata, env, processAdapter); + return { acquired: false, env, command: selectedNode, prefix: [entry] }; + } + storage(tools, env); + if (!lstatSync(tools).isDirectory() || realpathSync(tools) !== resolve(tools)) throw new Error("Unsafe Windows tools"); + const destination = join(tools, "pnpm"); + if (exists(destination)) throw new Error("Conflicting Windows pnpm destination"); + const entries = readWindowsPnpmArchive(await verifiedDownload("pnpm", adapters)); + const pin = artifactFor("pnpm"); + const packageEntry = entries.find((entry) => entry.name === "package/package.json" && !entry.directory); + const metadata = packageEntry && JSON.parse(packageEntry.bytes.toString("utf8")); + if (!metadata || metadata.name !== pin.name || metadata.version !== pin.version || metadata.engines?.node !== pin.engine || metadata.bin?.pnpm !== "bin/pnpm.mjs") throw new Error("Windows pnpm pin metadata rejected"); + if (!entries.some((entry) => entry.name === "package/bin/pnpm.mjs" && !entry.directory)) throw new Error("Windows pnpm entry missing"); + let owned = false; + try { + mkdirSync(destination); // atomic no-clobber claim under the verified private root + owned = true; + storage(destination, env); + for (const entry of entries) { + const path = join(destination, ...entry.name.split("/")); + if (entry.directory) mkdirSync(path, { recursive: true }); + else { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, entry.bytes, { flag: "wx" }); + } + } + const entry = join(destination, "package", "bin", "pnpm.mjs"); + proveCli(node, entry, metadata, env, processAdapter); + return { acquired: true, env, command: node, prefix: [entry] }; + } catch { + if (owned) rmSync(destination, { recursive: true, force: true }); + throw new Error("Windows pnpm acquisition failed; no installation completed"); + } +} + +export async function bootstrapWindows({ bundle, tools, env }) { + if (process.platform !== "win32") throw new Error("Windows bootstrap requires native Windows"); + for (const file of ["package.json", "bin/gentle-shell-install.mjs", "scripts/installer-downloads.mjs", "scripts/installer-windows.mjs", "scripts/installer-windows-artifacts.json"]) { + if (!regular(join(bundle, file))) throw new Error("Required wizard bundle file missing"); + } + const metadata = JSON.parse(readFileSync(join(bundle, "package.json"), "utf8")); + if (!compatibleEngine(windowsNodeFloor, process.versions.node) || !compatibleEngine(metadata.engines?.node, process.versions.node) || metadata.packageManager !== `pnpm@${artifactFor("pnpm").version}`) throw new Error("Windows repository prerequisite rejected"); + verifyWindowsStorage(tools, env); + const result = await ensureWindowsPnpm({ tools, env }); + // Fixed data handoff for the future T5 entry. No caller-controlled command text. + await launchWizard({ bundle, env: { ...result.env, GENTLE_INSTALL_PNPM_NODE: result.command, GENTLE_INSTALL_PNPM_ENTRY: result.prefix[0] } }); +} diff --git a/tests/installer-windows-bootstrap.test.ts b/tests/installer-windows-bootstrap.test.ts new file mode 100644 index 000000000..3bf5464ff --- /dev/null +++ b/tests/installer-windows-bootstrap.test.ts @@ -0,0 +1,634 @@ +import assert from "node:assert/strict"; +import { readFileSync, mkdtempSync, mkdirSync, writeFileSync, readdirSync, rmSync, copyFileSync, existsSync, symlinkSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; +import { gzipSync } from "node:zlib"; +import { spawn, spawnSync } from "node:child_process"; +import { pathToFileURL } from "node:url"; +import test from "node:test"; +import { artifactFor, compatibleEngine } from "../scripts/installer-downloads.mjs"; +import { validateWindowsEntries, proveWindowsWrapper, windowsNodeFloor, readWindowsPnpmArchive, ensureWindowsPnpm, windowsProcessCheck, windowsAclRuleUnsafe, verifyWindowsStorage } from "../scripts/installer-windows.mjs"; + +const wrapper = (entry: string) => `@ECHO off\nGOTO start\n:find_dp0\nSET dp0=%~dp0\nEXIT /b\n:start\nSETLOCAL\nCALL :find_dp0\n\nIF EXIST "%dp0%\\node.exe" (\n SET "_prog=%dp0%\\node.exe"\n) ELSE (\n SET "_prog=node"\n SET PATHEXT=%PATHEXT:;.JS;=;%\n)\n\nendLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\${entry}" %*\n`; + +test("Windows descriptors select immutable official ZIPs without changing POSIX pins", () => { + for (const arch of ["x64", "arm64"]) { + const descriptor = artifactFor("node", "win32", arch); + assert.equal(descriptor.version, "24.21.0"); + assert.equal(descriptor.url, `https://nodejs.org/dist/v24.21.0/node-v24.21.0-win-${arch}.zip`); + assert.match(descriptor.integrity, /^sha256-[a-f0-9]{64}$/); + } + assert.equal(artifactFor("node", "linux", "x64").integrity, "sha256-6e1db87ef58b8819e5d5402eff1536491b18edd8eb7bee5ef7897876e88dc5ff"); + assert.throws(() => artifactFor("node", "win32", "ia32")); + assert.equal(windowsNodeFloor, ">=24.3.0"); + assert.equal(compatibleEngine(windowsNodeFloor, "24.2.0"), false); +}); + +test("Windows archive namespace rejects traversal, aliases, reserved names, ADS and links", () => { + assert.doesNotThrow(() => validateWindowsEntries([{ name: "package/", directory: true }, { name: "package/bin/pnpm.mjs" }])); + for (const name of ["../escape", "/absolute", "C:/drive", "package/a:b", "package/a.", "package/a ", "package/CON.txt", "package/aux", "package/COM1", "package/LPT9.x", "package/a\\b", "package//b", "package/./b", "package/\u0000x"]) { + assert.throws(() => validateWindowsEntries([{ name }]), /Unsafe/); + } + assert.throws(() => validateWindowsEntries([{ name: "package/A" }, { name: "package/a" }]), /Unsafe/); + assert.throws(() => validateWindowsEntries([{ name: "Package/a" }, { name: "package/b" }]), /Unsafe/); + assert.throws(() => validateWindowsEntries([{ name: "package/a", link: true }]), /Unsafe/); + assert.throws(() => validateWindowsEntries([{ name: "package/a" }, { name: "package/a/b" }]), /Unsafe/); +}); + +test("only the complete known npm cmd shim proves selected Node and exact JS target", () => { + const entry = "node_modules\\pnpm\\bin\\pnpm.cjs"; + assert.deepEqual(proveWindowsWrapper(wrapper(entry)), { entry, localNode: "node.exe", fallbackNode: "node", flags: [], environment: "inherited" }); + assert.deepEqual(proveWindowsWrapper(wrapper(entry).replaceAll("\n", "\r\n")), proveWindowsWrapper(wrapper(entry))); + for (const altered of [wrapper(entry) + "echo changed\n", wrapper(entry).replace('"%_prog%"', '"%_prog%" --require evil'), wrapper(entry).replace("SETLOCAL", "SET NODE_OPTIONS=--require evil\nSETLOCAL"), wrapper("elsewhere\\pnpm.cjs"), "@node pnpm.mjs %*"]) { + assert.throws(() => proveWindowsWrapper(altered), /Unknown pnpm wrapper/); + } +}); + +test("CMD entry contains fixed commands, data-only paths, early bundle checks and no policy bypass", () => { + const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + assert.match(source, /DisableDelayedExpansion/i); + assert.match(source, /\$env:GENTLE_BOOTSTRAP_BUNDLE/); + assert.ok(source.indexOf("gentle-shell-install.mjs") < source.indexOf("New-Item")); + assert.doesNotMatch(source, /ExecutionPolicy|Unblock-File|EncodedCommand|Invoke-Expression|\biex\b|\.ps1\b|SkipCertificateCheck|RunAs/i); + assert.match(source, /LanguageMode/); + assert.match(source, /ReparsePoint/); + assert.match(source, /Get-Acl/); + assert.match(source, /AllowAutoRedirect\s*=\s*\$false/); + assert.match(source, /Get-FileHash/); +}); + +test("authored sources retain readable lines and normalized whitespace", () => { + for (const path of ["scripts/bootstrap.cmd", "scripts/installer-downloads.mjs", "scripts/installer-windows.mjs", "scripts/installer-windows-artifacts.json", "tests/installer-windows-bootstrap.test.ts", "docs/install-wizard.md"]) { + const source = readFileSync(new URL(`../${path}`, import.meta.url), "utf8"); + assert.ok(source.endsWith("\n"), path); + assert.doesNotMatch(source, /[ \t]+\r?$/m, path); + } + const batch = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + for (const command of batch.replaceAll(/\^\r?\n/g, " ").split(/\r?\n/)) assert.ok(command.length < 7000, "fixed CMD command leaves expansion headroom below 8191 characters"); +}); + +// A bounded interpretation of the exact fixed predicates, not Windows ACL +// execution. The legacy predicate's composite Write includes AppendData (4). +function fixedAclRejects(source: string, rights: number, depth: number) { + if (source.includes("'Write,Delete,DeleteSubdirectoriesAndFiles,ChangePermissions,TakeOwnership'")) return (rights & 0xd0156) !== 0; + assert.match(source, /\$allowedRights = 0x1200a9; if \(\$depth -ge 2\) \{ \$allowedRights = 0x1200ad \}/); + assert.match(source, /\[long\]\$rule.FileSystemRights -band \(-bnot \[long\]\$allowedRights\)/); + assert.match(source, /\$depth\+\+/); + return (rights & ~(depth >= 2 ? 0x1200ad : 0x1200a9)) !== 0; +} +function productionAclSources() { + const batch = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + return [ + batch.slice(batch.indexOf("rem Claim"), batch.indexOf("rem Select")), + batch.slice(batch.indexOf("rem Production direct"), batch.indexOf("$start = New-Object")), + readFileSync(new URL("../scripts/installer-windows.mjs", import.meta.url), "utf8").split("const aclCheck = String.raw`")[1].split("`;")[0], + ]; +} +test("fixed ACL predicates allow sibling-only CreateDirectories on distant existing ancestors", () => { + for (const source of productionAclSources()) { + assert.equal(fixedAclRejects(source, 4, 2), false, "distant sibling creation alone cannot overwrite a protected existing descendant"); + assert.equal(fixedAclRejects(source, 4, 0), true, "actual target remains protected"); + assert.equal(fixedAclRejects(source, 4, 1), true, "immediate parent remains protected"); + } +}); +test("fixed ACL predicates retain dangerous-right rejection at every depth", () => { + for (const source of productionAclSources()) { + for (const depth of [0, 1, 2, 5]) { + for (const rights of [2, 16, 256, 64, 65536, 262144, 524288, 0xd0156, 4 | 64]) assert.equal(fixedAclRejects(source, rights, depth), true); + } + } +}); + +test("portable ACL fixture model matches fixed predicates without treating inheritance as effective access", () => { + for (const depth of [0, 1, 2, 9]) { + for (const rights of [0, 4, 0x1200a9, 0x1200ad, 2, 16, 256, 64, 65536, 262144, 524288, 0x10000000, 0x40000000, 0xffffffff]) { + const rule = { rights, depth }; + for (const source of productionAclSources()) assert.equal(windowsAclRuleUnsafe(rule), fixedAclRejects(source, rights, depth)); + assert.equal(windowsAclRuleUnsafe({ ...rule, trusted: true }), false); + assert.equal(windowsAclRuleUnsafe({ ...rule, allow: false }), false); + assert.equal(windowsAclRuleUnsafe({ ...rule, inheritOnly: true }), false); + } + } + assert.throws(() => windowsAclRuleUnsafe({ rights: -1, depth: 0 })); + const batch = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + assert.match(productionAclSources()[0], /\$depth = 1/); + assert.match(productionAclSources()[1], /\$depth = 0/); + assert.match(productionAclSources()[2], /\$depth = 0/); + assert.match(batch, /AreAccessRulesProtected/); + assert.match(batch, /\$claimed = \$true/); +}); + +function tarFixture(files: Record, type = "0") { + const chunks: Buffer[] = []; + for (const [name, content] of Object.entries(files)) { + const bytes = Buffer.from(content); + const header = Buffer.alloc(512); + header.write(name, 0, 100); + header.write("0000644\0", 100); + header.write("0000000\0", 108); + header.write("0000000\0", 116); + header.write(bytes.length.toString(8).padStart(11, "0") + "\0", 124); + header.write("00000000000\0", 136); + header.fill(32, 148, 156); + header.write(type, 156); + header.write("ustar\0", 257); + header.write("00", 263); + const checksum = header.reduce((sum, value) => sum + value, 0); + header.write(checksum.toString(8).padStart(6, "0") + "\0 ", 148); + chunks.push(header, bytes, Buffer.alloc((512 - bytes.length % 512) % 512)); + } + return gzipSync(Buffer.concat([...chunks, Buffer.alloc(1024)])); +} +const pinnedPackage = { name: "pnpm", version: "11.1.1", engines: { node: ">=22.13" }, bin: { pnpm: "bin/pnpm.mjs" } }; +function pnpmTar(metadata = pinnedPackage) { + return tarFixture({ "package/package.json": JSON.stringify(metadata), "package/bin/pnpm.mjs": "// fixture only" }); +} +const digest = () => Buffer.from(artifactFor("pnpm").integrity.slice(7), "base64"); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "gentle windows 雪 & ! % (fixture) ")); + return { root, cleanup: () => rmSync(root, { recursive: true, force: true }) }; +} + +test("tar bytes are fully validated and bounded before publication", () => { + assert.equal(readWindowsPnpmArchive(pnpmTar()).length, 2); + for (const bytes of [Buffer.from("not gzip"), tarFixture({ "package/../escape": "x" }), tarFixture({ "package/link": "" }, "2"), tarFixture({ "package/CON": "x" }), tarFixture({ "package/a": "a", "package/A": "b" }), tarFixture({ "package/extended": "x" }, "x")]) { + assert.throws(() => readWindowsPnpmArchive(bytes)); + } +}); + +test("missing pnpm publishes only verified package data and returns direct invocation", async () => { + const f = fixture(); + try { + const calls: string[][] = []; + const env = { PATH: "fixture", NODE_OPTIONS: "inherited" }; + const result = await ensureWindowsPnpm({ tools: f.root, env, adapters: { + findCommand: () => null, storage: () => {}, download: async () => pnpmTar(), digest, + process: (_command: string, args: string[], childEnv: Record) => { + calls.push(args); assert.equal(childEnv, env); + return args[0] === "--version" ? "v24.21.0" : args.at(-1) === "--version" ? "11.1.1" : "--global"; + }, + } }); + assert.equal(result.command, process.execPath); + assert.deepEqual(result.prefix, [join(f.root, "pnpm/package/bin/pnpm.mjs")]); + assert.equal(result.env, env); + assert.equal(result.acquired, true); + assert.equal(calls.length, 4); + assert.equal(existsSync(join(f.root, "pnpm/package/package.json")), true); + assert.equal(existsSync(join(f.root, "pnpm.cmd")), false); + } finally { f.cleanup(); } +}); + +for (const failure of ["integrity", "namespace", "metadata", "probe", "conflict", "claim-race", "storage"]) { + test(`pnpm fails closed without unrelated cleanup: ${failure}`, async () => { + const f = fixture(); + try { + writeFileSync(join(f.root, "unrelated"), "preserve"); + if (failure === "conflict") mkdirSync(join(f.root, "pnpm")); + let processes = 0; + await assert.rejects(ensureWindowsPnpm({ tools: f.root, env: {}, adapters: { + findCommand: () => null, + storage: () => { if (failure === "storage") throw new Error("ACL rejected"); }, + download: async () => { + if (failure === "claim-race") { mkdirSync(join(f.root, "pnpm")); writeFileSync(join(f.root, "pnpm/not-owned"), "preserve"); } + return failure === "namespace" ? tarFixture({ "package/../escape": "x" }) : failure === "metadata" ? pnpmTar({ ...pinnedPackage, engines: { node: ">=22.13.0" } }) : pnpmTar(); + }, + digest: failure === "integrity" ? () => Buffer.alloc(64) : digest, + process: () => { processes++; throw new Error("failed"); }, + } })); + assert.equal(readFileSync(join(f.root, "unrelated"), "utf8"), "preserve"); + assert.equal(processes, failure === "probe" ? 1 : 0); + if (failure === "claim-race") assert.equal(readFileSync(join(f.root, "pnpm/not-owned"), "utf8"), "preserve"); + else assert.deepEqual(readdirSync(f.root).sort(), failure === "conflict" ? ["pnpm", "unrelated"] : ["unrelated"]); + } finally { f.cleanup(); } + }); +} + +for (const sibling of [true, false]) { + test(`known cmd wrapper honors ${sibling ? "sibling" : "PATH"}-selected Node without cmd.exe`, async () => { + const f = fixture(); + try { + const command = join(f.root, "pnpm.cmd"); + writeFileSync(command, wrapper("node_modules\\pnpm\\bin\\pnpm.mjs")); + const selected = join(f.root, sibling ? "node.exe" : "path-node.exe"); + writeFileSync(selected, "not executed fixture"); + const packageDir = join(f.root, "node_modules/pnpm"); + mkdirSync(join(packageDir, "bin"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify(pinnedPackage)); + writeFileSync(join(packageDir, "bin/pnpm.mjs"), "fixture"); + const env = { NODE_OPTIONS: "preserved", PATHEXT: ".EXE;.CMD" }; + const result = await ensureWindowsPnpm({ tools: f.root, env, adapters: { + findCommand: () => command, findNode: () => selected, storage: () => {}, + download: () => { throw new Error("must not download"); }, + process: (node: string, args: string[], childEnv: Record) => { assert.equal(node, selected); assert.equal(childEnv, env); return args[0] === "--version" ? "v24.21.0" : args.at(-1) === "--version" ? "11.1.1" : "--global"; }, + } }); + assert.equal(result.acquired, false); + assert.equal(result.command, selected); + } finally { f.cleanup(); } + }); +} + +test("production process adapter rejects cmd, nonzero exits and excess output", () => { + assert.throws(() => windowsProcessCheck("pnpm.cmd", [], process.env)); + assert.throws(() => windowsProcessCheck(process.execPath, ["-e", "process.exit(3)"], process.env)); + assert.throws(() => windowsProcessCheck(process.execPath, ["-e", "process.stdout.write('x'.repeat(2*1024*1024))"], process.env)); + assert.equal(windowsProcessCheck(process.execPath, ["--version"], process.env), process.version); +}); + +test("production direct-child deadline rejects valid output followed by hang (Linux process evidence only)", { skip: process.platform === "win32" ? "Windows needs independent native outer-guard lane" : false }, async () => { + const f = fixture(); + try { + const pidFile = join(f.root, "probe.pid"); + const probe = join(f.root, "probe.mjs"); + writeFileSync(probe, `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); process.on('SIGTERM', () => {}); console.log('v24.21.0'); setInterval(() => {}, 1000);`); + const moduleUrl = pathToFileURL(join(process.cwd(), "scripts/installer-windows.mjs")).href; + const runner = join(f.root, "runner.mjs"); + writeFileSync(runner, `import { windowsProcessCheck } from ${JSON.stringify(moduleUrl)}; try { windowsProcessCheck(process.execPath, [${JSON.stringify(probe)}], process.env); process.exitCode = 2; } catch { console.log('rejected'); }`); + const child = spawn(process.execPath, [runner], { detached: true, stdio: ["ignore", "pipe", "pipe"] }); + let guarded = false; + const killGroup = () => { if (child.pid) { try { process.kill(-child.pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } } }; + const timer = setTimeout(() => { guarded = true; killGroup(); }, 18000); + let output = ""; child.stdout.on("data", (bytes: Buffer) => { output += bytes.toString(); }); child.stderr.resume(); + try { + const code = await new Promise((resolveChild, reject) => { child.once("error", reject); child.once("close", resolveChild); }); + assert.equal(guarded, false); assert.equal(code, 0); assert.match(output, /rejected/); + const pid = Number(readFileSync(pidFile, "utf8")); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + } finally { clearTimeout(timer); killGroup(); } + } finally { f.cleanup(); } +}); + +const nativeUnavailable = process.platform !== "win32" ? "unavailable: Linux host without Windows PowerShell runner" : false; +test("native Windows entry: spaces, Unicode, CMD metacharacters and early missing bundle stop", { skip: nativeUnavailable }, () => { + const f = fixture(); + try { + const scripts = join(f.root, "scripts"); mkdirSync(scripts); + copyFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), join(scripts, "bootstrap.cmd")); + const local = join(f.root, "home"); mkdirSync(local); + const result = spawnSync(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", "scripts\\bootstrap.cmd"], { cwd: f.root, env: { ...process.env, LOCALAPPDATA: local }, timeout: 5000, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 1024 * 1024 }); + assert.equal(result.error, undefined); assert.equal(result.status, 1); + assert.match(result.stderr, /No acquisition attempted/); + assert.deepEqual(readdirSync(local), []); + } finally { f.cleanup(); } +}); + +// Native acceptance uses exact CMD command lines, including continuation and +// argument reconstruction. These fixture-only compositions never load PS files. +// Stage publication proof is NOT official artifact-integrity or whole-entry proof. +function cmdStage(marker: string) { + const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + const markerAt = source.indexOf(marker); + assert.ok(markerAt >= 0, marker); + const start = source.indexOf('"%GENTLE_BOOTSTRAP_PS%" -NoLogo', markerAt); + const end = source.indexOf("\nif errorlevel 1 goto failed", start); + const cleanupEnd = source.indexOf("\n:finishfailure", start); + assert.ok(start >= 0 && (end >= 0 || cleanupEnd >= 0)); + return source.slice(start, end >= 0 ? end : cleanupEnd); +} +const stageMarkers = { + bundle: "rem Bundle/dependency", claim: "rem Claim", resolve: "rem Select", + zip: "rem Inspect the entire ZIP", probe: "rem Production direct", + launch: "rem Launch the existing", cleanup: "rem Never scan/reuse/clean", +} as const; +const psRecordLine = ' "$record = [Diagnostics.Process]::GetCurrentProcess(); [IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$record.Id + [char]124 + [string]$record.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^'; +function observeStage(stage: string) { + const lines = stage.split("\n"); + const index = lines.findIndex((line) => line.includes("LanguageMode")); + assert.ok(index >= 0); + lines.splice(index + 1, 0, psRecordLine); + return lines.join("\n"); +} +function cmdComposition(stages: string[]) { + return ["@echo off", "setlocal DisableDelayedExpansion", 'set "GENTLE_BOOTSTRAP_PS=%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"', + ...stages.flatMap((stage) => [...observeStage(stage).split("\n"), "if errorlevel 1 exit /b 1"]), + "echo fixture-sentinel", "exit /b 0", ""].join("\r\n"); +} + +// Only pre-start setup is fixture-specific: a known Node runs a bounded local JS +// probe instead of --version. The real start/drain/deadline/validation/finally +// lines remain unchanged; recording observes owned handles without altering them. +function processPrimitive() { + const stage = cmdStage(stageMarkers.probe).split("\n"); + const childAt = stage.findIndex((line) => line.includes("$child = New-Object Diagnostics.Process;")); + const languageAt = stage.findIndex((line) => line.includes("LanguageMode")); + assert.ok(childAt > languageAt); + const prefix = stage.slice(0, languageAt + 1); + prefix.push(' "$tools = $env:GENTLE_BOOTSTRAP_TOOLS; $start = New-Object Diagnostics.ProcessStartInfo; $start.FileName = $env:GENTLE_FIXTURE_NODE; $start.Arguments = [string][char]34 + $env:GENTLE_FIXTURE_PROBE + [char]34; $start.UseShellExecute = $false; $start.CreateNoWindow = $true; $start.RedirectStandardOutput = $true; $start.RedirectStandardError = $true;" ^'); + const recordChild = ' "[IO.File]::AppendAllText($env:GENTLE_FIXTURE_RECORDS,([string]$child.Id + [char]124 + [string]$child.StartTime.ToUniversalTime().Ticks + [Environment]::NewLine));" ^'; + return [...prefix, stage[childAt], recordChild, ...stage.slice(childAt + 1)].join("\n"); +} + +test("native fixture compositions retain exact production fragments and no transport command", () => { + for (const marker of Object.values(stageMarkers)) { + const original = cmdStage(marker); + const composed = cmdComposition([original]); + for (const line of original.split("\n")) assert.ok(composed.includes(line)); + assert.doesNotMatch(composed, /HttpWebRequest|ExecutionPolicy|Invoke-Expression|\.ps1\b/); + assert.match(composed, /fixture-sentinel/); + } + const original = cmdStage(stageMarkers.probe); + const primitive = processPrimitive(); + assert.ok(primitive.includes(original.slice(original.indexOf(' "$streams =')))); + assert.match(primitive, /\$child.Kill\(\)/); + assert.match(primitive, /WaitForExit\(1000\)/); +}); + +interface NativeResult { + status: number | null; + stdout: string; + stderr: string; + guardKilled: boolean; +} +function nativeFixture() { + const f = fixture(); + writeFileSync(join(f.root, ".fixture-owned"), "gentle Windows acceptance fixture"); + return f; +} +function nativeEnv(root: string, tools = join(root, "tools")) { + return { ...process.env, LOCALAPPDATA: root, GENTLE_BOOTSTRAP_TOOLS: tools, + GENTLE_BOOTSTRAP_BUNDLE: root, GENTLE_FIXTURE_ROOT: root, + GENTLE_FIXTURE_RECORDS: join(root, "process-records"), NODE_OPTIONS: "" }; +} + +// Independent hard guard: kill only fresh fixture handles with matching creation +// ticks, never a name, ambient group or unverified reused PID. No tree guarantee. +const guardCommand = String.raw` +$ErrorActionPreference = 'Stop'; +if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Policy constrained' }; +if ((Get-Content -LiteralPath (Join-Path $env:GENTLE_FIXTURE_ROOT '.fixture-owned') -Raw) -ne 'gentle Windows acceptance fixture') { throw 'Unknown fixture' }; +$reaped = $false; +if (Test-Path -LiteralPath $env:GENTLE_FIXTURE_RECORDS) { + $records = @(Get-Content -LiteralPath $env:GENTLE_FIXTURE_RECORDS); + [Array]::Reverse($records); + foreach ($record in $records) { + if ($record -notmatch '^([1-9][0-9]*)\|([1-9][0-9]*)$') { throw 'Unknown process record' }; + $ownedPid = [int]$Matches[1]; $ticks = [long]$Matches[2]; + try { $owned = [Diagnostics.Process]::GetProcessById($ownedPid) } catch { if ($_.Exception -is [ArgumentException] -or $_.Exception.InnerException -is [ArgumentException]) { continue }; throw }; + try { + if (-not $owned.HasExited -and $owned.StartTime.ToUniversalTime().Ticks -eq $ticks) { $owned.Kill(); $reaped = $true; if (-not $owned.WaitForExit(1000)) { throw 'Guard could not reap' } }; + } finally { $owned.Dispose() }; + }; +}; +if ($reaped) { [Console]::WriteLine('fixture guard reaped residual owned process') }; +`; +function cleanNativeProcesses(root: string) { + const ps = join(process.env.SystemRoot!, "System32/WindowsPowerShell/v1.0/powershell.exe"); + const result = spawnSync(ps, ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", guardCommand], { env: nativeEnv(root), timeout: 5000, killSignal: "SIGKILL", encoding: "utf8", maxBuffer: 65536 }); + assert.equal(result.error, undefined, "fixture guard must be bounded and available"); + assert.equal(result.status, 0, "fixture guard must only clean verified owned processes"); + assert.ok(["", "fixture guard reaped residual owned process"].includes(result.stdout.trim()), "unexpected fixture guard output"); + return result.stdout.trim().length > 0; +} +async function nativeCmd(root: string, stages: string[], env = nativeEnv(root), limit = 14000): Promise { + writeFileSync(join(root, "fixture.cmd"), cmdComposition(stages)); + const child = spawn(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", "fixture.cmd"], { cwd: root, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; let stderr = ""; let guardKilled = false; + let guardError: unknown; + const result = await new Promise((resolveChild, reject) => { + const timer = setTimeout(() => { + guardKilled = true; + try { cleanNativeProcesses(root); } catch (error) { guardError = error; } + child.kill("SIGKILL"); + child.stdout.destroy(); child.stderr.destroy(); + resolveChild({ status: null, stdout, stderr, guardKilled }); + }, limit); + child.stdout.on("data", (bytes: Buffer) => { stdout += bytes.toString(); if (stdout.length > 1048576) { guardError = new Error("Fixture output limit"); child.kill("SIGKILL"); } }); + child.stderr.on("data", (bytes: Buffer) => { stderr += bytes.toString(); if (stderr.length > 1048576) { guardError = new Error("Fixture output limit"); child.kill("SIGKILL"); } }); + child.once("error", (error) => { clearTimeout(timer); reject(error); }); + child.once("close", (status) => { clearTimeout(timer); resolveChild({ status, stdout, stderr, guardKilled }); }); + }); + const residualReaped = cleanNativeProcesses(root); + assert.equal(residualReaped, false, "production must reap its own recorded children; fixture cleanup cannot mask a failure"); + assert.equal(guardError, undefined); + return result; +} +function assertNative(result: NativeResult, status: number) { + assert.equal(result.guardKilled, false, "production fragment must return before the independent outer guard"); + assert.equal(result.status, status, result.stderr); + if (status === 0) assert.match(result.stdout, /fixture-sentinel/); + else assert.doesNotMatch(result.stdout, /fixture-sentinel/); +} + +// Fixture ACL mutations are restricted to NEW owned descendants, never operator +// directories, global ACLs, policies or the existing Node/PowerShell installation. +const fixtureAclSetup = `"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Policy constrained' };" ^ + "$root = [IO.Path]::GetFullPath($env:GENTLE_FIXTURE_ROOT); $target = [IO.Path]::GetFullPath($env:GENTLE_FIXTURE_ACL_TARGET);" ^ + "if (-not $target.StartsWith($root + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase) -or (Get-Content -LiteralPath (Join-Path $root '.fixture-owned') -Raw) -ne 'gentle Windows acceptance fixture') { throw 'Not fixture-owned' };" ^ + "$item = Get-Item -LiteralPath $target -Force; if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw 'Unsafe fixture' };" ^ + "$rights = [int]$env:GENTLE_FIXTURE_RIGHTS; if (@(4,2,16,256,64,65536,262144,524288) -notcontains $rights) { throw 'Unknown fixture right' };" ^ + "$acl = Get-Acl -LiteralPath $target; $sid = New-Object Security.Principal.SecurityIdentifier('S-1-1-0');" ^ + "$rule = New-Object Security.AccessControl.FileSystemAccessRule($sid,$rights,'None','None','Allow'); $acl.SetAccessRule($rule); Set-Acl -LiteralPath $target -AclObject $acl;" ^ + "}"`; + +test("native Windows: production owned claim/check, ACL depth and collision preservation", { skip: nativeUnavailable }, async () => { + const f = nativeFixture(); + try { + const env = nativeEnv(f.root); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], env), 0); + verifyWindowsStorage(env.GENTLE_BOOTSTRAP_TOOLS, env); + writeFileSync(join(env.GENTLE_BOOTSTRAP_TOOLS, "unrelated"), "preserve"); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], env), 1); + assert.equal(readFileSync(join(env.GENTLE_BOOTSTRAP_TOOLS, "unrelated"), "utf8"), "preserve"); + for (const rights of [4, 2, 16, 256, 64, 65536, 262144, 524288]) { + const ancestor = join(env.GENTLE_BOOTSTRAP_TOOLS, `ancestor-${rights}`); + const parent = join(ancestor, "parent"); const target = join(parent, "target"); + mkdirSync(target, { recursive: true }); + assertNative(await nativeCmd(f.root, [fixtureAclSetup], { ...env, GENTLE_FIXTURE_ACL_TARGET: ancestor, GENTLE_FIXTURE_RIGHTS: String(rights) }), 0); + if (rights === 4) verifyWindowsStorage(target, env); + else assert.throws(() => verifyWindowsStorage(target, env)); + // The same CreateDirectories permission on the target/parent is unsafe. + if (rights === 4) { + const ownedTarget = join(env.GENTLE_BOOTSTRAP_TOOLS, "unsafe-target"); mkdirSync(ownedTarget); + assertNative(await nativeCmd(f.root, [fixtureAclSetup], { ...env, GENTLE_FIXTURE_ACL_TARGET: ownedTarget, GENTLE_FIXTURE_RIGHTS: "4" }), 0); + assert.throws(() => verifyWindowsStorage(ownedTarget, env)); + assertNative(await nativeCmd(f.root, [fixtureAclSetup], { ...env, GENTLE_FIXTURE_ACL_TARGET: parent, GENTLE_FIXTURE_RIGHTS: "4" }), 0); + assert.throws(() => verifyWindowsStorage(target, env)); + const claimEnv = { ...env, LOCALAPPDATA: parent, GENTLE_BOOTSTRAP_TOOLS: join(parent, "new-claim") }; + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], claimEnv), 1); + assert.equal(existsSync(claimEnv.GENTLE_BOOTSTRAP_TOOLS), false); + } + } + } finally { f.cleanup(); } +}); + +test("native Windows: production checks reject an owned fixture junction without touching its target", { skip: nativeUnavailable }, async (t) => { + const f = nativeFixture(); + try { + const env = nativeEnv(f.root); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], env), 0); + const target = join(env.GENTLE_BOOTSTRAP_TOOLS, "junction-target"); mkdirSync(target); + writeFileSync(join(target, "unrelated"), "preserve"); + const junction = join(env.GENTLE_BOOTSTRAP_TOOLS, "junction"); + try { symlinkSync(target, junction, "junction"); } + catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (["EPERM", "EACCES", "ENOTSUP"].includes(code ?? "")) { t.skip(`owned junction creation capability unavailable: ${code}`); return; } + throw error; + } + assert.throws(() => verifyWindowsStorage(junction, env)); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], { ...env, LOCALAPPDATA: junction, GENTLE_BOOTSTRAP_TOOLS: join(junction, "new-claim") }), 1); + assert.equal(readFileSync(join(target, "unrelated"), "utf8"), "preserve"); + assert.equal(existsSync(join(target, "new-claim")), false); + } finally { f.cleanup(); } +}); + +interface ZipFixtureEntry { + name: string; + bytes: Buffer; + attributes?: number; +} +function crc32(bytes: Buffer) { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); + } + return (crc ^ 0xffffffff) >>> 0; +} +function localZip(entries: ZipFixtureEntry[]) { + const locals: Buffer[] = []; + const central: Buffer[] = []; + let offset = 0; + for (const entry of entries) { + const name = Buffer.from(entry.name); + const checksum = crc32(entry.bytes); + // Stored UTF-8 ZIP member; no third-party ZIP tool or executable fixture. + const local = Buffer.alloc(30); + local.writeUInt32LE(0x04034b50); + local.writeUInt16LE(20, 4); + local.writeUInt16LE(0x800, 6); + local.writeUInt32LE(checksum, 14); + local.writeUInt32LE(entry.bytes.length, 18); + local.writeUInt32LE(entry.bytes.length, 22); + local.writeUInt16LE(name.length, 26); + const header = Buffer.alloc(46); + header.writeUInt32LE(0x02014b50); + header.writeUInt16LE(0x314, 4); // Unix creator: high attribute bits carry type. + header.writeUInt16LE(20, 6); + header.writeUInt16LE(0x800, 8); + header.writeUInt32LE(checksum, 16); + header.writeUInt32LE(entry.bytes.length, 20); + header.writeUInt32LE(entry.bytes.length, 24); + header.writeUInt16LE(name.length, 28); + header.writeUInt32LE((entry.attributes ?? 0x81a40020) >>> 0, 38); + header.writeUInt32LE(offset, 42); + locals.push(local, name, entry.bytes); + central.push(header, name); + offset += local.length + name.length + entry.bytes.length; + } + const directory = Buffer.concat(central); + const end = Buffer.alloc(22); + end.writeUInt32LE(0x06054b50); + end.writeUInt16LE(entries.length, 8); + end.writeUInt16LE(entries.length, 10); + end.writeUInt32LE(directory.length, 12); + end.writeUInt32LE(offset, 16); + return Buffer.concat([...locals, directory, end]); +} + +test("native Windows: actual PowerShell ZIP namespace validation precedes no-clobber publication", { skip: nativeUnavailable }, async () => { + const f = nativeFixture(); + try { + const stem = "node-v24.21.0-win-x64"; + const nativeNode = readFileSync(process.execPath); // approved available Node, never run from the ZIP + const invalid: ZipFixtureEntry[][] = [ + [{ name: `${stem}/../escape`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/NODE.EXE`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/link`, bytes: Buffer.from("target"), attributes: 0xa1ff0000 }], + [{ name: `${stem}/reparse`, bytes: Buffer.alloc(0), attributes: 0x81a40400 }], + [{ name: `${stem}/a:ads`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/CON.txt`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/parent`, bytes: Buffer.alloc(0) }, { name: `${stem}/parent/child`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/a.`, bytes: Buffer.alloc(0) }], + [{ name: `${stem}/a `, bytes: Buffer.alloc(0) }], + ]; + for (let index = 0; index <= invalid.length; index++) { + const tools = join(f.root, `zip-tools-${index}`); const env = nativeEnv(f.root, tools); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], env), 0); + writeFileSync(join(tools, ".node-stem"), stem); + writeFileSync(join(tools, "unrelated"), "preserve"); + const nodeBytes = index === 0 ? nativeNode : Buffer.from("invalid ZIP case; never extracted or executed"); + writeFileSync(join(tools, "node.zip"), localZip([{ name: `${stem}/node.exe`, bytes: nodeBytes }, ...(index ? invalid[index - 1] : [])])); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.zip)], env), index ? 1 : 0); + if (index) { + assert.equal(existsSync(join(tools, "node")), false); + assert.equal(existsSync(join(tools, ".node-target")), false); + } else assert.deepEqual(readFileSync(join(tools, "node/node.exe")), nativeNode); + assert.equal(readFileSync(join(tools, "unrelated"), "utf8"), "preserve"); + } + // A real valid archive cannot replace an already claimed publication directory. + const tools = join(f.root, "zip-collision"); const env = nativeEnv(f.root, tools); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.claim)], env), 0); + writeFileSync(join(tools, ".node-stem"), stem); + writeFileSync(join(tools, "node.zip"), localZip([{ name: `${stem}/node.exe`, bytes: nativeNode }])); + mkdirSync(join(tools, "node")); writeFileSync(join(tools, "node/unrelated"), "preserve"); + assertNative(await nativeCmd(f.root, [cmdStage(stageMarkers.zip)], env), 1); + assert.equal(readFileSync(join(tools, "node/unrelated"), "utf8"), "preserve"); + assert.equal(existsSync(join(f.root, "escape")), false); + } finally { f.cleanup(); } +}); + +const probeModes = ["valid", "nonzero", "stdout-limit", "stderr-limit", "quiet-hang", "stderr-hang", "valid-then-hang", "both-pipes-hang"] as const; +for (const mode of probeModes) { + test(`native Windows: pre-Node production process primitive ${mode}`, { skip: nativeUnavailable }, async () => { + const f = nativeFixture(); + try { + const env = nativeEnv(f.root); mkdirSync(env.GENTLE_BOOTSTRAP_TOOLS); + const pidFile = join(f.root, "probe.pid"); const probe = join(f.root, "probe.mjs"); + writeFileSync(join(f.root, "package.json"), JSON.stringify({ engines: { node: ">=24.3.0" } })); + writeFileSync(probe, `import { writeFileSync } from 'node:fs'; +writeFileSync(process.env.GENTLE_FIXTURE_PID, String(process.pid)); +const mode = process.env.GENTLE_FIXTURE_MODE; +if (mode === 'stdout-limit') process.stdout.write('x'.repeat(2*1024*1024)); +else if (mode === 'stderr-limit') process.stderr.write('x'.repeat(2*1024*1024)); +else { + if (['valid','nonzero','valid-then-hang','both-pipes-hang'].includes(mode)) console.log('v24.21.0'); + if (['valid','stderr-hang','both-pipes-hang'].includes(mode)) console.error('fixture diagnostic'); +} +if (mode === 'nonzero') process.exitCode = 7; +if (mode.endsWith('hang')) { process.on('SIGTERM', () => {}); setInterval(() => {}, 1000); } +`); + const result = await nativeCmd(f.root, [processPrimitive()], { ...env, GENTLE_FIXTURE_NODE: process.execPath, GENTLE_FIXTURE_PROBE: probe, GENTLE_FIXTURE_PID: pidFile, GENTLE_FIXTURE_MODE: mode }); + assertNative(result, mode === "valid" ? 0 : 1); + if (mode !== "valid") assert.match(result.stderr, /Node version, execution, deadline, output bound or policy check failed/); + assert.equal(existsSync(pidFile), true, "the approved fixture Node must actually start"); + const pid = Number(readFileSync(pidFile, "utf8")); assert.ok(Number.isSafeInteger(pid) && pid > 0); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }, "owned direct fixture Node must be reaped"); + } finally { f.cleanup(); } + }); +} + +test("native Windows: complete local sentinel composes production entry and late blocks without network", { skip: nativeUnavailable }, async () => { + const f = nativeFixture(); + try { + assert.equal(compatibleEngine(windowsNodeFloor, process.versions.node), true, "fixture runner needs the supported Node floor"); + const scripts = join(f.root, "scripts"); const bin = join(f.root, "bin"); const commands = join(f.root, "commands"); + const nodeDir = join(f.root, "fixture-node"); const fixtureNode = join(nodeDir, "node.exe"); + mkdirSync(scripts); mkdirSync(bin); mkdirSync(commands); mkdirSync(nodeDir); + // Clone ONLY the approved available Node's unchanged bytes, not any adjacent + // operator npm/Corepack/pnpm shim. No new executable is built or acquired. + copyFileSync(process.execPath, fixtureNode); + for (const name of ["installer-downloads.mjs", "installer-windows.mjs", "installer-windows-artifacts.json"]) copyFileSync(new URL(`../scripts/${name}`, import.meta.url), join(scripts, name)); + // Fail-only transport guard in this trusted local JS fixture copy. It never + // supplies bytes/digests, relaxes integrity or creates a production switch. + const helper = join(scripts, "installer-downloads.mjs"); + writeFileSync(helper, "globalThis.fetch = async () => { throw new Error('Fixture forbids network'); };\n" + readFileSync(helper, "utf8")); + writeFileSync(join(f.root, "package.json"), JSON.stringify({ engines: { node: ">=24.3.0" }, packageManager: "pnpm@11.1.1" })); + const packageDir = join(commands, "node_modules/pnpm"); mkdirSync(join(packageDir, "bin"), { recursive: true }); + writeFileSync(join(packageDir, "package.json"), JSON.stringify(pinnedPackage)); + writeFileSync(join(packageDir, "bin/pnpm.mjs"), "console.log(process.argv[2] === '--version' ? '11.1.1' : '--global');\n"); + writeFileSync(join(commands, "pnpm.cmd"), wrapper("node_modules\\pnpm\\bin\\pnpm.mjs")); + writeFileSync(join(bin, "gentle-shell-install.mjs"), "import { writeFileSync } from 'node:fs'; writeFileSync(process.env.GENTLE_FIXTURE_REPORT, JSON.stringify({ node: process.env.GENTLE_INSTALL_PNPM_NODE, entry: process.env.GENTLE_INSTALL_PNPM_ENTRY, path: process.env.PATH })); console.log('wizard-sentinel');\n"); + const env = { ...nativeEnv(f.root), PATH: [dirname(fixtureNode), commands, join(process.env.SystemRoot!, "System32")].join(";"), PATHEXT: ".EXE;.CMD", GENTLE_FIXTURE_REPORT: join(f.root, "handoff.json") }; + const stages = [stageMarkers.bundle, stageMarkers.claim, stageMarkers.resolve, stageMarkers.probe, stageMarkers.launch, stageMarkers.cleanup].map(cmdStage); + const result = await nativeCmd(f.root, stages, env, 45000); + assertNative(result, 0); assert.match(result.stdout, /wizard-sentinel/); + const report = JSON.parse(readFileSync(env.GENTLE_FIXTURE_REPORT, "utf8")) as Record; + assert.equal(report.node.toLowerCase(), fixtureNode.toLowerCase()); + assert.deepEqual(readFileSync(fixtureNode), readFileSync(process.execPath)); + assert.equal(report.entry, join(packageDir, "bin/pnpm.mjs")); + assert.equal(existsSync(env.GENTLE_BOOTSTRAP_TOOLS), false, "exact production cleanup block removes only its claimed fixture tools"); + assert.equal(existsSync(join(commands, "pnpm.cmd")), true); + } finally { f.cleanup(); } +}); From 82ce77788ab16cb0bc37e7559ff223d76b5c87c2 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 16:01:41 +0200 Subject: [PATCH 05/24] docs(odd): record Windows bootstrap verification and review --- odd/tasks/browser-install-wizard.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index d270f5e59..ad25063f9 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -23,8 +23,8 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. - Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. -- Running committed authored lines: 1,479 (T1 417 + T2 1,041 + closure ledger 21). -- Last reviewed source boundary: `03e770f75d5ce8a02f16d125b590e41c743c8101`; its exact staged tree matched the approved frozen candidate. +- Running committed authored lines through T3 source: 2,816 (T1 417 + T2 1,041 + T2 closure ledger 21 + T3 1,337); this T3 tracking closure is a separate passive ledger commit. +- Last reviewed source boundary: `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`; committed tree exactly matched approved frozen T3 tree `4e227bc619d1c8825b78d65613f9adf9768f2312`. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. - Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer. @@ -35,7 +35,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Tasks - [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. -- [ ] T3 — Windows clean-machine bootstrap foundation. Status: correction source recovered after worker timeout into the authorized isolated worktree. Depth-sensitive ACL logic and real native fixtures are present. Fresh writer and independent verification each passed 98 tests with 13 genuinely unavailable native Windows cases; no failures. The real pinned pnpm archive passed integrity, format and metadata checks. Native review and source commit remain pending; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. +- [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. - [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. @@ -93,9 +93,14 @@ Fresh independent verification (`muqyo9ni-g-ocro`) passed 111 total/98 passed/13 - Nonblocking advisories: `R2-engine-contract` (`scripts/bootstrap.sh:24-25`), `R3-001` (`scripts/installer-downloads.mjs:122-134`), `R3-002` (`tests/installer-posix-bootstrap.test.ts:23-24`). These do not open correction or invalidate approval; separate later work only, no automatic acceptance/fix. - Limitations: Linux fake-acquisition fixtures only; native macOS, clean-machine/network acquisition, full suites/build/package checks, forked-descendant cancellation remain unproved. Direct non-forking probe guarantee only. Missing T5 wizard entry intentionally prevents real bootstrap acquisition. -### T3 (source verification complete; native review pending) +### T3 closure evidence - Final writer self-check: `muqz9ufr-h-dlq0`, 111 total/98 passed/13 native skips, zero failures, 26.140s; shell syntax and whitespace passed, all seven hashes stable. - Independent verifier: `muqyo9ni-g-ocro`, same counts, 26.014s; no confirmed severe candidate-caused finding. Thirteen native Windows callbacks are implemented but unexecuted on Linux. - Actual pinned pnpm archive: `muqziidu-i-sv6f`, one HTTPS GET, 4,223,291 bytes; exact SHA512 matched literal and repository pin before `readWindowsPnpmArchive` accepted 448 entries. Metadata `pnpm`/`11.1.1`/`>=22.13`/`bin/pnpm.mjs` matched. No extraction or execution; owned temporary directory cleaned; source/index unchanged. - Post-correction RED history remains unknown after worker timeout; functional GREEN is observed, not reconstructed TDD evidence. -- Native Windows execution, whole-entry certification, clean-machine installation and remaining feature checks are still pending. No source commit or native lineage yet. +- Native Windows execution, whole-entry certification, clean-machine installation and remaining feature checks are still pending; they are explicit T7 acceptance gates, not a claim of Windows support. +- Parent spot check: 111 total/98 passed/13 skipped, zero failures, 26.104s; shell syntax/whitespace passed before staging/freezing. +- Native review `review-2aa665d2b4350300`: controller-selected medium consolidated `review-reliability`, approved on last admitted event; exact acknowledgement returned authority `burned` for target `sha256:e6a787a8f21dd68e9b071041a5289bf035491e2d5f73691509fe4c55ac3cdd07`, consumed revision `sha256:5d27b806de346232f89f5864168dfe15903ddab9d7d7b7727ec044ae4f5fa0bb`. Native ASSESS reported candidate consumed, derived closed, reviewDue false. No further independent verification was required by that plan; the prior independent proof remains recorded. +- Source commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`: staged and committed trees matched frozen `4e227bc619d1c8825b78d65613f9adf9768f2312`; seven files, 1,337 authored diff lines. Native medium classification does not replace installer high-risk functional checks, which passed as above. +- Native advisory `R3-archive-evidence`, `docs/install-wizard.md:307-310`, informational/non-blocking; receipt stands and no correction/re-review is offered. Separately follow up the stale artifact-evidence wording; recorded actual upstream artifact proof does not establish CLI or native Windows readiness. +- Next: T4 read-only contract mapping before bounded runner implementation. No push, PR or host provisioning authorized. From 0d9dac2cc90db922283f40cffa03958ba827fd42 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 19:33:00 +0200 Subject: [PATCH 06/24] feat(installer): add standard installation runner --- docs/install-wizard.md | 165 ++++++++++- odd/tasks/browser-install-wizard.md | 127 +++++++- scripts/installer-preflight.mjs | 34 ++- scripts/installer-runner.mjs | 312 ++++++++++++++++++++ tests/installer-preflight.test.ts | 17 +- tests/installer-runner.test.ts | 440 ++++++++++++++++++++++++++++ 6 files changed, 1074 insertions(+), 21 deletions(-) create mode 100644 scripts/installer-runner.mjs create mode 100644 tests/installer-runner.test.ts diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 1c0a058a0..8eccb77e7 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1,20 +1,22 @@ # Installation wizard groundwork -The preflight planner, POSIX bootstrap and Windows prerequisite foundation are -implemented. Windows native validation remains unavailable locally. +The preflight planner, POSIX bootstrap, Windows prerequisite foundation and the +standard installation runner module are implemented. Windows native validation +remains unavailable locally. **There is no working browser wizard or complete installation path yet.** The bootstrap stops explicitly when the future wizard entry is absent. For ordinary installation and terminal use, follow the [README](../README.md). ## What is available -`scripts/installer-preflight.mjs` exports three small integration surfaces: +`scripts/installer-preflight.mjs` exports four small integration surfaces: | API | Contract | | --- | --- | | `requirements` | Repository-derived Node/Pi minima, pnpm acquisition pin, package version, native installer pin and Windows Go minimum. | | `collectInventory({ platform, arch, probes })` | Calls injected named read-only probes serially. Missing/failed probes become unknown; error text is not retained. | | `planPreflight(inventory)` | Pure classification and ordered action intents; no commands, downloads, installation or setup execution. | +| `pnpmGlobalBin({ platform, env })` | Pure pnpm 11 global-bin resolution: `{ pnpmHome, path, onPath }` or `null` when unknowable. | Current requirements come from `package.json` and [`gentle-ai-installer.mjs`](../scripts/gentle-ai-installer.mjs): Node ≥22.19.0, @@ -57,7 +59,14 @@ purity of caller-provided functions; adapter implementations require review. - Global-bin observations use `{ available: true, path, writable: true, onPath }`. A known missing PATH entry yields an explicit setup intent. Unknown or non-writable directories block; no directory is created or permission probed - by writing. No platform-specific bin path is invented. + by writing. pnpm 11 places global executables in **`$PNPM_HOME/bin`**, not + `$PNPM_HOME`, and `pnpm root -g`/`bin -g` fail when that directory is not on + PATH. Probes must report `pnpmGlobalBin().path`, whose `onPath` compares PATH + entries with that bin directory. PNPM_HOME is the user's existing absolute + value; otherwise pnpm's documented default (`$XDG_DATA_HOME/pnpm` or + `~/.local/share/pnpm` on Linux, `~/Library/pnpm` on macOS, + `%LOCALAPPDATA%\pnpm` on Windows). A relative PNPM_HOME or missing home is + unknown, not guessed. - `setup` is a boolean evidence of normal Shell setup readiness. Unknown setup on an existing stack blocks; a missing Shell/native binary needs normal setup. @@ -93,18 +102,150 @@ companion installer belongs in this plan. `npmCommand` supports pnpm, but independent upstream `npm exec` control remains unverified. **This preflight does not establish an npm-free installation chain.** -Standard installation driver, local server, consent UI, distribution packaging -and native acceptance evidence remain future work in the +Local server, consent UI, distribution packaging and native acceptance evidence +remain future work in the [feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected tests do not prove clean-machine installation on Windows, macOS or Linux. Focused verification: ```sh -node --experimental-strip-types --test tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts +node --experimental-strip-types --test tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts sh -n scripts/bootstrap.sh ``` +## Standard installation runner + +`scripts/installer-runner.mjs` exports `runStandardInstall({ plan, consent }, +adapters)`, the fixed installation step the future wizard host (T5) calls after +bootstrap and a fresh preflight. It is a pure module: every process, filesystem, +environment, integrity and log effect goes through adapters supplied by trusted +local code, never through the browser. + +### Request contract + +The request is exactly `{ plan, consent }`. `plan` must be an unmodified +`planPreflight` result: every action must equal a descriptor preflight can emit +(same id, kind, target and repository-derived version, no extra keys). Extra +request keys, commands, URLs, roots, environment or altered versions are +rejected as `invalid-request` before any process runs. `consent` must be the +boolean `true`; anything else returns `consent-required` with no commands. The +consent covers the whole displayed plan, including the PATH profile change +below, so the wizard must show that change before asking. + +### Adapters + +| Adapter | Contract | +| --- | --- | +| `platform` | `linux`, `darwin` or `win32`. | +| `nodePath` | Absolute Node executable of the trusted host, used for npm proof and `gentle-shell setup`. | +| `env` | The user's environment. Never mutated; child environments are copies. | +| `home` | Optional; defaults to `HOME` (`USERPROFILE` on Windows). | +| `run(command, argv, { env, deadlineMs })` | Argv arrays with `shell:false` semantics and a hard deadline; returns `{ code, signal, timedOut, stdout }` with bounded output. | +| `fs` | Read-only `isFile`, `realpath`, `readText`. The runner has no delete or write operation. | +| `verifyGentleAi({ packageRoot, platform, env, home })` | Returns `{ ok: true }` only for package-native integrity. `packageNativeGentleAi` is the default implementation over `runtime/gentle-ai-binary.mjs`. | +| `log({ step, status, reason? })` | Receives step identifiers and statuses only, never raw command output or error text. | + +### Fixed sequence + +No-process gates, all returning `blocked`: + +1. Valid request and explicit consent. +2. Preflight blockers are absent. Only the clean-stack plan is supported: it + must contain `install-pi`, `install-shell`, `setup-shell` and + `verify-readiness`, plus optional `setup-global-bin`. Prerequisite acquisition + intents (Node, pnpm, Go), `provision-native`, partial existing stacks and + fully reused stacks are `unsupported-plan`. +3. On Windows, Go must be `reusable` in the plan because gentle-pi's postinstall + may build Gentle AI from source; T4 never acquires Go (`go-required`). +4. `pnpmGlobalBin` resolves PNPM_HOME (`pnpm-home-unknown` otherwise) and + `nodePath` is absolute. +5. pnpm comes from the bootstrap handoff `GENTLE_INSTALL_PNPM_NODE` + + `GENTLE_INSTALL_PNPM_ENTRY` (both absolute), or on POSIX from a `pnpm` + executable on PATH. Windows requires the handoff because a `.cmd` shim cannot + run with `shell:false` (`pnpm-unavailable`). + +Every child process receives the user's environment plus `PNPM_HOME` and +`$PNPM_HOME/bin` first on PATH. + +Pre-install checks, returning `blocked` on a false result or adapter error: + +1. `check-npm`: Gentle AI's normal Pi install always runs an Engram init step + that can use `npm exec` and stops on failure. The runner resolves `npm` the + way Go's `exec.LookPath` (used by Gentle AI) does: absolute child PATH + directories in order and, on Windows, every child PATHEXT extension in + PATHEXT order (case-insensitive keys; default `.COM;.EXE;.BAT;.CMD`). That + first candidate must be the npm package's own `bin/npm-cli.js` (symlink + target on POSIX; on Windows the candidate must be `npm.cmd`, with + `node_modules/npm` beside it), its `package.json` must name `npm` with a + stable version, and `node npm-cli.js --version` must print that version. On + Windows an `npm.com`, `npm.exe` or `npm.bat` that resolves first, in the + same or an earlier directory, blocks as `npm-shadowed`; any other mismatch + blocks as `npm-unavailable`. No npm wrapper is created. +2. `check-global-bin`: `pnpm bin -g` must succeed and equal `$PNPM_HOME/bin` + (`global-bin-mismatch`; case-insensitive on Windows). +3. `check-existing-stack`: `pnpm list -g --depth 0 --json` runs before any + mutation. If `@earendil-works/pi-coding-agent` or `gentle-pi` appears in the + dependencies, devDependencies or optionalDependencies of any listed project, + the runner blocks as `existing-stack` and never overwrites it, whatever the + caller's plan says. A failed, timed-out or unparseable listing, or an + unexpected JSON shape, blocks as `global-list-unavailable`. + +Mutating and verification steps, returning `failed` with `failedStep` and the +`completed` step list: + +1. `install-global`: exactly one + `pnpm add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi`. + Pi is gentle-pi's optional peer, so both resolve in one command. pnpm 11 + silently skips global postinstall scripts by default; the package-scoped + approval runs only gentle-pi's postinstall, which provisions Gentle AI. No + blanket build approval is ever passed. +2. `verify-global-list`: `pnpm list -g --depth 0 --json` must report exactly the + two pinned versions, and gentle-pi's absolute `path` must resolve inside + PNPM_HOME. +3. `verify-shell-bin`: `$PNPM_HOME/bin/gentle-shell` (`gentle-shell.cmd` on + Windows) exists. +4. `verify-gentle-ai`: package-native integrity of the installed package. A + declared development override (`GENTLE_PI_GENTLE_AI_DEV_BINARY` or its + registration file) is never package-native proof and fails. +5. `shell-setup`: the installed public CLI, `node /bin/gentle-shell.mjs + setup`, with the child environment. Nonzero, signal or deadline fails. The + runner neither duplicates setup logic nor writes the automatic provisioning + marker. +6. `persist-path`, only when `$PNPM_HOME/bin` was not on the user's own PATH: + `pnpm setup`, then the outcome is `terminal-action-required` with + `action: "open-new-terminal"`. A child environment never proves that a fresh + terminal resolves `gentle-shell`. + +`ready` requires every step above to complete and the global bin to already be +on the user's PATH. Skipped, unverified or failed mandatory steps never yield +`ready`, and no existing installation, home or data is deleted on any outcome. +Deadlines are 30 seconds for probes and `pnpm setup` and 20 minutes each for +the install and setup steps. + +### Remaining T7 real-machine checks + +The runner is verified only with deterministic fake adapters. Observed pnpm +11.1.1 facts come from one Linux laboratory run. Before claiming support, T7 +must establish on real Windows, macOS and Linux machines: + +- `pnpm list -g --json` reports a dependency `path` that resolves inside + PNPM_HOME for the installed layout, and prints a parseable JSON array (for + example `[]`) when no global package exists yet; empty output would block + a clean installation as `global-list-unavailable`; +- Windows npm resolution matches Gentle AI's actual lookup, including its + current-directory handling; +- the scoped `--allow-build=gentle-pi` postinstall provisions package-native + Gentle AI, including Windows source builds with the user's Go; +- `gentle-shell setup` completes the Engram init step with genuine npm, and + whether Gentle AI selects `pnpm dlx` or `npm exec`; +- `pnpm setup` makes `gentle-shell` resolvable in a fresh terminal of each + supported shell (the Linux lab showed it does not make `pnpm` itself + resolvable); +- the chosen deadlines suffice on slow networks, and pnpm's registry and + update-notice traffic is acceptable; +- interrupted installs are recoverable by rerunning the wizard without data loss. + ## POSIX bootstrap: bundle-local tooling only Run `sh scripts/bootstrap.sh` from a trusted extracted installation bundle or @@ -278,12 +419,10 @@ integrity into another runtime. Pin changes still require fresh primary evidence Windows and fixed stock PowerShell ACL commands, never POSIX mode/UID evidence. Go preparation is deliberately **not implemented or invoked** in this unit. -T4 must first prove whether its actual standard operation needs a compiler: -package-native Gentle AI manifest/hash reuse can avoid Go, but the current -installer resolves Go before source-bundle reuse. Do not reinstall just to test -reuse, treat an explicit override as native-package evidence, or assume a source -bundle bypasses Go. If required, T4 must use the existing companion installer, -a lazy verified prerequisite with Go ≥1.25.10, and `GOTOOLCHAIN=local`. +The standard installation runner blocks on Windows unless preflight reports a +reusable Go ≥1.25.10, because gentle-pi's postinstall may build Gentle AI from +source; it never acquires Go or treats an explicit override as native-package +evidence. ### Policy, bounds and evidence limits diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index ad25063f9..0a3fe27d3 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -23,7 +23,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. - Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. -- Running committed authored lines through T3 source: 2,816 (T1 417 + T2 1,041 + T2 closure ledger 21 + T3 1,337); this T3 tracking closure is a separate passive ledger commit. +- Running committed authored lines through T3 closure: 2,831 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15). Passive T3 closure commit: `82ce7778`. - Last reviewed source boundary: `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`; committed tree exactly matched approved frozen T3 tree `4e227bc619d1c8825b78d65613f9adf9768f2312`. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. @@ -36,7 +36,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. - [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. -- [ ] T4 — Standard installation driver. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. +- [ ] T4 — Standard installation driver. Status: local read-only mapping completed (`mur18ury-j-eq2v`); bounded public research completed (`mur2mlxy-l-00gv`), dependency closure incomplete; disposable validation `mur3ceex-m-b88x` stopped on harness-assumed archive path before CLI execution; blocked: corrected repeat `mur5mscy-n-hwz5` also stopped on a harness preflight limitation before CLI execution. Experiments stopped; observation-based validation redesign documented below; human selected `authorize_t4_observed_lab_once`, single run `mur6c1an-o-5qc5` finished blocked at the isolation gate (harness PATH error); pnpm itself ran. Human selected `authorize_t4_observed_lab_path_fix_once`; corrected run `mur7rhcu-p-imdr` passed V/G/A/B/C and failed D2; design settled below; writer `mur7zk8j-q-iklz` implemented it test-first (RED observed, 117 pass/13 native skips); independent verification PASS; one scoped correction applied (`mur8epna-s-joyp`); parent spot check passed; native review pending. No runner implementation yet. Prior explorer `mur2j43y-k-8l0s` completed blocked with zero GETs before runner implementation. No runner source writes yet. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. - [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. @@ -64,11 +64,9 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -T1 and T2 bounded source units are closed with evidence below. T3's initial writer and independent verifier passed 94 portable tests with four skips, but verification found three empty always-skipped native placeholders and ancestor-ACL over-rejection. One bounded correction worker (`muqwpdkq-e-gfyf`) timed out after editing. Read-only incident inspection found depth-sensitive masks and substantial real native callbacks; no post-correction test result or RED/GREEN history is available. +T1–T3 bounded source units are closed with the evidence below. T3 source commit is `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`, followed by passive closure ledger `82ce7778`. Writer, independent verifier and parent each observed 98 passing tests and 13 unavailable native Windows cases. The integrity-verified published pnpm archive passed the existing parser and pinned metadata checks. Native review was approved and exactly acknowledged. Post-correction RED history remains unknown; current functional GREEN is observed. Native Windows/macOS and clean-machine acceptance remain T7. -The shared worktree had switched to `fix/bridge-only-agent-wake` with unrelated commit/work. The user authorized isolating the installer, transferring exactly seven installer files and cleaning only their pending originals after copy verification. All seven byte hashes/modes were verified. The main branch/commit and its unrelated pending ledger were preserved; the installer now uses its own worktree on `feat/browser-install-wizard` at `07e4971d`. Product/runtime/Engram roots are unchanged. - -Fresh independent verification (`muqyo9ni-g-ocro`) passed 111 total/98 passed/13 skipped in 26.014s; final read-only writer self-verification (`muqz9ufr-h-dlq0`) matched those counts in 26.140s. Both passed syntax/whitespace and source stability checks. Human-authorized archive proof (`muqziidu-i-sv6f`) made one public GET, verified the pinned SHA512 before the existing parser accepted 448 entries from 4,223,291 bytes; actual package identity, version, raw engine and bin matched. No artifact execution/extraction/installation occurred and owned temporary storage was cleaned. Next: bounded parent spot check and exact native review of the stable T3 source unit. Native Windows remains unavailable. Go preparation and persistent terminal readiness remain T4; the missing T5 wizard entry intentionally prevents real acquisition. No T3 native START/lineage or commit exists. Final normalized work-unit review must target this isolated worktree, never the accumulated feature branch. +The user-authorized isolated development worktree preserves unrelated original worktree changes. Product/runtime/Engram roots are unchanged. T4 local mapping is complete; public acquisition/lifecycle contracts are absent locally. Human authorized bounded read-only official-source research, now routed to `mur2mlxy-l-00gv` after the original research explorer completed with no network tools, before selecting complete dependency and runner behavior. No runner writer, host installation, publishing or extra source correction has been launched. ## Work-unit evidence ### T1 (closed) @@ -104,3 +102,120 @@ Fresh independent verification (`muqyo9ni-g-ocro`) passed 111 total/98 passed/13 - Source commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`: staged and committed trees matched frozen `4e227bc619d1c8825b78d65613f9adf9768f2312`; seven files, 1,337 authored diff lines. Native medium classification does not replace installer high-risk functional checks, which passed as above. - Native advisory `R3-archive-evidence`, `docs/install-wizard.md:307-310`, informational/non-blocking; receipt stands and no correction/re-review is offered. Separately follow up the stale artifact-evidence wording; recorded actual upstream artifact proof does not establish CLI or native Windows readiness. - Next: T4 read-only contract mapping before bounded runner implementation. No push, PR or host provisioning authorized. + +### T4 local mapping and decision gate +- Explorer `mur18ury-j-eq2v` completed read-only mapping in the isolated root; CodeGraph initialized there, bounded fallback after unsuccessful symbols. No source/task writes, tests, installation or network. Injected tracked project skills loaded; ignored local registry absent, not recreated. +- Existing setup owns companion installation, state restoration and cleanup. Use its public CLI, not internal functions or a duplicate component installer. +- Native-path existence is not provenance proof (`bin/gentle-shell.mjs:520–548`); package-local integrity resolver accepts a development override before native verification (`runtime/gentle-ai-binary.mjs:272–306`). An override cannot establish package-native readiness. +- Manual setup does not record automatic provisioning marker (`bin/gentle-shell.mjs:944–956` vs `:1160–1175`); ordinary auto-setup warns and launches on failure, so launcher exit alone does not prove installation readiness. +- Windows installer resolves Go before source-bundle reuse (`scripts/gentle-ai-installer.mjs:630–636`). Package-native reuse can avoid that operation, but temporary agent-home package lifecycle requirements remain unknown. +- Child-only PATH is not fresh-terminal readiness. Preserve existing configured homes and pnpm-global ownership; do not assume npm global layout. POSIX and Windows pnpm handoff differ. +- Requested research: official Gentle AI4.0.0 source commit `ff77164d4f56f1665b22fb6fac51c2ccbb769400`, Pi1.0.0 and0.99.1 acquisition contract, pnpm11.1.1 lifecycle/global/bin/PATH behavior. Read-only public sources, no credentials, executable downloads, installation, provisioning or source writes. Human selected `authorize_t4_public_contracts`; research explorer `mur2j43y-k-8l0s` completed tool-blocked (0 GETs/0 bytes); one read-only HTTPS-capable verifier fallback `mur2mlxy-l-00gv` is running. Budget: at most 20 public text GETs/10 MiB total/60 seconds per request; no executable artifacts, installs, credentials or source writes. Completion/evidence pending. +- Original T4 narrow surfaces remain `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Proposed prerequisite/bootstrap/integrity/marker/PATH expansions are not yet approved or implemented. Focused test-first command after safe design: `node --experimental-strip-types --test tests/installer-runner.test.ts`. + +- T4 research progress (not closure evidence): verifier reports 16 authorized GETs/1,453,761 UTF-8 bytes. Tool output truncation created harness-owned `/tmp/pi-bash-b74d2e010234e8a0.log`; no intentional repository/temp writes. Leave spool untouched. Finish with at most four remaining requests, compact <=100-line/8-KiB command output, exact source excerpts and final accounting; unseen truncated passages remain unproved. No scope/budget extension or implementation implied. + +- T4 acquisition budget exhausted: research reports 20 GETs/1,666,417 UTF-8 response-body bytes, including two Pi source refetches. Pi runtime array command/pnpm prefix-install behavior and independent Gentle AI `npm exec gentle-engram@latest` were inspected, exact citations pending final handoff. Latest pnpm setup docs mix v11 and since-v11.18; historical 11.1.1 global/lifecycle closure remains unavailable. Prefer pnpm does not prohibit genuine npm but does prohibit assuming it already exists. No further retrievals, implementation, tests or log operations; final handoff pending. + +### T4 final research disposition +- Final actor `mur2mlxy-l-00gv` completed 20 GETs/1,666,417 UTF-8 response-body bytes; no budget remaining. No implementation/tests/installations/intentional source writes. Harness spool untouched. Full cited evidence preserved in Engram topic `installer/t4-public-contract-findings` (21361). +- Verified Pi1.0.0/0.99.1 runtime array-command and pnpm-prefix argv do not establish product-global installation. Metadata declares Node>=22.19.0; stricter existing bootstrap minimum24.3.0 remains unchanged. Agent-home managed package ownership is preserved. +- Verified Gentle AI4.0.0 adapter builder adds independent `npm exec --yes --package gentle-engram@latest -- pi-engram init`. Actual reachability/policy/executor/error propagation remains unproved; do not label construction as unconditional successful normal-setup execution. +- Historical pnpm11.1.1 global/lifecycle/PATH behavior, trustworthy genuine npm availability/acquisition, native setup lifecycle/compiler needs and clean-machine acceptance remain unresolved. No complete T4 design or ready-looking scaffold accepted. +- Parent state check: local Node v24.18.0, feature branch intact, only task ledger modified. This runtime meets declared Pi/pnpm requirements but is not fresh-machine acquisition proof. +- Recommended single next gate: explicitly authorized disposable pnpm11.1.1 fixture validation (integrity-verified archive; offline synthetic packages; no real home or real product installation), plus narrowly bounded official Node/npm distribution and pinned Gentle AI execution-flow source reads. This would address concrete missing facts, not establish Windows/macOS/T7 acceptance. No such experiment or additional reads have been launched. + +### T4 disposable validation authorization +- Human selected `authorize_t4_disposable_validation`; one <=10-minute laboratory run now delegated to `mur3ceex-m-b88x`. +- One public pnpm11.1.1 archive GET <=32MiB; literal and repository SHA512 must match BEFORE processing/extraction/execution. Verified CLI may run only against trusted synthetic no-dependency fixtures in owned temporary HOME/cache/global/store/config and offline mode. +- Additionally <=6 textual official GETs/2MiB total from nodejs.org, public GitHubnodejs/node and the fixed Gentle AI source commit, to inspect genuine npm distribution and actual init execution/failure policy. No automatic extension, retry loop or fetched research-code execution. +- No real product installation, actual HOME/PATH/profile/config or credential access, security/lifecycle bypass, source changes, native platform claim or publishing. Clean only owned laboratory; existing harness spool untouched. +- Outcomes remain pending. Parent bookkeeping may update only this ledger/mirror; source stability evidence must distinguish those known ledger transitions from executable changes. + +### T4 disposable validation result (blocked) +- Actor `mur3ceex-m-b88x`: one HTTPS GET/4,223,291 bytes; literal and repository SHA512 matched before parser/extraction. Existing bounded parser accepted448entries. No CLI or fetched research text executed; zero of six text requests used. +- The validation harness assumed `package/dist/pnpm.cjs` was present and exited1 at `verified bundle source unavailable` in378ms. This is an incorrect test-harness assumption, not a pnpm compatibility or installer finding. +- Newly owned temporary extraction removed successfully in finally; tracked/nonignored byte aggregate, index and porcelain status identical before/after. Parent ledger was already modified in both snapshots. Existing harness spool unread/untouched. +- All CLI/global/lifecycle/PATH/Pi-prefix tests and exact Node/npm/Gentle AI execution-flow source reads remain unverified. T4 is not done; no source writer/commit/native-review candidate exists. +- The authorized single experiment has ended; its one archive GET is consumed. No automatic retry/extension permitted. Proposed single repeat would derive the declared `bin/pnpm.mjs` entry and imports from verified metadata/inventory, never assume a dist path. Await human repeat-or-pause decision before any new retrieval/execution. + +### T4 corrected repeat authorization +- Human selected `authorize_t4_corrected_lab_once`; continued the prior verifier context as new task `mur5mscy-n-hwz5`. No duplicate live experiment exists. +- One fresh <=10-minute bounded run with one pinned archive GET<=32MiB/SHA512-before-processing-execution, offline private synthetic fixtures, and <=6 official text GETs/2MiB; original safeguards unchanged. No third automatic attempt. +- Derive actual `bin/pnpm.mjs` entry, prefix normalization and imports from verified package metadata/entry inventory, not an assumed dist filename. Independent authorized source reads can still return evidence if the CLI portion is blocked, unless safety requires stopping. +- No real installation/home/PATH/config/credential/source/publishing changes; cleanup own temp only, old harness spool untouched. Outcome pending; previous failure remains a harness failure, not a pnpm defect. + +### T4 corrected repeat result and stop +- `mur5mscy-n-hwz5` finished semantically blocked (shell exit0),1,505ms. Scanner classified every quoted relative path as mandatory; absent `../lib/pnpm` was not shown reachable on normal execution. Harness-preflight limitation, not pnpm incompatibility. No extraction/CLI execution in this repeat; all CLI/global/lifecycle/Pi-prefix/bin/PATH checks skipped. +- Archive integrity and bounded parser passed448entries; parser preserves `package/`. Verified metadata entry `bin/pnpm.mjs` references existing `package/dist/pnpm.mjs`. Do not infer required modules from all string literals. +- Five GETs/4,272,724 response-body bytes (archive4,223,291 + text49,433). Exact Node24.21.0 source includes npm11.19.0/npm+npx mappings and conditional npm packaging; this is source proof, not actual platform bundle proof. Pinned Gentle AI builder/runner facts reconfirmed but mandatory call flow/error policy remains unknown. Exact citations preserved under `installer/t4-corrected-lab-result`. +- Owned temp cleaned; nonignored source bytes, index/status and parent ledger stable; old harness spool untouched. No installation/source/profile/native acceptance claim. +- Both explicitly authorized experiments have ended. No third run, new GET, source writer or automatic correction authorized. T4 is blocked; stop experiments and redesign the validation gate using control-flow-aware or direct bounded execution semantics rather than another string-scanner retry. No fresh execution or budget extension has been requested. + +### T4 redesigned validation gate (proposed, not authorized or executed) +Root cause of both failed labs: the harness tried to predict required files by static inspection instead of observing execution. The redesign removes all static dependency prediction. + +Preconditions (only these): +1. One pinned archive GET; literal and repository SHA512 match before parsing. +2. Existing bounded parser accepts the archive; extract every accepted entry under an owned private lab directory (`package/` prefix mapped to `/pnpm/`). +3. `package.json` declares name `pnpm`, version `11.1.1` and `bin.pnpm = bin/pnpm.mjs`, and that file exists. Nothing else is predicted. + +Isolation, asserted by observation before any mutation: +- `env -i` with lab-owned HOME, XDG config/cache/data/state, PNPM_HOME, `SHELL=/bin/bash`, minimal PATH; registry configured to an unreachable loopback address as defense in depth; lab cwd with no ancestor `package.json` or `packageManager` field. +- Run `node /pnpm/bin/pnpm.mjs --version` directly (`shell:false`, 60-second SIGKILL deadline, bounded output). Expect `11.1.1`; any runtime import failure is an observed pnpm result, not a harness guess. +- Observe `root -g` and `bin -g`; stop before mutation unless both resolve inside the lab. + +Observed checks, with an explicit dependency graph (a failed step skips only its dependents): +- A: `pnpm pack` of a trusted local fixture (own bin plus postinstall that writes a sentinel inside the lab only). +- B (needs A): `add -g --offline`; then `list -g --depth 0 --json`, run the installed bin from `bin -g` directly, and record whether the postinstall ran, was skipped or warned under default policy. Skipped never counts as ready. +- C (needs A): Pi's exact verified argv `install --prefix /pi --config.auto-install-peers=false --config.strict-peer-dependencies=false --config.strict-dep-builds=false`, then `uninstall --prefix /pi`. +- D (independent): `pnpm setup` with lab HOME/SHELL; diff only lab profile files, then `env -i HOME= bash -ic 'command -v pnpm'` as Linux-bash-only fresh-shell evidence. +- E (independent, text only): up to 5 public GETs (<=3 MiB) at Gentle AI `ff77164d4f56f1665b22fb6fac51c2ccbb769400`: one tree listing, then only files that consume `InstallCommand`, to establish reachability and StopOnError policy for the Engram `npm exec` step. + +Hard limits: <=10 minutes total, one archive GET <=32 MiB, no retries, no mid-run procedure changes, owned cleanup in finally, repository/index/status stability asserted, existing harness spool untouched. Evidence is Linux-only; no Windows/macOS or real-installation claim. + +### T4 observed lab result (blocked at isolation gate) +- `mur6c1an-o-5qc5`: archive GET, pin, SHA512-before-processing, parser (448 entries), metadata and ancestor checks passed. `node /pnpm/bin/pnpm.mjs --version` printed `11.1.1` under Node v24.18.0 with no import errors: the static-prediction failures are resolved. +- `root -g` and `bin -g` exited 1: pnpm 11.1.1 requires the global bin directory `$PNPM_HOME/bin` on PATH; the harness added `$PNPM_HOME`. Harness error, not a pnpm defect. A–D skipped before any mutation. +- Product implication for T4: the runner and terminal handoff must put `$PNPM_HOME/bin` (not `$PNPM_HOME`) on PATH, and preflight `globalBin.onPath` must check that directory. Current installer scripts do not reference `PNPM_HOME`. +- Step E: `internal/agents/pi/adapter.go:285` defines `InstallCommand`; `:297` returns the independent `npm exec` Engram step; `internal/pipeline/orchestrator.go:9-11` `WithFailurePolicy` sets the apply policy, whose default lives in unfetched `runner.go`. The `InstallCommand` consumer remains unknown. +- 6 requests/4,796,977 bytes; lab cleaned; index/status/HEAD stable; spool untouched. The verifier correctly declined a stop-hook review START on the parent's passive ledger. +- Remaining blocker: unobserved A–D and the Engram step failure policy. Proposed single corrected run: identical procedure with PATH `/bin:/usr/bin:/bin`, plus up to 3 text GETs (runner.go and InstallCommand consumers). Authorized once; completed as `mur7rhcu-p-imdr`. + +### T4 PATH-corrected lab result +- `mur7rhcu-p-imdr` (Linux only): `--version` 11.1.1; `root -g` = `$PNPM_HOME/global/v11`, `bin -g` = `$PNPM_HOME/bin`; offline pack, global add, list and direct bin execution passed; Pi's exact prefix argv accepted and uninstall worked. +- Default policy silently skips postinstall on `add -g` (no warning). Documented `--allow-build=` runs it only for that package. +- `pnpm setup` from the extracted entry wrote only the lab `.bashrc`; a fresh interactive bash still could not resolve `pnpm` (D2 FAIL). +- Gentle AI `installcmd/resolver.go:106-108`: Pi install ALWAYS runs `engramInitCommand()` (`pnpm dlx` or `npm exec`), executed by `agentInstallStep` (:58); `pipeline/stages.go:25-26` StopOnError is the default. Selection between `pnpm dlx` and `npm exec` remains unknown. +- Disclosed boundary issue: C1 printed a pnpm update notice despite a loopback registry, so pnpm likely contacted a public registry outside the counted budget (destination/bytes unverified). Counted: 4 requests/4,243,367 bytes. Lab cleaned; repository stable. + +### T4 settled runner design +Rationale: tests use fake process/filesystem adapters, so lab network isolation is not a blocker for implementation; real-machine behavior remains a T7 gate. +- Pure module `scripts/installer-runner.mjs` with injected adapters (process runner with argv arrays, shell:false, deadlines; filesystem; clock/log). It accepts only a fixed plan derived from preflight plus explicit consent; never browser-supplied commands, URLs, paths or environment. +- pnpm invocation normalized to an argv prefix `[nodePath, pnpmEntry]` (from the bootstrap handoff) or a resolved `pnpm` executable. +- PNPM_HOME: use the user's existing value; otherwise pnpm's documented platform default. Child env always gets `PNPM_HOME` and PATH with `$PNPM_HOME/bin` first. Verify `bin -g` equals `$PNPM_HOME/bin`; mismatch blocks. +- Genuine npm gate: Gentle AI's Engram step may run `npm exec` and stops the pipeline on failure, so npm must resolve in the child env (bundled with the pinned official Node) or the runner blocks with an explicit reason. No npm wrapper or impersonation. +- Windows Go gate: if gentle-pi's postinstall would need Go (Windows) and preflight reports Go missing or too old, block; T4 does not acquire Go. +- Single fixed global install: `add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi` in ONE command (Pi is an optional peer dependency resolved next to gentle-pi). Never blanket build approval. +- After install: verify via `list -g --depth 0 --json` that both packages are present at the expected versions, the `gentle-shell` bin exists in `$PNPM_HOME/bin`, and package-native Gentle AI provisioning verifies through the existing integrity resolver with development overrides cleared. +- Run installed `gentle-shell setup` (public CLI) with the child env; nonzero, signal or deadline fails. Do not write the automatic provisioning marker or duplicate setup logic. +- PATH persistence: if `$PNPM_HOME/bin` was not already on the user's PATH, run consented `pnpm setup` and report `terminal-action-required` (open a new terminal); never claim fresh-terminal readiness from a child env. +- Outcomes: `blocked`, `failed`, `terminal-action-required`, `ready`. Any skipped, unverified or failed mandatory step can never yield `ready`. Existing installs/homes are never deleted; failures report the completed steps. +- Fix preflight: `globalBin.onPath` must test `$PNPM_HOME/bin` (pnpm 11), not `$PNPM_HOME`. +- Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast 500–750 lines. Risk: high. + +### T4 implementation evidence (uncommitted) +- Writer `mur7zk8j-q-iklz`: `scripts/installer-runner.mjs` (new, 261), `tests/installer-runner.test.ts` (new, 316, 18 tests), `scripts/installer-preflight.mjs` (+33/-1, `pnpmGlobalBin`), `tests/installer-preflight.test.ts` (+16/-1), `docs/install-wizard.md` (+136/-13). About 777 lines; over the advisory forecast because of contract documentation. +- RED: runner `ERR_MODULE_NOT_FOUND`; preflight missing `pnpmGlobalBin` export. GREEN: preflight 22/22, runner 18/18. Mutation check: adding `--dangerously-allow-all-builds` failed the exact add -g test, then reverted. +- Writer verification: combined suites 130 tests, 117 pass, 0 fail, 13 native Windows skips, 26.2s; `git diff --check` clean. +- Decision: only the clean-stack plan is supported; existing or partial Pi/gentle-pi installs return `blocked` (`unsupported-plan`) so the pinned single `add -g` never downgrades or duplicates them. Upgrades stay with `gentle-shell update`. A verify-only path is a possible later extension. +- Writer-noted risks for review/T7: `list -g --json` `path` shape unverified on real machines (missing → failed); setup runs `node /bin/gentle-shell.mjs setup` because `.cmd` shims cannot run with `shell:false`; one consent covers the plan, so T5/T6 must show the `pnpm setup` profile change before consent; chosen deadlines 30s probes/`pnpm setup`, 20 min install/setup; genuine npm proof is structural, not cryptographic. +- Risk tier: high (installer). Next: independent verifier, parent spot check, native review, work-unit commit. + +### T4 independent verification and scoped correction +- Verifier `mur89jrr-r-be4r`: PASS, 130 tests/117 pass/0 fail/13 native skips, diff checks clean, no severe defects. +- Accepted for the single scoped correction: F1 Windows npm resolution must follow PATHEXT order per directory like Go `exec.LookPath` (`installer-runner.mjs:123-127`); A1 the runner itself confirms via `list -g --depth 0 --json` that Pi and gentle-pi are absent before `add -g`, otherwise `blocked`; A6 tighten the Go test to the exact reason and add win32 Path/case and positive `packageNativeGentleAi` tests. +- Recorded as later gates, not corrected now: A2 no post-setup re-probe for `verify-readiness`; A3 `onPath` depends on the caller env and fresh-terminal Node resolution is unverified (T5 must pass the real user env); A4 `projects.length === 1` may fail closed with other global packages (T7); A5 installed package identity, shim target and POSIX exec bit not verified (T7). +- Correction `mur8epna-s-joyp`: F1 Windows npm resolution now follows PATH order then PATHEXT order like Go `exec.LookPath` (`npm-shadowed` blocks); A1 new pre-install `check-existing-stack` runs `list -g --depth 0 --json` and blocks with `existing-stack` or `global-list-unavailable`; A6 exact Go reasons, win32 `Path`/`bin -g` case tests and a positive `packageNativeGentleAi` test. Runner 312 lines, tests 440 lines (22 tests). RED: 12/22 failed for the expected reasons; GREEN 22/22; PATHEXT mutation check failed exactly one test, then reverted. A6 Go and win32 Path tests are characterization tests (no own RED). +- Writer and parent spot check: 134 tests/121 pass/0 fail/13 native skips (26.1s); `git diff --check` clean. The writer's stray `/tmp/runner-tail-3619111.ts` (a copy of the test tail outside allowed surfaces) was inspected and removed by the parent. +- New T7 checks: clean-machine `pnpm list -g --json` output shape (empty output blocks closed); Windows current-directory lookup (`ErrDot`/`NoDefaultCurrentDirectoryInExePath`) not reproduced. A fake `npm.cmd` earlier in PATH reports `npm-unavailable` rather than `npm-shadowed` (still blocks). diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs index deccd1bb0..cd6201da8 100644 --- a/scripts/installer-preflight.mjs +++ b/scripts/installer-preflight.mjs @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { posix, win32 } from "node:path"; import { INSTALLER_VERSION, GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION, @@ -19,13 +20,44 @@ export const requirements = Object.freeze({ go: GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION, }); +/** pnpm 11 global bin directory: `$PNPM_HOME/bin`, not `$PNPM_HOME` itself. + * PNPM_HOME is the user's existing absolute value, otherwise pnpm's documented + * platform default. onPath compares PATH entries with that bin directory, so a + * globalBin probe must report this path. Returns null when it is unknowable. + */ +export function pnpmGlobalBin({ platform, env }) { + const path = platform === "win32" ? win32 : posix; + const absolute = (value) => typeof value === "string" && value.length > 0 && path.isAbsolute(value); + let pnpmHome = env.PNPM_HOME; + if (pnpmHome !== undefined) { + if (!absolute(pnpmHome)) return null; + } else if (platform === "win32") { + if (!absolute(env.LOCALAPPDATA)) return null; + pnpmHome = path.join(env.LOCALAPPDATA, "pnpm"); + } else { + if (!absolute(env.HOME)) return null; + if (platform === "darwin") pnpmHome = path.join(env.HOME, "Library", "pnpm"); + else pnpmHome = path.join(absolute(env.XDG_DATA_HOME) ? env.XDG_DATA_HOME : path.join(env.HOME, ".local", "share"), "pnpm"); + } + pnpmHome = path.normalize(pnpmHome).replace(/(.)[\\/]+$/, "$1"); + const bin = path.join(pnpmHome, "bin"); + const comparable = (value) => { + const normal = path.normalize(value).replace(/(.)[\\/]+$/, "$1"); + return platform === "win32" ? normal.toLowerCase() : normal; + }; + const pathKey = platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") : "PATH"; + const entries = String((pathKey && env[pathKey]) ?? "").split(path.delimiter).filter(absolute); + return { pnpmHome, path: bin, onPath: entries.some((entry) => comparable(entry) === comparable(bin)) }; +} + const probeNames = ["node", "pnpm", "pi", "shell", "gentleAi", "go", "globalBin", "setup"]; /** Collect with caller-owned read-only probes; no default process/home adapters. * A tool probe returns { available, version, usable }; missing is available:false. * pnpm also needs compatible:true (runtime/capability evidence), shell global:true, * and Gentle AI compatible:true (normal binary resolver/integrity evidence). - * globalBin returns { available, path, writable, onPath }; setup returns boolean. + * globalBin returns { available, path, writable, onPath } for pnpmGlobalBin's + * `$PNPM_HOME/bin` directory; setup returns boolean. * Version values are exact stable versions, not raw arbitrary command output. */ export async function collectInventory({ platform, arch, probes = {} }) { diff --git a/scripts/installer-runner.mjs b/scripts/installer-runner.mjs new file mode 100644 index 000000000..aa30229dc --- /dev/null +++ b/scripts/installer-runner.mjs @@ -0,0 +1,312 @@ +import { readFileSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { + gentleAiBinaryPath, + gentleAiDevBinaryOverrideConfigured, + resolveGentleAiBinary, +} from "../runtime/gentle-ai-binary.mjs"; +import { pnpmGlobalBin, requirements } from "./installer-preflight.mjs"; + +// Standard installation runner: one fixed, consented global pnpm installation +// of Pi plus gentle-pi, then the public `gentle-shell setup`. Every adapter is +// injected by trusted local code; requests carry no commands, URLs, roots or env. + +/** Pi version installed next to gentle-pi (optional peer, resolved in one add). */ +export const PI_INSTALL_VERSION = "1.0.0"; +const PI_PACKAGE = "@earendil-works/pi-coding-agent"; +const SHELL_PACKAGE = "gentle-pi"; + +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const deadlines = Object.freeze({ probe: 30 * SECOND, install: 20 * MINUTE, setup: 20 * MINUTE }); + +// Exact descriptors planPreflight can emit; anything else is a foreign request. +const knownActions = Object.freeze({ + "acquire-node": { kind: "acquire", target: "node", version: requirements.node }, + "verify-node": { kind: "verify", target: "node" }, + "acquire-pnpm": { kind: "acquire", target: "pnpm", version: requirements.pnpm }, + "verify-pnpm": { kind: "verify", target: "pnpm" }, + "setup-global-bin": { kind: "setup", target: "globalBin" }, + "acquire-go": { kind: "acquire", target: "go", version: requirements.go }, + "verify-go": { kind: "verify", target: "go" }, + "install-pi": { kind: "install-global", target: "pi", version: requirements.pi }, + "install-shell": { kind: "install-global", target: "shell", version: requirements.shell }, + "provision-native": { kind: "existing-installer", target: "gentleAi", version: requirements.gentleAi }, + "setup-shell": { kind: "normal-setup", target: "shell" }, + "verify-readiness": { kind: "verify", target: "stack" }, +}); +// T4 implements only the clean-stack path: both global packages are missing. +const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"]; +const optionalActions = ["setup-global-bin"]; + +function stable(version) { + const match = typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); + return match ? match.slice(1).map(Number) : null; +} +function atLeast(version, minimum) { + const [left, right] = [stable(version), stable(minimum)]; + if (!left || !right) return false; + for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i]; + return true; +} +if (!atLeast(PI_INSTALL_VERSION, requirements.pi)) throw new Error("Pi install pin is below the peer minimum"); + +function plainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; +} +function onlyKeys(value, allowed) { + return plainObject(value) && Object.keys(value).every((key) => allowed.includes(key)); +} + +/** Accept only `{ plan, consent }` where plan is an unmodified planPreflight result. */ +function validRequest(request) { + if (!onlyKeys(request, ["plan", "consent"]) || !onlyKeys(request.plan, ["tools", "blockers", "actions", "ready"])) return false; + const { tools, blockers, actions, ready } = request.plan; + if (!plainObject(tools) || !Array.isArray(blockers) || !Array.isArray(actions) || typeof ready !== "boolean") return false; + const ids = new Set(); + for (const action of actions) { + const expected = knownActions[action?.id]; + if (!expected || ids.has(action.id) || !onlyKeys(action, ["id", "kind", "target", "version"])) return false; + if (Object.keys(action).length !== Object.keys(expected).length + 1) return false; + if (Object.entries(expected).some(([key, value]) => action[key] !== value)) return false; + ids.add(action.id); + } + return true; +} + +/** Gates that need no process: returns a blocked reason or null. */ +function planGate(plan, platform) { + if (plan.blockers.length > 0) return "preflight-blocked"; + const ids = plan.actions.map((action) => action.id); + // gentle-pi's postinstall may build Gentle AI from source on Windows; T4 never acquires Go. + if (platform === "win32" && (ids.includes("acquire-go") || plan.tools.go?.status !== "reusable")) return "go-required"; + const supported = requiredActions.every((id) => ids.includes(id)) && + ids.every((id) => requiredActions.includes(id) || optionalActions.includes(id)); + return supported ? null : "unsupported-plan"; +} + +function pathKeyOf(env, platform) { + return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH"; +} +function envValue(env, name, platform) { + const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name; + return key === undefined ? undefined : env[key]; +} + +/** Child env: the user's env plus PNPM_HOME and `$PNPM_HOME/bin` first on PATH. */ +function childEnvironment(env, platform, globalBin) { + const path = platform === "win32" ? win32 : posix; + const key = pathKeyOf(env, platform); + const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0); + return { ...env, PNPM_HOME: globalBin.pnpmHome, [key]: [globalBin.path, ...rest].join(path.delimiter) }; +} + +/** pnpm comes from the bootstrap handoff, or a PATH executable on POSIX only. */ +async function pnpmInvocation(env, platform, fs) { + const path = platform === "win32" ? win32 : posix; + const node = env.GENTLE_INSTALL_PNPM_NODE; + const entry = env.GENTLE_INSTALL_PNPM_ENTRY; + if (node && entry) { + return path.isAbsolute(node) && path.isAbsolute(entry) ? { command: node, prefix: [entry] } : null; + } + // A Windows .cmd shim cannot be spawned with shell:false; require the direct handoff. + if (platform === "win32") return null; + for (const directory of String(env.PATH ?? "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue; + const candidate = path.join(directory, "pnpm"); + if (await fs.isFile(candidate)) return { command: candidate, prefix: [] }; + } + return null; +} + +/** First `npm` the way Go's exec.LookPath (used by Gentle AI) finds it: each + * absolute PATH directory in order and, on Windows, every PATHEXT extension in + * PATHEXT order (lowercased, dot-prefixed, default .com/.exe/.bat/.cmd). + */ +async function firstNpm(child, platform, fs) { + const path = platform === "win32" ? win32 : posix; + const extensions = platform === "win32" + ? String(envValue(child, "PATHEXT", platform) || ".com;.exe;.bat;.cmd").toLowerCase().split(";") + .filter((extension) => extension.length > 0).map((extension) => extension.startsWith(".") ? extension : `.${extension}`) + : [""]; + for (const directory of String(child[pathKeyOf(child, platform)] ?? "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue; + for (const extension of extensions) { + const candidate = path.join(directory, `npm${extension}`); + if (await fs.isFile(candidate)) return candidate; + } + } + return null; +} + +/** Genuine npm: the first resolved npm is the npm package's own CLI, which then runs. + * Returns true, "npm-shadowed" (Windows: an earlier non-.cmd npm wins) or false. + */ +async function genuineNpm(child, platform, nodePath, adapters) { + const path = platform === "win32" ? win32 : posix; + const first = await firstNpm(child, platform, adapters.fs); + if (!first) return false; + let cli; + if (platform === "win32") { + if (path.extname(first).toLowerCase() !== ".cmd") return "npm-shadowed"; + cli = path.join(path.dirname(first), "node_modules", "npm", "bin", "npm-cli.js"); + } else { + cli = await adapters.fs.realpath(first); + } + if (!(await adapters.fs.isFile(cli))) return false; + const packageDir = path.dirname(path.dirname(cli)); + if (path.basename(cli) !== "npm-cli.js" || path.basename(packageDir) !== "npm" || + path.basename(path.dirname(packageDir)) !== "node_modules") return false; + const metadata = JSON.parse(await adapters.fs.readText(path.join(packageDir, "package.json"))); + if (metadata?.name !== "npm" || !stable(metadata.version)) return false; + const result = await adapters.run(nodePath, [cli, "--version"], { env: child, deadlineMs: deadlines.probe }); + return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version; +} + +function succeeded(result) { + return result?.code === 0 && !result.signal && result.timedOut !== true; +} + +function samePath(left, right, platform) { + const path = platform === "win32" ? win32 : posix; + const normal = (value) => path.normalize(value).replace(/(.)[\\/]+$/, "$1"); + const [a, b] = [normal(left), normal(right)]; + return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** Pre-install `list -g --json`: true when neither package is listed in any + * project, "existing-stack" when one is, false when the shape is unknown. + */ +function noExistingStack(stdout) { + const projects = JSON.parse(stdout); + if (!Array.isArray(projects)) return false; + for (const project of projects) { + if (!plainObject(project)) return false; + for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { + const dependencies = project[field]; + if (dependencies === undefined) continue; + if (!plainObject(dependencies)) return false; + if (Object.hasOwn(dependencies, PI_PACKAGE) || Object.hasOwn(dependencies, SHELL_PACKAGE)) return "existing-stack"; + } + } + return true; +} + +/** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME. */ +async function verifiedPackageRoot(stdout, pnpmHome, platform, fs) { + const path = platform === "win32" ? win32 : posix; + const projects = JSON.parse(stdout); + const dependencies = Array.isArray(projects) && projects.length === 1 ? projects[0]?.dependencies : null; + if (!plainObject(dependencies)) return null; + const pi = dependencies[PI_PACKAGE]; + const shell = dependencies[SHELL_PACKAGE]; + if (pi?.version !== PI_INSTALL_VERSION || shell?.version !== requirements.shell) return null; + if (typeof shell.path !== "string" || !path.isAbsolute(shell.path)) return null; + const [root, home] = [await fs.realpath(shell.path), await fs.realpath(pnpmHome)]; + const inside = path.relative(home, root); + if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) return null; + return root; +} + +/** Default integrity adapter: the package-local resolver, never a dev override. + * `resolve` is injectable only so trusted tests can stand in for a pinned binary. + */ +export async function packageNativeGentleAi({ packageRoot, platform, env, home }, resolve = resolveGentleAiBinary) { + const environment = { env, home }; + if (gentleAiDevBinaryOverrideConfigured(environment)) return { ok: false, reason: "development-override" }; + try { + const binary = resolve(packageRoot, platform, readFileSync, environment); + if (binary === gentleAiBinaryPath(packageRoot, platform)) return { ok: true }; + } catch { + // Resolver errors name local paths; report only the classification. + } + return { ok: false, reason: "package-native-unverified" }; +} + +/** + * runStandardInstall({ plan, consent }, adapters) -> { outcome, ... } + * Outcomes: blocked (nothing installed), failed (stopped after `completed`), + * terminal-action-required (installed; user PATH persisted, open a new + * terminal) or ready. Adapters: platform, nodePath, env (user env), home?, + * run(command, argv, { env, deadlineMs }) with shell:false semantics returning + * { code, signal, timedOut, stdout }, fs { isFile, realpath, readText }, + * verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }). + * Nothing is ever deleted; no provisioning marker is written. + */ +export async function runStandardInstall(request, adapters) { + const { platform, env, log = () => {} } = adapters; + const completed = []; + const blocked = (reason) => { + log({ step: "gate", status: "blocked", reason }); + return { outcome: "blocked", reason, completed }; + }; + if (!validRequest(request)) return blocked("invalid-request"); + if (request.consent !== true) return blocked("consent-required"); + const gate = planGate(request.plan, platform); + if (gate) return blocked(gate); + + const path = platform === "win32" ? win32 : posix; + const globalBin = pnpmGlobalBin({ platform, env }); + if (!globalBin) return blocked("pnpm-home-unknown"); + if (!path.isAbsolute(adapters.nodePath ?? "")) return blocked("node-unavailable"); + const child = childEnvironment(env, platform, globalBin); + const pnpm = await pnpmInvocation(env, platform, adapters.fs).catch(() => null); + if (!pnpm) return blocked("pnpm-unavailable"); + const runPnpm = (args, deadlineMs) => adapters.run(pnpm.command, [...pnpm.prefix, ...args], { env: child, deadlineMs }); + const home = adapters.home ?? (platform === "win32" ? env.USERPROFILE : env.HOME); + + const list = () => runPnpm(["list", "-g", "--depth", "0", "--json"], deadlines.probe); + + // Pre-install gates: a false check or adapter exception blocks before mutation; + // a check may return a more specific blocked reason instead of false. + const checks = [ + ["check-npm", "npm-unavailable", () => genuineNpm(child, platform, adapters.nodePath, adapters)], + ["check-global-bin", "global-bin-mismatch", async () => { + const result = await runPnpm(["bin", "-g"], deadlines.probe); + const reported = String(result.stdout ?? "").trim(); + return succeeded(result) && path.isAbsolute(reported) && samePath(reported, globalBin.path, platform); + }], + // Never rely on the caller's plan alone: an existing Pi or gentle-pi is not overwritten. + ["check-existing-stack", "global-list-unavailable", async () => { + const result = await list(); + return succeeded(result) && noExistingStack(String(result.stdout ?? "")); + }], + ]; + for (const [step, reason, check] of checks) { + const verdict = await check().catch(() => false); + if (verdict !== true) return blocked(typeof verdict === "string" ? verdict : reason); + completed.push(step); + log({ step, status: "done" }); + } + + // Mutating and post-install steps: a false result or exception is a failure. + let packageRoot = null; + const steps = [ + ["install-global", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`, + `${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], deadlines.install))], + ["verify-global-list", async () => { + const result = await list(); + if (!succeeded(result)) return false; + packageRoot = await verifiedPackageRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs); + return packageRoot !== null; + }], + ["verify-shell-bin", () => adapters.fs.isFile(path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"))], + ["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true], + ["shell-setup", async () => succeeded(await adapters.run(adapters.nodePath, + [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"], { env: child, deadlineMs: deadlines.setup }))], + ]; + // A child PATH never proves a fresh terminal; persist it with pnpm's own setup. + // globalBin.onPath was computed from the user's own PATH, not the child env. + const persistPath = !globalBin.onPath; + if (persistPath) steps.push(["persist-path", async () => succeeded(await runPnpm(["setup"], deadlines.probe))]); + for (const [step, run] of steps) { + if (!(await Promise.resolve().then(run).catch(() => false))) { + log({ step, status: "failed" }); + return { outcome: "failed", failedStep: step, completed }; + } + completed.push(step); + log({ step, status: "done" }); + } + if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed }; + return { outcome: "ready", completed }; +} diff --git a/tests/installer-preflight.test.ts b/tests/installer-preflight.test.ts index 6365e8693..9af46bcb9 100644 --- a/tests/installer-preflight.test.ts +++ b/tests/installer-preflight.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import test from "node:test"; -import { collectInventory, planPreflight, requirements } from "../scripts/installer-preflight.mjs"; +import { collectInventory, planPreflight, pnpmGlobalBin, requirements } from "../scripts/installer-preflight.mjs"; const absent = { available: false }; const tool = (version: string) => ({ available: true, version, usable: true }); @@ -93,6 +93,21 @@ test("global-bin must be writable and reachable; known PATH repair is explicit", } }); +test("pnpm global bin is $PNPM_HOME/bin and onPath checks that directory, not $PNPM_HOME", () => { + const home = "/home/u/.local/share/pnpm"; + assert.deepEqual(pnpmGlobalBin({ platform: "linux", env: { HOME: "/home/u", PATH: `${home}:/usr/bin` } }), + { pnpmHome: home, path: `${home}/bin`, onPath: false }); + assert.equal(pnpmGlobalBin({ platform: "linux", env: { HOME: "/home/u", PATH: `/usr/bin:${home}/bin/` } })?.onPath, true); + assert.equal(pnpmGlobalBin({ platform: "linux", env: { HOME: "/home/u", XDG_DATA_HOME: "/data", PATH: "" } })?.path, "/data/pnpm/bin"); + assert.equal(pnpmGlobalBin({ platform: "darwin", env: { HOME: "/Users/u", PNPM_HOME: "/opt/pnpm", PATH: "/opt/pnpm/bin" } })?.onPath, true); + assert.equal(pnpmGlobalBin({ platform: "darwin", env: { HOME: "/Users/u" } })?.path, "/Users/u/Library/pnpm/bin"); + assert.deepEqual(pnpmGlobalBin({ platform: "win32", env: { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", Path: "c:\\users\\U\\appdata\\local\\PNPM\\Bin\\;C:\\Windows" } }), + { pnpmHome: "C:\\Users\\u\\AppData\\Local\\pnpm", path: "C:\\Users\\u\\AppData\\Local\\pnpm\\bin", onPath: true }); + for (const env of [{ PNPM_HOME: "relative/pnpm", HOME: "/home/u" }, { PATH: "/usr/bin" }, { HOME: "relative" }]) { + assert.equal(pnpmGlobalBin({ platform: "linux", env }), null); + } +}); + test("unsupported targets never offer installation", () => { for (const inventory of [clean("freebsd"), clean("linux", "ia32")]) { assert.equal(planPreflight(inventory).blockers[0].code, "unsupported-target"); diff --git a/tests/installer-runner.test.ts b/tests/installer-runner.test.ts new file mode 100644 index 000000000..0adf60e96 --- /dev/null +++ b/tests/installer-runner.test.ts @@ -0,0 +1,440 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs"; +import { planPreflight, requirements } from "../scripts/installer-preflight.mjs"; +import { PI_INSTALL_VERSION, packageNativeGentleAi, runStandardInstall } from "../scripts/installer-runner.mjs"; + +const HOME = "/home/u"; +const PNPM_HOME = "/home/u/.local/share/pnpm"; +const BIN = `${PNPM_HOME}/bin`; +const NODE = "/opt/node/bin/node"; +const ENTRY = "/tools/pnpm/bin/pnpm.mjs"; +const NPM_CLI = "/opt/node/lib/node_modules/npm/bin/npm-cli.js"; +const PACKAGE_ROOT = `${PNPM_HOME}/global/v11/node_modules/gentle-pi`; +const SHELL_ENTRY = `${PACKAGE_ROOT}/bin/gentle-shell.mjs`; + +const W_NODE_DIR = "C:\\Program Files\\nodejs"; +const W_NODE = `${W_NODE_DIR}\\node.exe`; +const W_ENTRY = "C:\\Tools\\pnpm\\bin\\pnpm.mjs"; +const W_NPM_CMD = `${W_NODE_DIR}\\npm.cmd`; +const W_NPM_CLI = `${W_NODE_DIR}\\node_modules\\npm\\bin\\npm-cli.js`; +const W_PNPM_HOME = "C:\\Users\\u\\AppData\\Local\\pnpm"; +const W_BIN = `${W_PNPM_HOME}\\bin`; +const W_ROOT = `${W_PNPM_HOME}\\global\\v11\\node_modules\\gentle-pi`; + +type Call = { command: string; args: string[]; env: Record; deadlineMs: number }; +type Result = { code: number | null; signal?: string | null; timedOut?: boolean; stdout?: string }; +type Layout = { platform: string; node: string; entry: string; npmCli: string; shellEntry: string; bin: string; + root: string; env: Record; files: string[]; realpaths: Record; texts: Record }; + +const npmPackage = JSON.stringify({ name: "npm", version: "11.19.0" }); +const posixLayout: Layout = { + platform: "linux", node: NODE, entry: ENTRY, npmCli: NPM_CLI, shellEntry: SHELL_ENTRY, bin: BIN, root: PACKAGE_ROOT, + env: { HOME, PATH: `${BIN}:/opt/node/bin:/usr/bin`, GENTLE_INSTALL_PNPM_NODE: NODE, GENTLE_INSTALL_PNPM_ENTRY: ENTRY }, + files: ["/opt/node/bin/npm", NPM_CLI, `${BIN}/gentle-shell`], + realpaths: { "/opt/node/bin/npm": NPM_CLI, [PNPM_HOME]: PNPM_HOME, [PACKAGE_ROOT]: PACKAGE_ROOT }, + texts: { "/opt/node/lib/node_modules/npm/package.json": npmPackage }, +}; +// The user's Path already holds the global bin, spelled with different case. +const windowsLayout: Layout = { + platform: "win32", node: W_NODE, entry: W_ENTRY, npmCli: W_NPM_CLI, shellEntry: `${W_ROOT}\\bin\\gentle-shell.mjs`, + bin: W_BIN, root: W_ROOT, + env: { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", USERPROFILE: "C:\\Users\\u", + Path: `c:\\users\\u\\appdata\\local\\PNPM\\bin\\;${W_NODE_DIR};C:\\Windows`, + GENTLE_INSTALL_PNPM_NODE: W_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_ENTRY }, + files: [W_NPM_CMD, W_NPM_CLI, `${W_BIN}\\gentle-shell.cmd`], + realpaths: { [W_PNPM_HOME]: W_PNPM_HOME, [W_ROOT]: W_ROOT }, + texts: { [`${W_NODE_DIR}\\node_modules\\npm\\package.json`]: npmPackage }, +}; + +const absent = { available: false }; +const tool = (version: string) => ({ available: true, version, usable: true }); +function plan(platform = "linux", change: object = {}) { + return planPreflight({ platform, arch: "x64", node: tool("24.18.0"), pnpm: { ...tool("11.1.1"), compatible: true }, + pi: absent, shell: absent, gentleAi: absent, go: platform === "win32" ? tool("1.26.0") : absent, + globalBin: { available: true, path: BIN, writable: true, onPath: true }, setup: false, ...change }); +} +function listing(pi = PI_INSTALL_VERSION, shell = requirements.shell, path = PACKAGE_ROOT) { + return JSON.stringify([{ path: `${PNPM_HOME}/global/v11`, dependencies: { + "@earendil-works/pi-coding-agent": { version: pi }, + "gentle-pi": { version: shell, path }, + } }]); +} + +// The same response key may hold a sequence: the first list -g precedes installation. +const LIST = "list -g --depth 0 --json"; +const emptyList = { code: 0, stdout: "[]" }; + +function harness({ env = {}, results = {}, files = [] as string[], integrity = { ok: true } as object, layout = posixLayout } = {}) { + const calls: Call[] = []; + const logs: object[] = []; + const fileSet = new Set([...layout.files, ...files]); + const { realpaths, texts } = layout; + const defaults: Record = { + "--version": { code: 0, stdout: "11.19.0\n" }, + "bin -g": { code: 0, stdout: `${layout.bin}\n` }, + add: { code: 0 }, + [LIST]: [emptyList, { code: 0, stdout: listing(PI_INSTALL_VERSION, requirements.shell, layout.root) }], + "gentle-shell setup": { code: 0 }, + "pnpm setup": { code: 0 }, + }; + const responses = { ...defaults, ...results } as Record; + const seen: Record = {}; + function key(command: string, args: string[]) { + if (command === layout.node && args[0] === layout.npmCli) return args.slice(1).join(" "); + if (command === layout.node && args[0] === layout.shellEntry) return `gentle-shell ${args.slice(1).join(" ")}`; + const rest = command === layout.node && args[0] === layout.entry ? args.slice(1) : args; + if (rest[0] === "add") return "add"; + if (rest.join(" ") === "setup") return "pnpm setup"; + return rest.join(" "); + } + const integrityCalls: object[] = []; + const adapters = { + platform: layout.platform, + nodePath: layout.node, + env: { ...layout.env, ...env } as Record, + run: async (command: string, args: string[], options: { env: Record; deadlineMs: number }) => { + calls.push({ command, args, env: options.env, deadlineMs: options.deadlineMs }); + const k = key(command, args); + const entry = responses[k]; + assert.ok(entry, `unexpected command ${command} ${args.join(" ")}`); + const index = seen[k] ?? 0; + seen[k] = index + 1; + const response = Array.isArray(entry) ? entry[Math.min(index, entry.length - 1)] : entry; + return { signal: null, timedOut: false, stdout: "", stderr: "", ...response }; + }, + fs: { + isFile: async (path: string) => fileSet.has(path), + realpath: async (path: string) => { + if (!(path in realpaths)) throw new Error(`ENOENT ${path}`); + return realpaths[path]; + }, + readText: async (path: string) => { + if (!(path in texts)) throw new Error(`ENOENT ${path}`); + return texts[path]; + }, + }, + verifyGentleAi: async (request: object) => { + integrityCalls.push(request); + return integrity; + }, + log: (entry: object) => logs.push(entry), + }; + const pnpmCalls = () => calls.filter((call) => call.args[0] === layout.entry).map((call) => call.args.slice(1).join(" ")); + return { adapters, calls, logs, pnpmCalls, integrityCalls }; +} + +const INSTALL = `add -g @earendil-works/pi-coding-agent@${PI_INSTALL_VERSION} gentle-pi@${requirements.shell} --allow-build=gentle-pi`; + +test("declined or missing consent runs no commands", async () => { + for (const consent of [false, undefined, "yes", 1]) { + const h = harness(); + const result = await runStandardInstall({ plan: plan(), consent }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "consent-required"); + assert.deepEqual(h.calls, []); + } +}); + +test("caller-supplied commands, URLs, roots or env are rejected before any command", async () => { + const base = plan(); + const injected = [ + { plan: base, consent: true, commands: [["rm", "-rf", "/"]] }, + { plan: base, consent: true, env: { PATH: "/evil" } }, + { plan: { ...base, commands: ["curl https://example.invalid"] }, consent: true }, + { plan: { ...base, actions: [...base.actions, { id: "run", kind: "exec", target: "sh", command: "sh" }] }, consent: true }, + { plan: { ...base, actions: base.actions.map((a: object) => ({ ...a, url: "https://example.invalid" })) }, consent: true }, + { plan: { ...base, actions: base.actions.map((a: { id: string }) => a.id === "install-shell" ? { ...a, version: "9.9.9" } : a) }, consent: true }, + { plan: { ...base, root: "/elsewhere" }, consent: true }, + null, + ]; + for (const request of injected) { + const h = harness(); + const result = await runStandardInstall(request, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "invalid-request"); + assert.deepEqual(h.calls, []); + } +}); + +test("preflight blockers and unsupported T4 plans are blocked before any command", async () => { + const cases = [ + plan("linux", { node: tool("20.0.0") }), + plan("linux", { node: absent }), + plan("linux", { pi: tool("1.2.0") }), + plan("linux", { pi: tool("1.2.0"), shell: { ...tool("4.0.0"), global: true } }), + ]; + for (const fixed of cases) { + const h = harness(); + const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.match(result.reason, /^(preflight-blocked|unsupported-plan)$/); + assert.deepEqual(h.calls, []); + } +}); + +test("Windows without suitable Go is blocked before install", async () => { + // Missing Go yields an acquire-go intent; too-old Go is already a preflight blocker. + for (const [go, reason] of [[absent, "go-required"], [tool("1.25.9"), "preflight-blocked"]] as const) { + const h = harness({ layout: windowsLayout }); + const result = await runStandardInstall({ plan: plan("win32", { go }), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, reason); + assert.deepEqual(h.calls, []); + } +}); + +test("Windows uses the case-insensitive Path key for the child env and bin -g comparison", async () => { + const h = harness({ layout: windowsLayout, results: { "bin -g": { code: 0, stdout: "c:\\users\\u\\appdata\\local\\PNPM\\BIN\\\r\n" } } }); + const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.ok(h.calls.length > 0); + for (const call of h.calls) { + assert.equal(call.env.PATH, undefined); + assert.equal(call.env.PNPM_HOME, W_PNPM_HOME); + assert.equal(call.env.Path.split(";")[0], W_BIN); + } + assert.deepEqual(h.calls.at(-1)?.args, [`${W_ROOT}\\bin\\gentle-shell.mjs`, "setup"]); + const missing = harness({ layout: windowsLayout, env: { Path: `${W_NODE_DIR};C:\\Windows` } }); + const persisted = await runStandardInstall({ plan: plan("win32"), consent: true }, missing.adapters); + assert.equal(persisted.outcome, "terminal-action-required"); + assert.equal(missing.pnpmCalls().at(-1), "setup"); +}); + +test("Windows npm resolves like Go exec.LookPath: PATH order, then PATHEXT order", async () => { + const shadows = [ + { files: [`${W_NODE_DIR}\\npm.exe`] }, + { files: [`${W_NODE_DIR}\\npm.bat`] }, + { files: [`${W_NODE_DIR}\\npm.com`] }, + { files: ["C:\\Early\\npm.exe"], env: { Path: `C:\\Early;${W_NODE_DIR}` } }, + { files: [`${W_NODE_DIR}\\npm.ps1`, `${W_NODE_DIR}\\npm.exe`], env: { PathExt: ".PS1;.CMD" } }, + ]; + for (const { files, env } of shadows) { + const h = harness({ layout: windowsLayout, files, env }); + const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "npm-shadowed"); + assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false); + } + const fake = harness({ layout: windowsLayout, files: ["C:\\Early\\npm.cmd"], env: { Path: `C:\\Early;${W_NODE_DIR}` } }); + assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, fake.adapters)).reason, "npm-unavailable"); + // PATHEXT order wins inside one directory: .CMD before .EXE resolves the genuine npm.cmd. + const ordered = harness({ layout: windowsLayout, files: [`${W_NODE_DIR}\\npm.exe`], env: { PATHEXT: "cmd;.EXE" } }); + assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, ordered.adapters)).outcome, "ready"); + const noCmd = harness({ layout: windowsLayout, env: { PATHEXT: ".EXE;.COM" } }); + assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, noCmd.adapters)).reason, "npm-unavailable"); +}); + +test("Windows without the direct pnpm handoff is blocked rather than spawning a .cmd shim", async () => { + const h = harness({ env: { GENTLE_INSTALL_PNPM_NODE: "", GENTLE_INSTALL_PNPM_ENTRY: "" } }); + h.adapters.platform = "win32"; + h.adapters.env = { LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", Path: "C:\\Windows" }; + const result = await runStandardInstall({ plan: plan("win32"), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "pnpm-unavailable"); + assert.deepEqual(h.calls, []); +}); + +test("missing or impersonated npm blocks before installation", async () => { + const variants = [ + (h: ReturnType) => { h.adapters.env.PATH = `${BIN}:/usr/bin`; }, + (h: ReturnType) => { h.adapters.fs.realpath = async () => "/opt/fake/npm-wrapper.sh"; }, + (h: ReturnType) => { h.adapters.fs.readText = async () => JSON.stringify({ name: "not-npm", version: "1.0.0" }); }, + ]; + for (const vary of variants) { + const h = harness(); + vary(h); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "npm-unavailable"); + assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false); + } + const lying = harness({ results: { "--version": { code: 0, stdout: "10.0.0\n" } } }); + assert.equal((await runStandardInstall({ plan: plan(), consent: true }, lying.adapters)).reason, "npm-unavailable"); + assert.equal(lying.pnpmCalls().some((call) => call.startsWith("add")), false); +}); + +test("verified clean install runs one exact add -g with a package-scoped build approval", async () => { + const h = harness(); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]); + const adds = h.calls.filter((call) => call.args[1] === "add"); + assert.equal(adds.length, 1); + assert.equal(adds[0].args.some((arg) => /dangerously|allow-build=\*|--allow-build$|approve-builds/.test(arg)), false); + assert.equal(adds[0].command, NODE); + assert.deepEqual(h.calls.at(-1)?.args, [SHELL_ENTRY, "setup"]); + assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack", "install-global", + "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]); + assert.deepEqual(h.integrityCalls, [{ packageRoot: PACKAGE_ROOT, platform: "linux", env: h.adapters.env, home: HOME }]); + assert.equal(JSON.stringify(h.logs).includes("11.19.0"), false); +}); + +test("every child env has PNPM_HOME and $PNPM_HOME/bin first on PATH", async () => { + const h = harness({ env: { PATH: "/opt/node/bin:/usr/bin" } }); + await runStandardInstall({ plan: plan("linux", { globalBin: { available: true, path: BIN, writable: true, onPath: false } }), consent: true }, h.adapters); + assert.ok(h.calls.length > 0); + for (const call of h.calls) { + assert.equal(call.env.PNPM_HOME, PNPM_HOME); + assert.equal(call.env.PATH.split(":")[0], BIN); + assert.ok(call.deadlineMs > 0); + } + assert.equal(h.adapters.env.PNPM_HOME, undefined); +}); + +test("bin -g mismatch or failure is blocked before installation", async () => { + for (const result of [{ code: 0, stdout: `${PNPM_HOME}\n` }, { code: 1, stdout: "" }, { code: 0, stdout: "/elsewhere/bin\n" }]) { + const h = harness({ results: { "bin -g": result } }); + const outcome = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(outcome.outcome, "blocked"); + assert.equal(outcome.reason, "global-bin-mismatch"); + assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false); + } +}); + +test("install nonzero, signal or deadline fails and reports completed steps", async () => { + for (const add of [{ code: 1 }, { code: null, signal: "SIGKILL" }, { code: 0, timedOut: true }]) { + const h = harness({ results: { add } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "install-global"); + assert.deepEqual(result.completed, ["check-npm", "check-global-bin", "check-existing-stack"]); + assert.equal(h.pnpmCalls().length, 3); + } +}); + +test("an existing global Pi or gentle-pi blocks before installation regardless of the plan", async () => { + const existing = [ + listing(), + JSON.stringify([{ dependencies: { "@earendil-works/pi-coding-agent": { version: "1.2.0" } } }]), + JSON.stringify([{ dependencies: {} }, { dependencies: { "gentle-pi": { version: "3.0.0" } } }]), + JSON.stringify([{ optionalDependencies: { "gentle-pi": { version: "4.0.0" } } }]), + ]; + for (const stdout of existing) { + const h = harness({ results: { [LIST]: { code: 0, stdout } } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "existing-stack"); + assert.equal(h.pnpmCalls().some((call) => call.startsWith("add")), false); + } + const unrelated = harness({ results: { [LIST]: [{ code: 0, stdout: JSON.stringify([{ dependencies: { typescript: { version: "5.0.0" } } }]) }, + { code: 0, stdout: listing() }] } }); + assert.equal((await runStandardInstall({ plan: plan(), consent: true }, unrelated.adapters)).outcome, "ready"); +}); + +test("a failed or unparseable pre-install global list blocks before mutation", async () => { + for (const list of [{ code: 1, stdout: "[]" }, { code: 0, timedOut: true, stdout: "[]" }, { code: 0, stdout: "not json" }, + { code: 0, stdout: "" }, { code: 0, stdout: "{}" }, { code: 0, stdout: JSON.stringify([{ dependencies: [] }]) }]) { + const h = harness({ results: { [LIST]: list } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "global-list-unavailable"); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST]); + } +}); + +test("global list missing a package, wrong version or unconfined path fails", async () => { + const listings = [ + JSON.stringify([{ dependencies: { "gentle-pi": { version: requirements.shell, path: PACKAGE_ROOT } } }]), + listing("0.99.1"), + listing(PI_INSTALL_VERSION, "3.0.0"), + listing(PI_INSTALL_VERSION, requirements.shell, "/elsewhere/gentle-pi"), + "not json", + ]; + for (const stdout of listings) { + const h = harness({ results: { [LIST]: [emptyList, { code: 0, stdout }] } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "verify-global-list"); + assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false); + } +}); + +test("missing gentle-shell bin in $PNPM_HOME/bin fails", async () => { + const h = harness(); + h.adapters.fs.isFile = async (path: string) => path !== `${BIN}/gentle-shell` && [NPM_CLI, "/opt/node/bin/npm"].includes(path); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "verify-shell-bin"); +}); + +test("integrity failure or development override fails before setup", async () => { + for (const integrity of [{ ok: false, reason: "package-local-binary-missing" }, { ok: false, reason: "development-override" }, {}]) { + const h = harness({ integrity }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "verify-gentle-ai"); + assert.equal(h.calls.some((call) => call.args[0] === SHELL_ENTRY), false); + } +}); + +test("gentle-shell setup nonzero, signal or deadline fails", async () => { + for (const setup of [{ code: 2 }, { code: null, signal: "SIGTERM" }, { code: 0, timedOut: true }]) { + const h = harness({ results: { "gentle-shell setup": setup } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "shell-setup"); + assert.ok(result.completed.includes("verify-gentle-ai")); + } +}); + +test("$PNPM_HOME/bin absent from the user PATH runs pnpm setup and requires a new terminal", async () => { + const h = harness({ env: { PATH: `${PNPM_HOME}:/opt/node/bin:/usr/bin` } }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "terminal-action-required"); + assert.equal(result.action, "open-new-terminal"); + assert.equal(h.pnpmCalls().at(-1), "setup"); + assert.equal(result.completed.at(-1), "persist-path"); + const failing = harness({ env: { PATH: "/opt/node/bin:/usr/bin" }, results: { "pnpm setup": { code: 1 } } }); + const failed = await runStandardInstall({ plan: plan(), consent: true }, failing.adapters); + assert.equal(failed.outcome, "failed"); + assert.equal(failed.failedStep, "persist-path"); +}); + +test("already on PATH and fully verified is ready without pnpm setup", async () => { + const h = harness(); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(h.pnpmCalls().includes("setup"), false); +}); + +test("an unexpected adapter exception fails closed without leaking its message", async () => { + const h = harness(); + h.adapters.run = async () => { throw new Error("private /home/u/secret"); }; + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(JSON.stringify(result).includes("secret"), false); + assert.equal(JSON.stringify(h.logs).includes("secret"), false); +}); + +test("package-native integrity rejects development overrides and unverified package roots", async () => { + const dir = mkdtempSync(join(tmpdir(), "gentle-runner-integrity-")); + try { + const dev = join(dir, "dev-gentle-ai"); + writeFileSync(dev, "#!/bin/sh\n"); + chmodSync(dev, 0o755); + const overridden = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, + env: { GENTLE_PI_GENTLE_AI_DEV_BINARY: dev } }); + assert.deepEqual(overridden, { ok: false, reason: "development-override" }); + const missing = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} }); + assert.deepEqual(missing, { ok: false, reason: "package-native-unverified" }); + const seen: unknown[][] = []; + const verified = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} }, + (...args: unknown[]) => { seen.push(args); return gentleAiBinaryPath(dir, "linux"); }); + assert.deepEqual(verified, { ok: true }); + assert.equal(seen.length, 1); + assert.deepEqual(seen[0].slice(0, 2), [dir, "linux"]); + assert.deepEqual(seen[0][3], { env: {}, home: dir }); + const elsewhere = await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: {} }, () => dev); + assert.deepEqual(elsewhere, { ok: false, reason: "package-native-unverified" }); + let called = false; + await packageNativeGentleAi({ packageRoot: dir, platform: "linux", home: dir, env: { GENTLE_PI_GENTLE_AI_DEV_BINARY: dev } }, + () => { called = true; return gentleAiBinaryPath(dir, "linux"); }); + assert.equal(called, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); From f755380118b61a4d206d1563ea1dd38731bcb3b1 Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 19:33:23 +0200 Subject: [PATCH 07/24] docs(odd): record T4 runner verification and review --- odd/tasks/browser-install-wizard.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index 0a3fe27d3..a4377f522 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -23,8 +23,8 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. - Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. -- Running committed authored lines through T3 closure: 2,831 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15). Passive T3 closure commit: `82ce7778`. -- Last reviewed source boundary: `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`; committed tree exactly matched approved frozen T3 tree `4e227bc619d1c8825b78d65613f9adf9768f2312`. +- Running committed authored lines through T4 source: 3,926 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15 + T4 source 1,095). This T4 closure ledger is a separate passive commit. +- Last reviewed source boundary: `0d9dac2cc90db922283f40cffa03958ba827fd42`; committed tree exactly matched approved frozen T4 tree `2aba557ebaa015241c998fe6f707f0e8715e30a3`. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. - Each task uses delegated direct: unfamiliar preparation and two or more non-trivial implementation files trigger a bounded writer. @@ -36,7 +36,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - [x] T1 — Dependency inventory and ordered preflight plan. Status: done; commit `09860c05ee951cdfa85abfb4006c56ca577b28d3`, three independent focused runs passed 21/21, native medium review approved and acknowledged. Route: delegated direct (preparation and multiple non-trivial files). Surfaces: `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast: 250–360 lines. Checks: `node --experimental-strip-types --test tests/installer-preflight.test.ts`; fake inventory/process adapters, no host provisioning. - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. - [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. -- [ ] T4 — Standard installation driver. Status: local read-only mapping completed (`mur18ury-j-eq2v`); bounded public research completed (`mur2mlxy-l-00gv`), dependency closure incomplete; disposable validation `mur3ceex-m-b88x` stopped on harness-assumed archive path before CLI execution; blocked: corrected repeat `mur5mscy-n-hwz5` also stopped on a harness preflight limitation before CLI execution. Experiments stopped; observation-based validation redesign documented below; human selected `authorize_t4_observed_lab_once`, single run `mur6c1an-o-5qc5` finished blocked at the isolation gate (harness PATH error); pnpm itself ran. Human selected `authorize_t4_observed_lab_path_fix_once`; corrected run `mur7rhcu-p-imdr` passed V/G/A/B/C and failed D2; design settled below; writer `mur7zk8j-q-iklz` implemented it test-first (RED observed, 117 pass/13 native skips); independent verification PASS; one scoped correction applied (`mur8epna-s-joyp`); parent spot check passed; native review pending. No runner implementation yet. Prior explorer `mur2j43y-k-8l0s` completed blocked with zero GETs before runner implementation. No runner source writes yet. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. +- [x] T4 — Standard installation driver. Status: bounded implementation closed; commit `0d9dac2cc90db922283f40cffa03958ba827fd42`. Writer, independent verifier and parent passed (final 134 tests/121 pass/0 fail/13 native Windows skips); native review approved and exactly acknowledged. Real-machine behavior remains T7. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. - [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. @@ -219,3 +219,11 @@ Rationale: tests use fake process/filesystem adapters, so lab network isolation - Correction `mur8epna-s-joyp`: F1 Windows npm resolution now follows PATH order then PATHEXT order like Go `exec.LookPath` (`npm-shadowed` blocks); A1 new pre-install `check-existing-stack` runs `list -g --depth 0 --json` and blocks with `existing-stack` or `global-list-unavailable`; A6 exact Go reasons, win32 `Path`/`bin -g` case tests and a positive `packageNativeGentleAi` test. Runner 312 lines, tests 440 lines (22 tests). RED: 12/22 failed for the expected reasons; GREEN 22/22; PATHEXT mutation check failed exactly one test, then reverted. A6 Go and win32 Path tests are characterization tests (no own RED). - Writer and parent spot check: 134 tests/121 pass/0 fail/13 native skips (26.1s); `git diff --check` clean. The writer's stray `/tmp/runner-tail-3619111.ts` (a copy of the test tail outside allowed surfaces) was inspected and removed by the parent. - New T7 checks: clean-machine `pnpm list -g --json` output shape (empty output blocks closed); Windows current-directory lookup (`ErrDot`/`NoDefaultCurrentDirectoryInExePath`) not reproduced. A fake `npm.cmd` earlier in PATH reports `npm-unavailable` rather than `npm-shadowed` (still blocks). + +### T4 closure evidence +- Native review `review-7d456e98582a8344`: controller-selected medium, one consolidated `review-reliability` run; approved on the last admitted event. Exact acknowledgement burned authority for target `sha256:bb9e531446008047217a1c8d2f72c2f07ba12860c8222835eb17951da3b98729`, consumed revision `sha256:0cbab3ad68771f222d4daf6eb088d133b2c2908c08187e32a9509587fcbcbb00`. The installer-high functional checks above still apply; native medium classification does not replace them. +- Source commit `0d9dac2cc90db922283f40cffa03958ba827fd42` (`feat(installer): add standard installation runner`): staged and committed trees equal frozen `2aba557ebaa015241c998fe6f707f0e8715e30a3`; 6 files, 1,074 insertions/21 deletions. +- Nonblocking advisories (receipt stands; separate later work, no automatic correction): `R3-multi-project-list` (`scripts/installer-runner.mjs:199`), `R3-no-rerun-recovery` (`:269-273`), `R3-npm-node-resolution` (`:162`), `R3-pnpm-path-fallback-untested` (`:114-117`), `R3-stale-ledger-status` (ledger, addressed by this closure), `R3-unmodified-plan-overclaim` (`docs/install-wizard.md:127-128`), `R3-verify-readiness-no-step` (`:39`). +- Carried into T5: build the plan server-side only and never round-trip it through the browser; pass the real user environment; show the `pnpm setup` profile change and all fixed actions before one consent; map `blocked` reasons (`existing-stack`, `npm-unavailable`, `npm-shadowed`, `go-required`, `unsupported-plan`, `global-list-unavailable`) to clear guidance. +- Carried into T7: `list -g --json` shape on clean and populated machines, rerun/recovery after partial failure, Windows current-directory lookup, installed package identity/shim target/exec bit, fresh-terminal Node and `gentle-shell` resolution, the observed uncounted pnpm update-notifier network contact. +- Next: T5 secure local wizard host and packaged entry. From e4cac184c7d707d44ef036f517cc2d85a9eb4c8d Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 22:32:10 +0200 Subject: [PATCH 08/24] feat(installer): add host probes and pnpm runtime persistence --- docs/install-wizard.md | 201 ++++++++++++-- odd/tasks/browser-install-wizard.md | 184 ++++--------- scripts/installer-preflight.mjs | 31 ++- scripts/installer-probes.mjs | 302 ++++++++++++++++++++ scripts/installer-runner.mjs | 277 ++++++++++++++++--- tests/installer-preflight.test.ts | 33 +++ tests/installer-probes.test.ts | 335 ++++++++++++++++++++++ tests/installer-runner.test.ts | 413 +++++++++++++++++++++++++++- 8 files changed, 1575 insertions(+), 201 deletions(-) create mode 100644 scripts/installer-probes.mjs create mode 100644 tests/installer-probes.test.ts diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 8eccb77e7..365236bc2 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1,8 +1,9 @@ # Installation wizard groundwork -The preflight planner, POSIX bootstrap, Windows prerequisite foundation and the -standard installation runner module are implemented. Windows native validation -remains unavailable locally. +The preflight planner, real host probes, POSIX bootstrap, Windows prerequisite +foundation and the standard installation runner module (including runtime +persistence) are implemented. Windows native validation remains unavailable +locally. **There is no working browser wizard or complete installation path yet.** The bootstrap stops explicitly when the future wizard entry is absent. For ordinary installation and terminal use, follow the [README](../README.md). @@ -17,6 +18,10 @@ For ordinary installation and terminal use, follow the [README](../README.md). | `collectInventory({ platform, arch, probes })` | Calls injected named read-only probes serially. Missing/failed probes become unknown; error text is not retained. | | `planPreflight(inventory)` | Pure classification and ordered action intents; no commands, downloads, installation or setup execution. | | `pnpmGlobalBin({ platform, env })` | Pure pnpm 11 global-bin resolution: `{ pnpmHome, path, onPath }` or `null` when unknowable. | +| `persistencePins` | Fixed runtime persisted under PNPM_HOME: Node 24.21.0, its bundled npm 11.19.0 and the pnpm acquisition pin. | + +`scripts/installer-probes.mjs` supplies the real read-only probes for +`collectInventory`; see [Host probes](#host-probes). Current requirements come from `package.json` and [`gentle-ai-installer.mjs`](../scripts/gentle-ai-installer.mjs): Node ≥22.19.0, @@ -35,15 +40,21 @@ minimum OS versions, libc compatibility, artifact trust and elevation handling remain to be verified in later units. Probes are named `node`, `pnpm`, `pi`, `shell`, `gentleAi`, `go`, `globalBin`, and -`setup`. There are deliberately no built-in host process probes yet. Adapters -must use bounded read-only checks, never launcher setup, postinstall, paid model -requests, credential inspection or home writes. The collector cannot enforce -purity of caller-provided functions; adapter implementations require review. +`setup`. `collectInventory` has no default probes; the real implementations live +in [`installer-probes.mjs`](#host-probes). Probes must use bounded read-only +checks, never launcher setup, postinstall, paid model requests, credential +inspection or home writes. The collector cannot enforce purity of +caller-provided functions; adapter implementations require review. - Tool observations use `{ available: true, version, usable: true }`; absence is `{ available: false }`. Unknown availability, usability or unparseable versions block planning. Versions are exact stable `major.minor.patch` strings (optional `v` prefix); prereleases and raw command output are unknown, not silently reused. +- Node may add `persistent` (resolvable from the user's PATH without bootstrap + tool directories) and `npm` (a genuine npm resolves there) booleans; pnpm + may add `persistent` (resolvable from the user's PATH). A `false` value + yields the runtime persistence intents below; absent or `null` values add + nothing, and the runner's own npm gate still applies. - Shell additionally needs `global: true`, proving normal global ownership. Compatible newer Node, Pi and Shell versions are reused, never downgraded. - pnpm additionally needs `compatible: true`, based on its actual Node engine and @@ -81,6 +92,13 @@ this inventory, **not that verification has executed**. A clean target receives these intents in dependency order: 1. Acquire and verify Node, then pnpm; prepare the usable global-bin environment. + Persist only what is missing. A bootstrap-only reusable Node gets the full + group, always together: `persist-node` (`persist-runtime`, version + 24.21.0), `persist-package-managers` (`install-global`) and + `configure-npm-prefix` (`configure`). A persistent Node is never replaced: + it gets at most one intent, `persist-npm` (npm 11.19.0) when no genuine + npm resolves, `persist-pnpm` (pnpm 11.1.1) when pnpm is bootstrap-only, or + `persist-package-managers` when both are missing. 2. On Windows, acquire and verify Go before missing native provisioning. 3. Install Pi globally, then `gentle-pi` globally (its existing postinstall owns native installation). For an existing Shell with missing native binary, call @@ -110,10 +128,58 @@ tests do not prove clean-machine installation on Windows, macOS or Linux. Focused verification: ```sh -node --experimental-strip-types --test tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts +node --experimental-strip-types --test tests/installer-probes.test.ts tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts sh -n scripts/bootstrap.sh ``` +## Host probes + +`scripts/installer-probes.mjs` exports `createProbes({ platform, env, run, fs, +home?, verifyGentleAi? })`, returning the eight named probes for +`collectInventory`. `env` is the wizard's own environment, with bootstrap tools +first on PATH. Every probe returns the documented shape or the unknown shape +`{ available: null }`; adapter errors are caught and never retained. + +| Export | Contract | +| --- | --- | +| `bootstrapRoots({ platform, env })` | Bootstrap tool roots: an absolute `GENTLE_BOOTSTRAP_TOOLS` plus every PATH entry segment named `.gentle-shell-bootstrap-tools.*` (case-insensitive on Windows). | +| `userEnvironment({ platform, env })` | A copy of `env` whose PATH omits entries inside those roots: the user's real PATH. | +| `hostAdapters({ maxOutputBytes?, maxTextBytes? })` | Real `run` and read-only `fs` adapters, described below. | + +Adapters: + +- `run(command, argv, { env, deadlineMs })` is the runner's process contract: + argv arrays with `shell:false`, SIGKILL at the deadline and stdout bounded + to `maxOutputBytes` (default 1 MiB, reported as `truncated: true`). stderr + is discarded because it can hold private paths. A spawn failure returns + `code: null`. The deadline signals the direct child only, not descendants; + it then destroys the child's pipes and settles as timed out (`code: null`, + `signal: "SIGKILL"`) without waiting for `close`, which a descendant holding + stdout could otherwise delay indefinitely. +- `fs` offers `isFile`, `isDirectory`, `exists` (only ENOENT/ENOTDIR are + absent), `realpath`, `readText` (regular files up to `maxTextBytes`) and + `writable` (an access check, never a write). No probe creates, deletes or + writes anything. + +Probes run only fixed argv: `node --version`, `pnpm --version`, +`pnpm list -g --depth 0 --json` (once, shared), `go version` and npm's +`npm-cli.js --version`. Deadlines are 10 seconds for versions and 30 seconds for +the listing; truncated, nonzero, signalled or timed-out output is unknown. + +| Probe | Evidence | +| --- | --- | +| `node` | The first `node` on the user's real PATH (Go `exec.LookPath` order, PATHEXT on Windows), else the bootstrap one. `persistent` says which. `npm` is the runner's genuine-npm proof in the user's real PATH with `$PNPM_HOME/bin` first, so a bootstrap npm never counts. A Windows `.cmd`/`.bat` cannot run with `shell:false` and is unknown. | +| `pnpm` | The runner's invocation (bootstrap handoff, or a POSIX `pnpm` on PATH). `compatible` requires a successful run (pnpm checks its Node engine at startup) at the pinned major and at least the pin, because the runner's argv is verified for pnpm 11 only. `persistent` is whether any `pnpm` resolves on the real PATH. | +| `pi`, `shell` | pnpm-global entries from the listing. A package that is not pnpm-global but whose command (`pi`, `gentle-shell`) resolves on the real PATH is unknown, never absent, so another installation is not duplicated. Shell is `usable` only when `$PNPM_HOME/bin/gentle-shell` (`.cmd` on Windows) exists. | +| `gentleAi` | Absent without gentle-pi or without its package-native binary. Otherwise compatible only for this package version, a listed path that resolves inside PNPM_HOME and `verifyGentleAi` (default `packageNativeGentleAi`) success; anything else is unknown. | +| `go` | `go version` from the real PATH; `go1.22` normalizes to `1.22.0`; devel and release-candidate builds are unknown. | +| `globalBin` | `pnpmGlobalBin` over the real PATH; `writable` is write access on the nearest existing ancestor of `$PNPM_HOME/bin` (itself included). A non-directory ancestor is not writable. | +| `setup` | `false` when gentle-pi is absent; unknown otherwise, because an existing Shell's setup readiness has no read-only evidence yet. | + +A failed, empty or unparseable listing makes Pi, Shell, Gentle AI and setup +unknown, which blocks planning. A package listed in two projects is ambiguous +and unknown. + ## Standard installation runner `scripts/installer-runner.mjs` exports `runStandardInstall({ plan, consent }, @@ -130,8 +196,9 @@ The request is exactly `{ plan, consent }`. `plan` must be an unmodified request keys, commands, URLs, roots, environment or altered versions are rejected as `invalid-request` before any process runs. `consent` must be the boolean `true`; anything else returns `consent-required` with no commands. The -consent covers the whole displayed plan, including the PATH profile change -below, so the wizard must show that change before asking. +consent covers the whole displayed plan, including the PATH profile change and +the runtime persistence and npm prefix actions below, so the wizard must show +those changes before asking. ### Adapters @@ -142,7 +209,7 @@ below, so the wizard must show that change before asking. | `env` | The user's environment. Never mutated; child environments are copies. | | `home` | Optional; defaults to `HOME` (`USERPROFILE` on Windows). | | `run(command, argv, { env, deadlineMs })` | Argv arrays with `shell:false` semantics and a hard deadline; returns `{ code, signal, timedOut, stdout }` with bounded output. | -| `fs` | Read-only `isFile`, `realpath`, `readText`. The runner has no delete or write operation. | +| `fs` | Read-only `isFile`, `realpath`, `readText` (also used to read pnpm's npm shim). The runner has no delete or write operation. | | `verifyGentleAi({ packageRoot, platform, env, home })` | Returns `{ ok: true }` only for package-native integrity. `packageNativeGentleAi` is the default implementation over `runtime/gentle-ai-binary.mjs`. | | `log({ step, status, reason? })` | Receives step identifiers and statuses only, never raw command output or error text. | @@ -153,9 +220,13 @@ No-process gates, all returning `blocked`: 1. Valid request and explicit consent. 2. Preflight blockers are absent. Only the clean-stack plan is supported: it must contain `install-pi`, `install-shell`, `setup-shell` and - `verify-readiness`, plus optional `setup-global-bin`. Prerequisite acquisition - intents (Node, pnpm, Go), `provision-native`, partial existing stacks and - fully reused stacks are `unsupported-plan`. + `verify-readiness`, plus optional `setup-global-bin` and at most one exact + persistence variant: `persist-node` + `persist-package-managers` + + `configure-npm-prefix`, or `persist-package-managers` alone, or + `persist-npm` alone, or `persist-pnpm` alone. Prerequisite acquisition + intents (Node, pnpm, Go), `provision-native`, any other persistence + combination, partial existing stacks and fully reused stacks are + `unsupported-plan`. 3. On Windows, Go must be `reusable` in the plan because gentle-pi's postinstall may build Gentle AI from source; T4 never acquires Go (`go-required`). 4. `pnpmGlobalBin` resolves PNPM_HOME (`pnpm-home-unknown` otherwise) and @@ -178,7 +249,14 @@ Pre-install checks, returning `blocked` on a false result or adapter error: first candidate must be the npm package's own `bin/npm-cli.js` (symlink target on POSIX; on Windows the candidate must be `npm.cmd`, with `node_modules/npm` beside it), its `package.json` must name `npm` with a - stable version, and `node npm-cli.js --version` must print that version. On + stable version, and `node npm-cli.js --version` must print that version. + A candidate in `$PNPM_HOME/bin` must instead be pnpm's global npm: a POSIX + symlink, or a shim whose single quoted `npm-cli.js` target (`$basedir/...` + on POSIX, `%~dp0\...` or `%dp0%\...` on Windows, or absolute), must + resolve (realpath) to `node_modules/npm/bin/npm-cli.js` inside PNPM_HOME, + and the package must be exactly `npm@11.19.0`. Two different targets, a + target outside PNPM_HOME or another version are rejected. When the plan + adds npm, this check runs after the add and fails as a mutating step. On Windows an `npm.com`, `npm.exe` or `npm.bat` that resolves first, in the same or an earlier directory, blocks as `npm-shadowed`; any other mismatch blocks as `npm-unavailable`. No npm wrapper is created. @@ -192,7 +270,9 @@ Pre-install checks, returning `blocked` on a false result or adapter error: unexpected JSON shape, blocks as `global-list-unavailable`. Mutating and verification steps, returning `failed` with `failedStep` and the -`completed` step list: +`completed` step list. When the plan contains a persistence variant, its +[runtime persistence](#runtime-persistence) steps run first, after +`check-global-bin` and `check-existing-stack`: 1. `install-global`: exactly one `pnpm add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi`. @@ -201,17 +281,19 @@ Mutating and verification steps, returning `failed` with `failedStep` and the approval runs only gentle-pi's postinstall, which provisions Gentle AI. No blanket build approval is ever passed. 2. `verify-global-list`: `pnpm list -g --depth 0 --json` must report exactly the - two pinned versions, and gentle-pi's absolute `path` must resolve inside - PNPM_HOME. + two pinned versions in the single listed project that owns gentle-pi + (other projects, such as the persisted npm and pnpm, are ignored), and + gentle-pi's absolute `path` must resolve inside PNPM_HOME. 3. `verify-shell-bin`: `$PNPM_HOME/bin/gentle-shell` (`gentle-shell.cmd` on Windows) exists. 4. `verify-gentle-ai`: package-native integrity of the installed package. A declared development override (`GENTLE_PI_GENTLE_AI_DEV_BINARY` or its registration file) is never package-native proof and fails. 5. `shell-setup`: the installed public CLI, `node /bin/gentle-shell.mjs - setup`, with the child environment. Nonzero, signal or deadline fails. The - runner neither duplicates setup logic nor writes the automatic provisioning - marker. + setup`, with the child environment minus every `GENTLE_BOOTSTRAP_*` and + `GENTLE_INSTALL_*` key (case-insensitive on Windows); pnpm commands keep the + handoff. Nonzero, signal or deadline fails. The runner neither duplicates + setup logic nor writes the automatic provisioning marker. 6. `persist-path`, only when `$PNPM_HOME/bin` was not on the user's own PATH: `pnpm setup`, then the outcome is `terminal-action-required` with `action: "open-new-terminal"`. A child environment never proves that a fresh @@ -220,8 +302,60 @@ Mutating and verification steps, returning `failed` with `failedStep` and the `ready` requires every step above to complete and the global bin to already be on the user's PATH. Skipped, unverified or failed mandatory steps never yield `ready`, and no existing installation, home or data is deleted on any outcome. -Deadlines are 30 seconds for probes and `pnpm setup` and 20 minutes each for -the install and setup steps. +Deadlines are 30 seconds for probes, npm config commands and `pnpm setup`, and +20 minutes each for the install, persistence and setup steps. + +### Runtime persistence + +A bootstrap-acquired Node lives in a temporary tools directory, so a later +terminal would not find `node` or the genuine npm that Gentle AI's Engram step +needs. Every persistence step uses the same pnpm argv prefix and child +environment as the stack install, and the runner persists only what is missing. + +For a bootstrap-only Node, the full group runs these fixed steps: + +1. `persist-node`: `pnpm runtime set node 24.21.0 -g`, which installs Node under + PNPM_HOME and links `node` into `$PNPM_HOME/bin`. +2. `persist-package-managers`: `pnpm add -g npm@11.19.0 pnpm@11.1.1`, which + adds the `npm`, `npx` and `pnpm` shims to `$PNPM_HOME/bin`. No build + approval of any kind is passed. +3. `verify-persistent-runtime`: in the child environment both `node` and `npm` + must resolve (`exec.LookPath` order) from `$PNPM_HOME/bin`, the node must be + spawnable (`.exe`/`.com` on Windows) and print `v24.21.0`. +4. `check-npm`: the genuine-npm gate above, now accepting the pinned shim. +5. `configure-npm-prefix`: with a pnpm-managed node, npm's default prefix is + derived from a path inside pnpm's store, so `npm install -g` would write + there. The runner reads `npm config get prefix` with the persisted node. + Unless an `npm_config_prefix` (any case) or `PREFIX` variable is set, it + asks pnpm for its store with `pnpm store path` (30-second deadline); a + failed, non-absolute or unresolvable answer fails this step rather than + assuming `$PNPM_HOME/store`. Only when the effective prefix resolves inside + that store does it run + `npm config set prefix --location=user`, then require the + effective prefix to equal PNPM_HOME. An explicit user prefix elsewhere + makes npm report that prefix, so it is never overwritten. A failed read, + write or verification fails this step with the completed list. + +The outcome then also reports `npmPrefix: "configured"` or `"unchanged"`. + +A persistent Node is never replaced or shadowed: there is no `runtime set` and +no prefix change. One fixed add runs, then only the matching checks: + +| Intent | Command | Then | +| --- | --- | --- | +| `persist-npm` | `pnpm add -g npm@11.19.0` | `check-npm` | +| `persist-pnpm` | `pnpm add -g pnpm@11.1.1` | `verify-persistent-pnpm` | +| `persist-package-managers` | `pnpm add -g npm@11.19.0 pnpm@11.1.1` | `check-npm`, `verify-persistent-pnpm` | + +`check-npm` is the genuine-npm gate above. `verify-persistent-pnpm` requires +the first `pnpm` in the child environment to be in `$PNPM_HOME/bin` (`.cmd` +on Windows), resolving like the npm shim to `node_modules/pnpm/bin/pnpm.mjs` +inside PNPM_HOME, with `package.json` `pnpm@11.1.1`, and `node pnpm.mjs +--version` printing `11.1.1`. When pnpm is not added, `check-npm` stays a +pre-install gate. +`npm config get` ignores `--location`, and `.npmrc` files can hold credentials, +so the runner never reads them: a user-level prefix that itself points into +the store is indistinguishable from the default and would be replaced. ### Remaining T7 real-machine checks @@ -244,7 +378,26 @@ must establish on real Windows, macOS and Linux machines: resolvable); - the chosen deadlines suffice on slow networks, and pnpm's registry and update-notice traffic is acceptable; -- interrupted installs are recoverable by rerunning the wizard without data loss. +- interrupted installs are recoverable by rerunning the wizard without data loss; +- `pnpm runtime set node -g` on Windows links a spawnable `node.exe` (a `.cmd` + shim fails `verify-persistent-runtime`), and pnpm's real `npm.cmd`/POSIX + shim text matches the accepted target forms; +- npm's Windows global layout: it places executables in the prefix itself, so + a PNPM_HOME prefix may not put `npm install -g` executables on PATH; also + whether Windows npm's default prefix lands in the store at all; +- `pnpm store path` reports a directory (for example `$PNPM_HOME/store/v11`) + that actually contains the effective npm prefix of a pnpm-managed node; +- pnpm's real global `pnpm` shim targets `node_modules/pnpm/bin/pnpm.mjs`, and + a persistent Node missing npm has a prefix outside the store (the variant + without `runtime set` configures no prefix); +- download integrity of the Node runtime and the npm and pnpm tarballs fetched + during persistence (the Linux lab saw a lockfile hash matching the Node pin, + not download-time verification); +- pnpm's update notifier contacting registry.npmjs.org during these commands; +- `pnpm list -g --json` listing persisted npm/pnpm and the stack as separate + projects with a usable gentle-pi `path`; +- the probes' `access(W_OK)` writability check, which ignores Windows ACLs, and + process deadlines that kill only the direct child. ## POSIX bootstrap: bundle-local tooling only diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index a4377f522..344c18d5d 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -8,6 +8,8 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning ## Authorized scope and constraints - User authorized implementation after approving the corrected design. +- User later granted standing permission for in-scope labs, research, implementation, verification and work-unit commits without per-step prompts ("te permito todo, tiene que quedar genial"). Destructive actions and outward publishing (push, PR, release) still require explicit confirmation. +- Visual reference for the wizard UI: https://gentlemanprogramming.com (verify in a browser during T6). - Preserve the default `~/.gentle-shell/agent`, existing Engram data/server semantics, shared integrations and existing setup/update ownership. - Reuse compatible prerequisites and install missing ones, including Pi. - Prefer pnpm without assuming npm, pnpm, Corepack, Node or Go is already installed. @@ -23,7 +25,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - Development workspace: user-authorized isolated worktree `../gentle-pi-browser-install-wizard` beside the main worktree. This does not change product or Engram installation roots. - Base/initial review boundary: `7a27c1c008b3922b851da5efb78e4ca4dae6e6b1`. - Original forecast: 2,350–3,610 authored changed lines; revised forecast approximately 3,040–4,100 after the cohesive T2 security/tests unit exceeded its initial estimate. Counts are additions plus deletions, generated files excluded; the authorized deadline correction was 153 lines before folding into the work unit. -- Running committed authored lines through T4 source: 3,926 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15 + T4 source 1,095). This T4 closure ledger is a separate passive commit. +- Running committed authored lines through T4 closure (ledger `f7553801` adds about 30): 3,926 (T1 417 + T2 1,041 + T2 ledger 21 + T3 source 1,337 + T3 ledger 15 + T4 source 1,095). This T4 closure ledger is a separate passive commit. - Last reviewed source boundary: `0d9dac2cc90db922283f40cffa03958ba827fd42`; committed tree exactly matched approved frozen T4 tree `2aba557ebaa015241c998fe6f707f0e8715e30a3`. - Delivery strategy: `single-pr`, explicitly selected by the user. No chain strategy or tracker applies. Any repository size-exception approval remains a separate future publishing gate. - No publishing is implied by a delivery-strategy choice. @@ -37,7 +39,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. - [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. - [x] T4 — Standard installation driver. Status: bounded implementation closed; commit `0d9dac2cc90db922283f40cffa03958ba827fd42`. Writer, independent verifier and parent passed (final 134 tests/121 pass/0 fail/13 native Windows skips); native review approved and exactly acknowledged. Real-machine behavior remains T7. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. -- [ ] T5 — Secure local wizard host and packaged entry. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. +- [ ] T5 — Secure local wizard host and packaged entry. Status: split into T5a (probes and runtime persistence, corrected, native review pending) and T5b (secure host and entry, pending); see "T5 settled design". Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. - [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. @@ -64,9 +66,7 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -T1–T3 bounded source units are closed with the evidence below. T3 source commit is `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`, followed by passive closure ledger `82ce7778`. Writer, independent verifier and parent each observed 98 passing tests and 13 unavailable native Windows cases. The integrity-verified published pnpm archive passed the existing parser and pinned metadata checks. Native review was approved and exactly acknowledged. Post-correction RED history remains unknown; current functional GREEN is observed. Native Windows/macOS and clean-machine acceptance remain T7. - -The user-authorized isolated development worktree preserves unrelated original worktree changes. Product/runtime/Engram roots are unchanged. T4 local mapping is complete; public acquisition/lifecycle contracts are absent locally. Human authorized bounded read-only official-source research, now routed to `mur2mlxy-l-00gv` after the original research explorer completed with no network tools, before selecting complete dependency and runner behavior. No runner writer, host installation, publishing or extra source correction has been launched. +T1–T4 are closed with commits, independent verification and native review. T5 is split: T5a (real probes and runtime persistence) is implemented and independently verified, with one scoped correction applied (`murerdbi-z-5qwl`) and native review pending; T5b (secure local host and entry) follows. T6 uses gentlemanprogramming.com as the visual reference. Native Windows/macOS and clean-machine acceptance remain T7. Development stays in the isolated worktree; product/runtime/Engram roots are unchanged. ## Work-unit evidence ### T1 (closed) @@ -103,127 +103,53 @@ The user-authorized isolated development worktree preserves unrelated original w - Native advisory `R3-archive-evidence`, `docs/install-wizard.md:307-310`, informational/non-blocking; receipt stands and no correction/re-review is offered. Separately follow up the stale artifact-evidence wording; recorded actual upstream artifact proof does not establish CLI or native Windows readiness. - Next: T4 read-only contract mapping before bounded runner implementation. No push, PR or host provisioning authorized. -### T4 local mapping and decision gate -- Explorer `mur18ury-j-eq2v` completed read-only mapping in the isolated root; CodeGraph initialized there, bounded fallback after unsuccessful symbols. No source/task writes, tests, installation or network. Injected tracked project skills loaded; ignored local registry absent, not recreated. -- Existing setup owns companion installation, state restoration and cleanup. Use its public CLI, not internal functions or a duplicate component installer. -- Native-path existence is not provenance proof (`bin/gentle-shell.mjs:520–548`); package-local integrity resolver accepts a development override before native verification (`runtime/gentle-ai-binary.mjs:272–306`). An override cannot establish package-native readiness. -- Manual setup does not record automatic provisioning marker (`bin/gentle-shell.mjs:944–956` vs `:1160–1175`); ordinary auto-setup warns and launches on failure, so launcher exit alone does not prove installation readiness. -- Windows installer resolves Go before source-bundle reuse (`scripts/gentle-ai-installer.mjs:630–636`). Package-native reuse can avoid that operation, but temporary agent-home package lifecycle requirements remain unknown. -- Child-only PATH is not fresh-terminal readiness. Preserve existing configured homes and pnpm-global ownership; do not assume npm global layout. POSIX and Windows pnpm handoff differ. -- Requested research: official Gentle AI4.0.0 source commit `ff77164d4f56f1665b22fb6fac51c2ccbb769400`, Pi1.0.0 and0.99.1 acquisition contract, pnpm11.1.1 lifecycle/global/bin/PATH behavior. Read-only public sources, no credentials, executable downloads, installation, provisioning or source writes. Human selected `authorize_t4_public_contracts`; research explorer `mur2j43y-k-8l0s` completed tool-blocked (0 GETs/0 bytes); one read-only HTTPS-capable verifier fallback `mur2mlxy-l-00gv` is running. Budget: at most 20 public text GETs/10 MiB total/60 seconds per request; no executable artifacts, installs, credentials or source writes. Completion/evidence pending. -- Original T4 narrow surfaces remain `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Proposed prerequisite/bootstrap/integrity/marker/PATH expansions are not yet approved or implemented. Focused test-first command after safe design: `node --experimental-strip-types --test tests/installer-runner.test.ts`. - -- T4 research progress (not closure evidence): verifier reports 16 authorized GETs/1,453,761 UTF-8 bytes. Tool output truncation created harness-owned `/tmp/pi-bash-b74d2e010234e8a0.log`; no intentional repository/temp writes. Leave spool untouched. Finish with at most four remaining requests, compact <=100-line/8-KiB command output, exact source excerpts and final accounting; unseen truncated passages remain unproved. No scope/budget extension or implementation implied. - -- T4 acquisition budget exhausted: research reports 20 GETs/1,666,417 UTF-8 response-body bytes, including two Pi source refetches. Pi runtime array command/pnpm prefix-install behavior and independent Gentle AI `npm exec gentle-engram@latest` were inspected, exact citations pending final handoff. Latest pnpm setup docs mix v11 and since-v11.18; historical 11.1.1 global/lifecycle closure remains unavailable. Prefer pnpm does not prohibit genuine npm but does prohibit assuming it already exists. No further retrievals, implementation, tests or log operations; final handoff pending. - -### T4 final research disposition -- Final actor `mur2mlxy-l-00gv` completed 20 GETs/1,666,417 UTF-8 response-body bytes; no budget remaining. No implementation/tests/installations/intentional source writes. Harness spool untouched. Full cited evidence preserved in Engram topic `installer/t4-public-contract-findings` (21361). -- Verified Pi1.0.0/0.99.1 runtime array-command and pnpm-prefix argv do not establish product-global installation. Metadata declares Node>=22.19.0; stricter existing bootstrap minimum24.3.0 remains unchanged. Agent-home managed package ownership is preserved. -- Verified Gentle AI4.0.0 adapter builder adds independent `npm exec --yes --package gentle-engram@latest -- pi-engram init`. Actual reachability/policy/executor/error propagation remains unproved; do not label construction as unconditional successful normal-setup execution. -- Historical pnpm11.1.1 global/lifecycle/PATH behavior, trustworthy genuine npm availability/acquisition, native setup lifecycle/compiler needs and clean-machine acceptance remain unresolved. No complete T4 design or ready-looking scaffold accepted. -- Parent state check: local Node v24.18.0, feature branch intact, only task ledger modified. This runtime meets declared Pi/pnpm requirements but is not fresh-machine acquisition proof. -- Recommended single next gate: explicitly authorized disposable pnpm11.1.1 fixture validation (integrity-verified archive; offline synthetic packages; no real home or real product installation), plus narrowly bounded official Node/npm distribution and pinned Gentle AI execution-flow source reads. This would address concrete missing facts, not establish Windows/macOS/T7 acceptance. No such experiment or additional reads have been launched. - -### T4 disposable validation authorization -- Human selected `authorize_t4_disposable_validation`; one <=10-minute laboratory run now delegated to `mur3ceex-m-b88x`. -- One public pnpm11.1.1 archive GET <=32MiB; literal and repository SHA512 must match BEFORE processing/extraction/execution. Verified CLI may run only against trusted synthetic no-dependency fixtures in owned temporary HOME/cache/global/store/config and offline mode. -- Additionally <=6 textual official GETs/2MiB total from nodejs.org, public GitHubnodejs/node and the fixed Gentle AI source commit, to inspect genuine npm distribution and actual init execution/failure policy. No automatic extension, retry loop or fetched research-code execution. -- No real product installation, actual HOME/PATH/profile/config or credential access, security/lifecycle bypass, source changes, native platform claim or publishing. Clean only owned laboratory; existing harness spool untouched. -- Outcomes remain pending. Parent bookkeeping may update only this ledger/mirror; source stability evidence must distinguish those known ledger transitions from executable changes. - -### T4 disposable validation result (blocked) -- Actor `mur3ceex-m-b88x`: one HTTPS GET/4,223,291 bytes; literal and repository SHA512 matched before parser/extraction. Existing bounded parser accepted448entries. No CLI or fetched research text executed; zero of six text requests used. -- The validation harness assumed `package/dist/pnpm.cjs` was present and exited1 at `verified bundle source unavailable` in378ms. This is an incorrect test-harness assumption, not a pnpm compatibility or installer finding. -- Newly owned temporary extraction removed successfully in finally; tracked/nonignored byte aggregate, index and porcelain status identical before/after. Parent ledger was already modified in both snapshots. Existing harness spool unread/untouched. -- All CLI/global/lifecycle/PATH/Pi-prefix tests and exact Node/npm/Gentle AI execution-flow source reads remain unverified. T4 is not done; no source writer/commit/native-review candidate exists. -- The authorized single experiment has ended; its one archive GET is consumed. No automatic retry/extension permitted. Proposed single repeat would derive the declared `bin/pnpm.mjs` entry and imports from verified metadata/inventory, never assume a dist path. Await human repeat-or-pause decision before any new retrieval/execution. - -### T4 corrected repeat authorization -- Human selected `authorize_t4_corrected_lab_once`; continued the prior verifier context as new task `mur5mscy-n-hwz5`. No duplicate live experiment exists. -- One fresh <=10-minute bounded run with one pinned archive GET<=32MiB/SHA512-before-processing-execution, offline private synthetic fixtures, and <=6 official text GETs/2MiB; original safeguards unchanged. No third automatic attempt. -- Derive actual `bin/pnpm.mjs` entry, prefix normalization and imports from verified package metadata/entry inventory, not an assumed dist filename. Independent authorized source reads can still return evidence if the CLI portion is blocked, unless safety requires stopping. -- No real installation/home/PATH/config/credential/source/publishing changes; cleanup own temp only, old harness spool untouched. Outcome pending; previous failure remains a harness failure, not a pnpm defect. - -### T4 corrected repeat result and stop -- `mur5mscy-n-hwz5` finished semantically blocked (shell exit0),1,505ms. Scanner classified every quoted relative path as mandatory; absent `../lib/pnpm` was not shown reachable on normal execution. Harness-preflight limitation, not pnpm incompatibility. No extraction/CLI execution in this repeat; all CLI/global/lifecycle/Pi-prefix/bin/PATH checks skipped. -- Archive integrity and bounded parser passed448entries; parser preserves `package/`. Verified metadata entry `bin/pnpm.mjs` references existing `package/dist/pnpm.mjs`. Do not infer required modules from all string literals. -- Five GETs/4,272,724 response-body bytes (archive4,223,291 + text49,433). Exact Node24.21.0 source includes npm11.19.0/npm+npx mappings and conditional npm packaging; this is source proof, not actual platform bundle proof. Pinned Gentle AI builder/runner facts reconfirmed but mandatory call flow/error policy remains unknown. Exact citations preserved under `installer/t4-corrected-lab-result`. -- Owned temp cleaned; nonignored source bytes, index/status and parent ledger stable; old harness spool untouched. No installation/source/profile/native acceptance claim. -- Both explicitly authorized experiments have ended. No third run, new GET, source writer or automatic correction authorized. T4 is blocked; stop experiments and redesign the validation gate using control-flow-aware or direct bounded execution semantics rather than another string-scanner retry. No fresh execution or budget extension has been requested. - -### T4 redesigned validation gate (proposed, not authorized or executed) -Root cause of both failed labs: the harness tried to predict required files by static inspection instead of observing execution. The redesign removes all static dependency prediction. - -Preconditions (only these): -1. One pinned archive GET; literal and repository SHA512 match before parsing. -2. Existing bounded parser accepts the archive; extract every accepted entry under an owned private lab directory (`package/` prefix mapped to `/pnpm/`). -3. `package.json` declares name `pnpm`, version `11.1.1` and `bin.pnpm = bin/pnpm.mjs`, and that file exists. Nothing else is predicted. - -Isolation, asserted by observation before any mutation: -- `env -i` with lab-owned HOME, XDG config/cache/data/state, PNPM_HOME, `SHELL=/bin/bash`, minimal PATH; registry configured to an unreachable loopback address as defense in depth; lab cwd with no ancestor `package.json` or `packageManager` field. -- Run `node /pnpm/bin/pnpm.mjs --version` directly (`shell:false`, 60-second SIGKILL deadline, bounded output). Expect `11.1.1`; any runtime import failure is an observed pnpm result, not a harness guess. -- Observe `root -g` and `bin -g`; stop before mutation unless both resolve inside the lab. - -Observed checks, with an explicit dependency graph (a failed step skips only its dependents): -- A: `pnpm pack` of a trusted local fixture (own bin plus postinstall that writes a sentinel inside the lab only). -- B (needs A): `add -g --offline`; then `list -g --depth 0 --json`, run the installed bin from `bin -g` directly, and record whether the postinstall ran, was skipped or warned under default policy. Skipped never counts as ready. -- C (needs A): Pi's exact verified argv `install --prefix /pi --config.auto-install-peers=false --config.strict-peer-dependencies=false --config.strict-dep-builds=false`, then `uninstall --prefix /pi`. -- D (independent): `pnpm setup` with lab HOME/SHELL; diff only lab profile files, then `env -i HOME= bash -ic 'command -v pnpm'` as Linux-bash-only fresh-shell evidence. -- E (independent, text only): up to 5 public GETs (<=3 MiB) at Gentle AI `ff77164d4f56f1665b22fb6fac51c2ccbb769400`: one tree listing, then only files that consume `InstallCommand`, to establish reachability and StopOnError policy for the Engram `npm exec` step. - -Hard limits: <=10 minutes total, one archive GET <=32 MiB, no retries, no mid-run procedure changes, owned cleanup in finally, repository/index/status stability asserted, existing harness spool untouched. Evidence is Linux-only; no Windows/macOS or real-installation claim. - -### T4 observed lab result (blocked at isolation gate) -- `mur6c1an-o-5qc5`: archive GET, pin, SHA512-before-processing, parser (448 entries), metadata and ancestor checks passed. `node /pnpm/bin/pnpm.mjs --version` printed `11.1.1` under Node v24.18.0 with no import errors: the static-prediction failures are resolved. -- `root -g` and `bin -g` exited 1: pnpm 11.1.1 requires the global bin directory `$PNPM_HOME/bin` on PATH; the harness added `$PNPM_HOME`. Harness error, not a pnpm defect. A–D skipped before any mutation. -- Product implication for T4: the runner and terminal handoff must put `$PNPM_HOME/bin` (not `$PNPM_HOME`) on PATH, and preflight `globalBin.onPath` must check that directory. Current installer scripts do not reference `PNPM_HOME`. -- Step E: `internal/agents/pi/adapter.go:285` defines `InstallCommand`; `:297` returns the independent `npm exec` Engram step; `internal/pipeline/orchestrator.go:9-11` `WithFailurePolicy` sets the apply policy, whose default lives in unfetched `runner.go`. The `InstallCommand` consumer remains unknown. -- 6 requests/4,796,977 bytes; lab cleaned; index/status/HEAD stable; spool untouched. The verifier correctly declined a stop-hook review START on the parent's passive ledger. -- Remaining blocker: unobserved A–D and the Engram step failure policy. Proposed single corrected run: identical procedure with PATH `/bin:/usr/bin:/bin`, plus up to 3 text GETs (runner.go and InstallCommand consumers). Authorized once; completed as `mur7rhcu-p-imdr`. - -### T4 PATH-corrected lab result -- `mur7rhcu-p-imdr` (Linux only): `--version` 11.1.1; `root -g` = `$PNPM_HOME/global/v11`, `bin -g` = `$PNPM_HOME/bin`; offline pack, global add, list and direct bin execution passed; Pi's exact prefix argv accepted and uninstall worked. -- Default policy silently skips postinstall on `add -g` (no warning). Documented `--allow-build=` runs it only for that package. -- `pnpm setup` from the extracted entry wrote only the lab `.bashrc`; a fresh interactive bash still could not resolve `pnpm` (D2 FAIL). -- Gentle AI `installcmd/resolver.go:106-108`: Pi install ALWAYS runs `engramInitCommand()` (`pnpm dlx` or `npm exec`), executed by `agentInstallStep` (:58); `pipeline/stages.go:25-26` StopOnError is the default. Selection between `pnpm dlx` and `npm exec` remains unknown. -- Disclosed boundary issue: C1 printed a pnpm update notice despite a loopback registry, so pnpm likely contacted a public registry outside the counted budget (destination/bytes unverified). Counted: 4 requests/4,243,367 bytes. Lab cleaned; repository stable. - -### T4 settled runner design -Rationale: tests use fake process/filesystem adapters, so lab network isolation is not a blocker for implementation; real-machine behavior remains a T7 gate. -- Pure module `scripts/installer-runner.mjs` with injected adapters (process runner with argv arrays, shell:false, deadlines; filesystem; clock/log). It accepts only a fixed plan derived from preflight plus explicit consent; never browser-supplied commands, URLs, paths or environment. -- pnpm invocation normalized to an argv prefix `[nodePath, pnpmEntry]` (from the bootstrap handoff) or a resolved `pnpm` executable. -- PNPM_HOME: use the user's existing value; otherwise pnpm's documented platform default. Child env always gets `PNPM_HOME` and PATH with `$PNPM_HOME/bin` first. Verify `bin -g` equals `$PNPM_HOME/bin`; mismatch blocks. -- Genuine npm gate: Gentle AI's Engram step may run `npm exec` and stops the pipeline on failure, so npm must resolve in the child env (bundled with the pinned official Node) or the runner blocks with an explicit reason. No npm wrapper or impersonation. -- Windows Go gate: if gentle-pi's postinstall would need Go (Windows) and preflight reports Go missing or too old, block; T4 does not acquire Go. -- Single fixed global install: `add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi` in ONE command (Pi is an optional peer dependency resolved next to gentle-pi). Never blanket build approval. -- After install: verify via `list -g --depth 0 --json` that both packages are present at the expected versions, the `gentle-shell` bin exists in `$PNPM_HOME/bin`, and package-native Gentle AI provisioning verifies through the existing integrity resolver with development overrides cleared. -- Run installed `gentle-shell setup` (public CLI) with the child env; nonzero, signal or deadline fails. Do not write the automatic provisioning marker or duplicate setup logic. -- PATH persistence: if `$PNPM_HOME/bin` was not already on the user's PATH, run consented `pnpm setup` and report `terminal-action-required` (open a new terminal); never claim fresh-terminal readiness from a child env. -- Outcomes: `blocked`, `failed`, `terminal-action-required`, `ready`. Any skipped, unverified or failed mandatory step can never yield `ready`. Existing installs/homes are never deleted; failures report the completed steps. -- Fix preflight: `globalBin.onPath` must test `$PNPM_HOME/bin` (pnpm 11), not `$PNPM_HOME`. -- Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. Forecast 500–750 lines. Risk: high. - -### T4 implementation evidence (uncommitted) -- Writer `mur7zk8j-q-iklz`: `scripts/installer-runner.mjs` (new, 261), `tests/installer-runner.test.ts` (new, 316, 18 tests), `scripts/installer-preflight.mjs` (+33/-1, `pnpmGlobalBin`), `tests/installer-preflight.test.ts` (+16/-1), `docs/install-wizard.md` (+136/-13). About 777 lines; over the advisory forecast because of contract documentation. -- RED: runner `ERR_MODULE_NOT_FOUND`; preflight missing `pnpmGlobalBin` export. GREEN: preflight 22/22, runner 18/18. Mutation check: adding `--dangerously-allow-all-builds` failed the exact add -g test, then reverted. -- Writer verification: combined suites 130 tests, 117 pass, 0 fail, 13 native Windows skips, 26.2s; `git diff --check` clean. -- Decision: only the clean-stack plan is supported; existing or partial Pi/gentle-pi installs return `blocked` (`unsupported-plan`) so the pinned single `add -g` never downgrades or duplicates them. Upgrades stay with `gentle-shell update`. A verify-only path is a possible later extension. -- Writer-noted risks for review/T7: `list -g --json` `path` shape unverified on real machines (missing → failed); setup runs `node /bin/gentle-shell.mjs setup` because `.cmd` shims cannot run with `shell:false`; one consent covers the plan, so T5/T6 must show the `pnpm setup` profile change before consent; chosen deadlines 30s probes/`pnpm setup`, 20 min install/setup; genuine npm proof is structural, not cryptographic. -- Risk tier: high (installer). Next: independent verifier, parent spot check, native review, work-unit commit. - -### T4 independent verification and scoped correction -- Verifier `mur89jrr-r-be4r`: PASS, 130 tests/117 pass/0 fail/13 native skips, diff checks clean, no severe defects. -- Accepted for the single scoped correction: F1 Windows npm resolution must follow PATHEXT order per directory like Go `exec.LookPath` (`installer-runner.mjs:123-127`); A1 the runner itself confirms via `list -g --depth 0 --json` that Pi and gentle-pi are absent before `add -g`, otherwise `blocked`; A6 tighten the Go test to the exact reason and add win32 Path/case and positive `packageNativeGentleAi` tests. -- Recorded as later gates, not corrected now: A2 no post-setup re-probe for `verify-readiness`; A3 `onPath` depends on the caller env and fresh-terminal Node resolution is unverified (T5 must pass the real user env); A4 `projects.length === 1` may fail closed with other global packages (T7); A5 installed package identity, shim target and POSIX exec bit not verified (T7). -- Correction `mur8epna-s-joyp`: F1 Windows npm resolution now follows PATH order then PATHEXT order like Go `exec.LookPath` (`npm-shadowed` blocks); A1 new pre-install `check-existing-stack` runs `list -g --depth 0 --json` and blocks with `existing-stack` or `global-list-unavailable`; A6 exact Go reasons, win32 `Path`/`bin -g` case tests and a positive `packageNativeGentleAi` test. Runner 312 lines, tests 440 lines (22 tests). RED: 12/22 failed for the expected reasons; GREEN 22/22; PATHEXT mutation check failed exactly one test, then reverted. A6 Go and win32 Path tests are characterization tests (no own RED). -- Writer and parent spot check: 134 tests/121 pass/0 fail/13 native skips (26.1s); `git diff --check` clean. The writer's stray `/tmp/runner-tail-3619111.ts` (a copy of the test tail outside allowed surfaces) was inspected and removed by the parent. -- New T7 checks: clean-machine `pnpm list -g --json` output shape (empty output blocks closed); Windows current-directory lookup (`ErrDot`/`NoDefaultCurrentDirectoryInExePath`) not reproduced. A fake `npm.cmd` earlier in PATH reports `npm-unavailable` rather than `npm-shadowed` (still blocks). - -### T4 closure evidence -- Native review `review-7d456e98582a8344`: controller-selected medium, one consolidated `review-reliability` run; approved on the last admitted event. Exact acknowledgement burned authority for target `sha256:bb9e531446008047217a1c8d2f72c2f07ba12860c8222835eb17951da3b98729`, consumed revision `sha256:0cbab3ad68771f222d4daf6eb088d133b2c2908c08187e32a9509587fcbcbb00`. The installer-high functional checks above still apply; native medium classification does not replace them. -- Source commit `0d9dac2cc90db922283f40cffa03958ba827fd42` (`feat(installer): add standard installation runner`): staged and committed trees equal frozen `2aba557ebaa015241c998fe6f707f0e8715e30a3`; 6 files, 1,074 insertions/21 deletions. -- Nonblocking advisories (receipt stands; separate later work, no automatic correction): `R3-multi-project-list` (`scripts/installer-runner.mjs:199`), `R3-no-rerun-recovery` (`:269-273`), `R3-npm-node-resolution` (`:162`), `R3-pnpm-path-fallback-untested` (`:114-117`), `R3-stale-ledger-status` (ledger, addressed by this closure), `R3-unmodified-plan-overclaim` (`docs/install-wizard.md:127-128`), `R3-verify-readiness-no-step` (`:39`). -- Carried into T5: build the plan server-side only and never round-trip it through the browser; pass the real user environment; show the `pnpm setup` profile change and all fixed actions before one consent; map `blocked` reasons (`existing-stack`, `npm-unavailable`, `npm-shadowed`, `go-required`, `unsupported-plan`, `global-list-unavailable`) to clear guidance. -- Carried into T7: `list -g --json` shape on clean and populated machines, rerun/recovery after partial failure, Windows current-directory lookup, installed package identity/shim target/exec bit, fresh-terminal Node and `gentle-shell` resolution, the observed uncounted pnpm update-notifier network contact. -- Next: T5 secure local wizard host and packaged entry. +### T4 evidence summary (closed) +Full lab and research details are preserved in Engram topics `installer/t4-public-contract-findings`, `installer/t4-corrected-lab-result`, `installer/t4-observed-lab-result`, `installer/t4-observed-lab-path-fix-result`, `installer/t4-runner-design`, `installer/t4-independent-verification`, `installer/t4-scoped-correction` and `installer/t4-closure`. +- Mapping (`mur18ury-j-eq2v`): existing setup owns companions; native-path existence is not provenance; a development override is not package-native proof; manual setup writes no auto-provisioning marker; launcher exit is not readiness; Windows installer resolves Go before bundle reuse; child PATH is not fresh-terminal readiness. +- Public research (`mur2mlxy-l-00gv`, 20 GETs): Pi 1.0.0/0.99.1 array `npmCommand` and pnpm `--prefix` argv; Gentle AI always runs an Engram init (`pnpm dlx` or `npm exec`) executed by `agentInstallStep`, StopOnError by default. +- Two labs failed on harness static-prediction errors (assumed dist path; quoted strings treated as imports), one on a harness PATH error; none was a pnpm defect. The observation-based redesign then proved (Linux): global bin is `$PNPM_HOME/bin` and must be on PATH; `add -g` silently skips postinstall unless `--allow-build=`; Pi's prefix argv is accepted. One run likely contacted the registry through pnpm's update notifier outside the counted budget (disclosed). +- Runner design and implementation: single fixed `add -g @earendil-works/pi-coding-agent@1.0.0 gentle-pi@ --allow-build=gentle-pi`; genuine-npm gate with Windows PATHEXT-order resolution; Windows Go gate; pre-install `list -g` existing-stack block; package-native integrity without overrides; public `gentle-shell setup`; `pnpm setup` → `terminal-action-required`; never `ready` on skipped/unverified steps. Existing stacks are blocked; upgrades stay with `gentle-shell update`. +- Verification: writer RED/GREEN, independent verifier PASS (`mur89jrr-r-be4r`), one scoped correction (`mur8epna-s-joyp`), parent 134 tests/121 pass/13 native skips. Native review `review-7d456e98582a8344` approved and acknowledged. Commit `0d9dac2cc90db922283f40cffa03958ba827fd42` (tree `2aba557e`), ledger `f7553801`. +- Nonblocking advisories for later: `R3-multi-project-list`, `R3-no-rerun-recovery`, `R3-npm-node-resolution`, `R3-pnpm-path-fallback-untested`, `R3-unmodified-plan-overclaim`, `R3-verify-readiness-no-step`. +- T7 checks carried: `list -g --json` shapes, rerun/recovery, Windows current-directory lookup, installed identity/shim/exec bit, fresh-terminal resolution, update-notifier network contact. + +### T5 mapping and decisions +- Explorer `mur8s5uo-t-8sgi` (read-only, CodeGraph first). Key facts: `launchWizard` spawns `bin/gentle-shell-install.mjs` with no argv and inherited stdio/cwd; nonzero exit makes the bootstrap delete its owned tools, exit 0 keeps them. POSIX passes pnpm only through PATH; Windows also sets `GENTLE_INSTALL_PNPM_*`. Preflight has no real host probes yet, so T5 must supply all eight. `processCheck` is synchronous and unsuitable for the long-running server. Packaging already ships `bin/`, `scripts/`, `assets/`; `verify-package-files` must list installer files and export a testable helper. No existing HTTP server or browser opener. +- Parent decisions: D1 real probes in a separate `scripts/installer-probes.mjs` with tests; `globalBin.writable` = write access on the nearest existing ancestor of `$PNPM_HOME/bin`, without writing. D3 exit 0 for `ready`/`terminal-action-required`, nonzero for `blocked`/`failed`/closed without install. D4 print the URL and open it with a fixed per-platform opener; the one-time code expires quickly and is single-use. D5 minimal placeholder page until T6; tests use fixture assets. D6 the runner strips `GENTLE_BOOTSTRAP_*` and `GENTLE_INSTALL_*` from the env passed to `gentle-shell setup` (small runner edit). +- Open product decision D2: a bootstrap-acquired Node lives in a temporary tools directory that is not on the user's real PATH, so after the wizard an ordinary terminal may not find `node` (or the npm that Gentle AI's Engram step needs). Asked the user where Node should persist. +- D2 resolved: user selected `persist_node_via_pnpm`. Verification lab `mur8yve2-u-7kqn` (authorized once) checks pnpm 11.1.1 Node management, integrity and fresh-shell resolution before implementation. +- Node lab `mur8yve2-u-7kqn`: `pnpm env use --global 24.21.0` persists Node under `$PNPM_HOME` and links only `node` into `$PNPM_HOME/bin`; a fresh bash after `pnpm setup` resolves it. npm and pnpm are NOT placed in `$PNPM_HOME/bin`. `env use` is deprecated in favor of `pnpm runtime set node -g`. The global lockfile sha256 for the Node tarball equals the official pin; download-time verification was not observed. The update notifier contacted registry.npmjs.org. The child's cleanup was blocked by the safety hook; the parent inspected and removed the lab. +- Visual reference `mur8zlmy-v-6de5`: CSS-source extraction (no screenshots; ego-browser CLI unavailable on Linux). Tokens saved in Engram topic `installer/t6-visual-reference`. Dark-only palette with accent `#f095c8` on `#1a1218`; no web fonts (local font stacks only); do not copy logos, branding images, favicon or photos. +- Next: follow-up Node lab for `runtime set node 24.21.0 -g` plus pinned `add -g npm@11.19.0 pnpm@11.1.1` (npm 11.19.0 is the version bundled with Node 24.21.0), then the T5 writer. +- Follow-up lab `murdct4a-w-q23y`: `runtime set node 24.21.0 -g` (no deprecation), then `add -g npm@11.19.0 pnpm@11.1.1`, then `setup` leave `node`, `npm`, `npx` and `pnpm` in `$PNPM_HOME/bin`; a fresh bash resolves all three there (v24.21.0, 11.19.0, 11.1.1), shadowing host npm. Shims run `$basedir/node`. Gotcha: npm's default `prefix` then points inside pnpm's content-addressed store. Parent removed the lab (181 MB) after the safety hook blocked the child. + +### T5 settled design +Split into two work units, each with its own verification, review and commit. + +**T5a — real probes and runtime persistence (runner extension).** +- `scripts/installer-probes.mjs`: async real probes for `node`, `pnpm`, `pi`, `shell`, `gentleAi`, `go`, `globalBin`, `setup` with injected spawn/fs; bounded deadlines and output; never write. `globalBin.writable` checks the nearest existing ancestor of `$PNPM_HOME/bin`. Node/pnpm report their source: persistent (resolvable from the user's real PATH) or bootstrap-only (inside the bootstrap tools directory). +- Runner: when Node is bootstrap-only or a genuine npm is not resolvable, add fixed steps before the stack install: `runtime set node 24.21.0 -g`, then `add -g npm@11.19.0 pnpm@11.1.1`. The genuine-npm gate also accepts the pnpm-global npm shim in `$PNPM_HOME/bin` whose target is `node_modules/npm/bin/npm-cli.js` inside `$PNPM_HOME` with package `npm@11.19.0`. +- npm prefix: only when npm's effective prefix resolves inside the pnpm store and the user has no explicit prefix, set the user-level npm prefix to `$PNPM_HOME` (listed as a fixed action requiring consent). Never overwrite an existing prefix. +- D6: strip `GENTLE_BOOTSTRAP_*` and `GENTLE_INSTALL_*` from the env passed to `gentle-shell setup`. +- Surfaces: `scripts/installer-probes.mjs`, `tests/installer-probes.test.ts`, `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `scripts/installer-preflight.mjs`, `tests/installer-preflight.test.ts`, `docs/install-wizard.md`. + +**T5b — secure local host and packaged entry.** +- `scripts/installer-server.mjs` per the mapped design: `127.0.0.1:0` only, exact Host, Origin plus `X-Gentle-Install` header on POST, one-time short-TTL session code → HttpOnly SameSite=Strict cookie, fixed endpoints (`/`, assets, `/api/plan`, `/api/install`, `/api/progress`, `/api/shutdown`), server-side plan with `planId` and re-inventory 409, single-flight, bounded ring-buffer log, strict headers/CSP, guidance for every blocked reason and failed step, idle timeout. +- `bin/gentle-shell-install.mjs`: wires real probes/adapters (async spawn, fs), prints the URL and opens it with a fixed per-platform opener; exit 0 for `ready`/`terminal-action-required`, nonzero otherwise. +- Minimal `assets/install-wizard/index.html` placeholder until T6. +- `verify-package-files`: list installer files and export a testable helper. +- Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `assets/install-wizard/index.html`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. + +### T5a implementation evidence (uncommitted) +- Writer `murdr48a-x-kllq`: `scripts/installer-probes.mjs` (new, 293), `tests/installer-probes.test.ts` (new, 311, 17 tests), `scripts/installer-runner.mjs` (+164/-34), `tests/installer-runner.test.ts` (+253/-9), `scripts/installer-preflight.mjs` (+21/-2), `tests/installer-preflight.test.ts` (+19), `docs/install-wizard.md` (+145/-24). About 1,275 lines; over the advisory heuristic because of test tables and documented contract. +- RED observed: probes `ERR_MODULE_NOT_FOUND`; runner missing `persistencePins` export then 9 assertion failures; preflight persistence-intent test. GREEN: probes 17/17, runner+preflight 55/55 (one test-only assertion fix). Mutation checks: all targeted rules killed after isolating two initially surviving mutants. +- Writer verification: 162 tests/149 pass/0 fail/13 native skips (26.2s); diff checks clean. Temporary `/dev/shm/t5a-mut-hashes.txt` removed by the writer. +- Open gaps for the parent: (1) pnpm bootstrap-only with persistent Node and genuine npm does not persist pnpm; (2) when npm is missing but Node is persistent, `runtime set` puts pnpm's Node first in `$PNPM_HOME/bin` and may shadow the user's Node — must be shown before consent; (3) an explicit user prefix pointing inside the store is indistinguishable from the default without reading `.npmrc`; (4) Windows npm global bins land in the prefix root, possibly off PATH (T7); (5) Windows shim text and `runtime set` layout are assumed (T7); (6) Pi bin name `pi` unverified locally; (7) pnpm major must be 11 (pnpm 12 on PATH blocks on POSIX); (8) deadlines kill only the direct child; `access(W_OK)` ignores Windows ACLs. +- Risk: high. Next: independent verifier, then at most one scoped correction including gaps (1) and (2). + +### T5a independent verification and scoped correction +- Verifier `mureiums-y-e31e`: PASS, 162 tests/149 pass/0 fail/13 native skips; diff checks clean; no severe defects; T4 guarantees intact. +- Accepted for the single scoped correction: persist only what is missing — Node bootstrap-only keeps the full group; with a persistent user Node, no `runtime set`, and one `add -g` containing only `npm@11.19.0` (no genuine npm) and/or `pnpm@11.1.1` (pnpm bootstrap-only). This removes user-Node shadowing and uses the ignored `pnpm.persistent` evidence. Also: probe deadline settles without waiting for `close`; npm prefix resolves the store with `pnpm store path` and fails closed; POSIX symlinked npm must pass realpath confinement and the pin. +- Recorded, not corrected: `npm config set --location=user` may rewrite `~/.npmrc` formatting; npm global bins share `$PNPM_HOME/bin` on POSIX; underlying tools may write logs/update-notifier state during probes (T7). +- Correction `murerdbi-z-5qwl` applied: persistent user Node → no `runtime set`, one fixed `add -g` with `npm@11.19.0` and/or `pnpm@11.1.1` (4 exact accepted variants) plus pnpm verification; probe deadline settles without `close`; npm prefix uses `pnpm store path` and fails closed; every npm in `$PNPM_HOME/bin` (symlink or shim) is confined and pinned. RED observed per change; 8 mutations killed. Writer and parent spot check: 169 tests/156 pass/0 fail/13 native skips (26.2s); diff check clean; no stray files. +- New T7 checks: pnpm-managed persistent Node missing npm skips the prefix check; pnpm shim target assumed from pin metadata; `pnpm store path` containment; full-group added pnpm still unverified. diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs index cd6201da8..0379b92b7 100644 --- a/scripts/installer-preflight.mjs +++ b/scripts/installer-preflight.mjs @@ -20,6 +20,13 @@ export const requirements = Object.freeze({ go: GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION, }); +/** Runtime persisted under PNPM_HOME. Bootstrap-only Node: `pnpm runtime set + * node -g`, then `pnpm add -g npm@ pnpm@`. Persistent Node: + * one `pnpm add -g` of only the missing npm and/or bootstrap-only pnpm. + * npm 11.19.0 is the npm bundled with Node 24.21.0. + */ +export const persistencePins = Object.freeze({ node: "24.21.0", npm: "11.19.0", pnpm: requirements.pnpm }); + /** pnpm 11 global bin directory: `$PNPM_HOME/bin`, not `$PNPM_HOME` itself. * PNPM_HOME is the user's existing absolute value, otherwise pnpm's documented * platform default. onPath compares PATH entries with that bin directory, so a @@ -56,8 +63,10 @@ const probeNames = ["node", "pnpm", "pi", "shell", "gentleAi", "go", "globalBin" * A tool probe returns { available, version, usable }; missing is available:false. * pnpm also needs compatible:true (runtime/capability evidence), shell global:true, * and Gentle AI compatible:true (normal binary resolver/integrity evidence). - * globalBin returns { available, path, writable, onPath } for pnpmGlobalBin's - * `$PNPM_HOME/bin` directory; setup returns boolean. + * Node may add persistent (resolvable from the user's PATH without bootstrap + * tool directories) and npm (a genuine npm resolves there) booleans; pnpm may + * add persistent (resolvable from the user's PATH). globalBin returns { available, path, writable, onPath } for + * pnpmGlobalBin's `$PNPM_HOME/bin` directory; setup returns boolean. * Version values are exact stable versions, not raw arbitrary command output. */ export async function collectInventory({ platform, arch, probes = {} }) { @@ -84,6 +93,9 @@ function compareVersions(left, right) { } return 0; } +if (compareVersions(versionParts(persistencePins.node), versionParts(requirements.node)) < 0) { + throw new Error("Persistent Node pin is below the repository minimum"); +} function classify(observation, required, extraCheck = () => true, exact = false) { if (observation?.available === false) return "unavailable"; if (observation?.available !== true) return "unknown"; @@ -146,6 +158,21 @@ export function planPreflight(inventory) { acquire("node"); acquire("pnpm"); if (tools.globalBin.status !== "reusable") action("setup-global-bin", "setup", "globalBin"); + // A bootstrap Node lives in a temporary tools directory, and Gentle AI's Engram + // step needs a genuine npm: persist what is missing under PNPM_HOME through + // pnpm itself. A persistent Node is never replaced or shadowed. + const node = inventory.node; + if (tools.node.status === "reusable" && node.persistent === false) { + action("persist-node", "persist-runtime", "node", persistencePins.node); + action("persist-package-managers", "install-global", "package-managers"); + action("configure-npm-prefix", "configure", "npm-prefix"); + } else if (tools.node.status === "reusable") { + const npm = node.npm === false; + const pnpm = inventory.pnpm?.persistent === false; + if (npm && pnpm) action("persist-package-managers", "install-global", "package-managers"); + else if (npm) action("persist-npm", "install-global", "npm", persistencePins.npm); + else if (pnpm) action("persist-pnpm", "install-global", "pnpm", persistencePins.pnpm); + } acquire("go"); if (tools.pi.status === "unavailable") action("install-pi", "install-global", "pi", requirements.pi); if (missingShell) action("install-shell", "install-global", "shell", requirements.shell); diff --git a/scripts/installer-probes.mjs b/scripts/installer-probes.mjs new file mode 100644 index 000000000..e0f0b7519 --- /dev/null +++ b/scripts/installer-probes.mjs @@ -0,0 +1,302 @@ +import { spawn as spawnProcess } from "node:child_process"; +import { constants } from "node:fs"; +import { access, lstat, readFile, realpath, stat } from "node:fs/promises"; +import { posix, win32 } from "node:path"; +import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs"; +import { pnpmGlobalBin, requirements } from "./installer-preflight.mjs"; +import { + PI_PACKAGE, + SHELL_PACKAGE, + childEnvironment, + contains, + genuineNpm, + lookPath, + packageNativeGentleAi, + pnpmInvocation, + spawnable, + succeeded, +} from "./installer-runner.mjs"; + +// Real host probes for collectInventory. Every effect goes through injected +// adapters; probes only run fixed read-only argv (`--version`, `go version`, +// `pnpm list -g`) and never write, create directories or run setup/postinstall. + +const SECOND = 1000; +const deadlines = Object.freeze({ version: 10 * SECOND, list: 30 * SECOND }); +const BOOTSTRAP_TOOLS = /^\.gentle-shell-bootstrap-tools\./; +const PNPM_MAJOR = Number(requirements.pnpm.split(".")[0]); + +const unknown = () => ({ available: null }); +const absent = () => ({ available: false }); + +function pathOf(platform) { + return platform === "win32" ? win32 : posix; +} +function pathKey(env, platform) { + return platform === "win32" ? Object.keys(env).find((key) => key.toUpperCase() === "PATH") ?? "Path" : "PATH"; +} +function envValue(env, name, platform) { + const key = platform === "win32" ? Object.keys(env).find((candidate) => candidate.toUpperCase() === name) : name; + return key === undefined ? undefined : env[key]; +} +function plainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; +} +function exactVersion(text, pattern) { + const match = pattern.exec(String(text ?? "").trim()); + return match ? `${match[1]}.${match[2]}.${match[3] ?? "0"}` : null; +} +const NUMBER = "(0|[1-9]\\d*)"; +const STABLE = new RegExp(`^${NUMBER}\\.${NUMBER}\\.${NUMBER}$`); +const NODE_VERSION = new RegExp(`^v${NUMBER}\\.${NUMBER}\\.${NUMBER}$`); +const GO_VERSION = new RegExp(`^go version go${NUMBER}\\.${NUMBER}(?:\\.${NUMBER})? \\S+$`); +function atLeast(version, minimum) { + const [left, right] = [version, minimum].map((value) => value.split(".").map(Number)); + for (let i = 0; i < 3; i += 1) if (left[i] !== right[i]) return left[i] > right[i]; + return true; +} + +function normalDirectory(path, value) { + return path.normalize(value).replace(/(.)[\\/]+$/, "$1"); +} +function sameDirectory(platform, left, right) { + const path = pathOf(platform); + const [a, b] = [normalDirectory(path, left), normalDirectory(path, right)]; + return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; +} + +/** Bootstrap tool roots: `GENTLE_BOOTSTRAP_TOOLS` and every PATH entry segment + * named `.gentle-shell-bootstrap-tools.*` (the bootstrap's temporary directory). + */ +export function bootstrapRoots({ platform, env }) { + const path = pathOf(platform); + const roots = []; + const add = (root) => { + const normal = normalDirectory(path, root); + if (!roots.some((other) => sameDirectory(platform, other, normal))) roots.push(normal); + }; + const tools = envValue(env, "GENTLE_BOOTSTRAP_TOOLS", platform); + if (typeof tools === "string" && path.isAbsolute(tools)) add(tools); + const marker = platform === "win32" ? new RegExp(BOOTSTRAP_TOOLS.source, "i") : BOOTSTRAP_TOOLS; + for (const entry of String(env[pathKey(env, platform)] ?? "").split(path.delimiter)) { + if (!path.isAbsolute(entry)) continue; + const parts = path.normalize(entry).split(path.sep); + const index = parts.findIndex((part) => marker.test(part)); + if (index > 0) add(parts.slice(0, index + 1).join(path.sep) || path.sep); + } + return roots; +} + +/** The user's own environment: PATH without bootstrap tool directories. */ +export function userEnvironment({ platform, env }) { + const path = pathOf(platform); + const roots = bootstrapRoots({ platform, env }); + const key = pathKey(env, platform); + if (env[key] === undefined) return { ...env }; + const own = (entry) => !roots.some((root) => sameDirectory(platform, root, entry) || contains(root, entry, platform)); + return { ...env, [key]: String(env[key]).split(path.delimiter).filter((entry) => entry.length > 0 && own(entry)).join(path.delimiter) }; +} + +/** Real adapters: argv spawn without a shell, SIGKILL at the deadline, bounded + * stdout (stderr discarded: it can hold private paths) and read-only fs checks. + * The deadline signals the direct child only, not its descendants, and settles + * the result as timed out without waiting for the child's pipes to close. + * `spawn` is injectable only for trusted tests. + */ +export function hostAdapters({ maxOutputBytes = 1024 * 1024, maxTextBytes = 1024 * 1024, spawn = spawnProcess } = {}) { + const run = (command, argv, { env, deadlineMs }) => new Promise((resolve) => { + let size = 0; + let truncated = false; + let timedOut = false; + const chunks = []; + let child; + try { + child = spawn(command, argv, { env, shell: false, stdio: ["ignore", "pipe", "ignore"], windowsHide: true }); + } catch { + resolve({ code: null, signal: null, timedOut: false, truncated: false, stdout: "" }); + return; + } + let settled = false; + const settle = (code, signal) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ code, signal, timedOut, truncated, stdout: Buffer.concat(chunks).toString("utf8") }); + }; + // A descendant can keep stdout open after the kill, so `close` may never come: + // settle at the deadline and release the pipes instead of waiting for it. + const timer = setTimeout(() => { + timedOut = true; + child.kill("SIGKILL"); + for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy(); + settle(null, "SIGKILL"); + }, deadlineMs); + child.stdout.on("data", (chunk) => { + const room = maxOutputBytes - size; + if (chunk.length > room) truncated = true; + if (room > 0) { + chunks.push(chunk.subarray(0, room)); + size += Math.min(room, chunk.length); + } + }); + child.once("error", () => settle(null, null)); + child.once("close", settle); + }); + const info = (path, follow = true) => (follow ? stat(path) : lstat(path)).catch((error) => error); + const fs = { + isFile: async (path) => (await stat(path).catch(() => null))?.isFile() === true, + isDirectory: async (path) => (await stat(path).catch(() => null))?.isDirectory() === true, + // Only a missing entry is absent; an unreadable one exists (and is then not a writable directory). + exists: async (path) => { + const result = await info(path, false); + return !(result instanceof Error) || !["ENOENT", "ENOTDIR"].includes(result.code); + }, + realpath: (path) => realpath(path), + readText: async (path) => { + const result = await stat(path); + if (!result.isFile() || result.size > maxTextBytes) throw new Error("Unreadable probe file"); + return readFile(path, "utf8"); + }, + writable: (path) => access(path, constants.W_OK).then(() => true, () => false), + }; + return { run, fs }; +} + +/** + * createProbes({ platform, env, run, fs, home?, verifyGentleAi? }) -> the eight + * named collectInventory probes. `env` is the wizard's environment (bootstrap + * tools first on PATH); `run` and `fs` follow hostAdapters. Each probe returns + * the shape collectInventory documents, or { available: null } when unknown or + * failed; errors are never thrown or retained. + */ +export function createProbes({ platform, env, run, fs, home, verifyGentleAi = packageNativeGentleAi }) { + const path = pathOf(platform); + const user = userEnvironment({ platform, env }); + const globalBin = pnpmGlobalBin({ platform, env: user }); + // pnpm 11 global commands need `$PNPM_HOME/bin` on PATH, as in the runner. + const child = globalBin ? childEnvironment(env, platform, globalBin) : env; + const userChild = globalBin ? childEnvironment(user, platform, globalBin) : user; + const userHome = home ?? (platform === "win32" ? env.USERPROFILE : env.HOME); + const output = async (command, argv, runEnv, deadlineMs) => { + const result = await run(command, argv, { env: runEnv, deadlineMs }); + return succeeded(result) && result.truncated !== true ? String(result.stdout ?? "").trim() : null; + }; + const persistentOn = async (name) => (await lookPath(name, user, platform, fs)) !== null; + + let listing; + /** pnpm-global packages by name from one `list -g` call; null entries are ambiguous. */ + const globalPackages = () => (listing ??= (async () => { + const pnpm = globalBin ? await pnpmInvocation(env, platform, fs) : null; + if (!pnpm) return null; + const stdout = await output(pnpm.command, [...pnpm.prefix, "list", "-g", "--depth", "0", "--json"], child, deadlines.list); + if (stdout === null) return null; + const projects = JSON.parse(stdout); + if (!Array.isArray(projects)) return null; + const packages = new Map(); + for (const project of projects) { + if (!plainObject(project)) return null; + for (const field of ["dependencies", "devDependencies", "optionalDependencies"]) { + const dependencies = project[field]; + if (dependencies === undefined) continue; + if (!plainObject(dependencies)) return null; + for (const [name, entry] of Object.entries(dependencies)) { + packages.set(name, packages.has(name) || !plainObject(entry) ? null : entry); + } + } + } + return packages; + })()); + /** A package that is not pnpm-global but whose command resolves elsewhere is unknown, never absent. */ + const globalPackage = async (name, command) => { + const packages = await globalPackages(); + if (!packages) return { state: "unknown" }; + if (!packages.has(name)) return { state: (await persistentOn(command)) ? "unknown" : "absent" }; + const entry = packages.get(name); + const version = exactVersion(entry?.version, STABLE); + return entry && version ? { state: "present", entry, version } : { state: "unknown" }; + }; + const shellBin = () => path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"); + + const probes = { + async node() { + const persistent = await lookPath("node", user, platform, fs); + const node = persistent ?? await lookPath("node", env, platform, fs); + if (!node) return absent(); + if (!spawnable(node, platform)) return unknown(); + const version = exactVersion(await output(node, ["--version"], env, deadlines.version), NODE_VERSION); + if (!version) return unknown(); + // A genuine npm must resolve without bootstrap tools, as in a fresh terminal. + let npm = null; + try { + npm = typeof (await genuineNpm(userChild, platform, node, { run, fs }, globalBin)) === "object"; + } catch { + npm = null; + } + return { available: true, version, usable: true, persistent: persistent !== null, npm }; + }, + async pnpm() { + const pnpm = await pnpmInvocation(env, platform, fs); + // Without the direct handoff a Windows .cmd shim cannot run with shell:false. + if (!pnpm) return (await lookPath("pnpm", env, platform, fs)) ? unknown() : absent(); + const version = exactVersion(await output(pnpm.command, [...pnpm.prefix, "--version"], child, deadlines.version), STABLE); + if (!version) return unknown(); + // pnpm checks its Node engine at startup; the runner's argv is verified for pnpm 11 only. + const compatible = Number(version.split(".")[0]) === PNPM_MAJOR && atLeast(version, requirements.pnpm); + return { available: true, version, usable: true, compatible, persistent: await persistentOn("pnpm") }; + }, + async pi() { + const pi = await globalPackage(PI_PACKAGE, "pi"); + if (pi.state !== "present") return pi.state === "absent" ? absent() : unknown(); + return { available: true, version: pi.version, usable: true }; + }, + async shell() { + const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell"); + if (shell.state !== "present") return shell.state === "absent" ? absent() : unknown(); + return { available: true, version: shell.version, usable: await fs.isFile(shellBin()), global: true }; + }, + async gentleAi() { + const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell"); + if (shell.state === "absent") return absent(); + // Only this package version's native pin is the requirement being checked. + if (shell.state !== "present" || shell.version !== requirements.shell) return unknown(); + if (typeof shell.entry.path !== "string" || !path.isAbsolute(shell.entry.path)) return unknown(); + const [root, pnpmHome] = [await fs.realpath(shell.entry.path), await fs.realpath(globalBin.pnpmHome)]; + if (!contains(pnpmHome, root, platform)) return unknown(); + if (!(await fs.isFile(gentleAiBinaryPath(root, platform)))) return absent(); + const verdict = await verifyGentleAi({ packageRoot: root, platform, env, home: userHome }); + return verdict?.ok === true ? { available: true, version: requirements.gentleAi, usable: true, compatible: true } : unknown(); + }, + async go() { + const go = await lookPath("go", user, platform, fs); + if (!go) return absent(); + if (!spawnable(go, platform)) return unknown(); + const version = exactVersion(await output(go, ["version"], user, deadlines.version), GO_VERSION); + return version ? { available: true, version, usable: true } : unknown(); + }, + async globalBin() { + if (!globalBin) return unknown(); + // Nearest existing ancestor (or the directory itself); nothing is created. + let current = globalBin.path; + while (!(await fs.exists(current))) { + const parent = path.dirname(current); + if (parent === current) return unknown(); + current = parent; + } + const writable = (await fs.isDirectory(current)) && (await fs.writable(current)); + return { available: true, path: globalBin.path, writable, onPath: globalBin.onPath }; + }, + async setup() { + // Setup readiness of an existing Shell has no read-only evidence yet. + const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell"); + return shell.state === "absent" ? false : unknown(); + }, + }; + return Object.fromEntries(Object.entries(probes).map(([name, probe]) => [name, async () => { + try { + return await probe(); + } catch { + // Do not retain probe errors: they can contain private paths or credentials. + return unknown(); + } + }])); +} diff --git a/scripts/installer-runner.mjs b/scripts/installer-runner.mjs index aa30229dc..4bcaa9aac 100644 --- a/scripts/installer-runner.mjs +++ b/scripts/installer-runner.mjs @@ -5,16 +5,18 @@ import { gentleAiDevBinaryOverrideConfigured, resolveGentleAiBinary, } from "../runtime/gentle-ai-binary.mjs"; -import { pnpmGlobalBin, requirements } from "./installer-preflight.mjs"; +import { persistencePins, pnpmGlobalBin, requirements } from "./installer-preflight.mjs"; // Standard installation runner: one fixed, consented global pnpm installation // of Pi plus gentle-pi, then the public `gentle-shell setup`. Every adapter is // injected by trusted local code; requests carry no commands, URLs, roots or env. +// When Node is bootstrap-only or npm is not genuine, fixed steps first persist +// node, npm and pnpm under PNPM_HOME through pnpm itself. /** Pi version installed next to gentle-pi (optional peer, resolved in one add). */ export const PI_INSTALL_VERSION = "1.0.0"; -const PI_PACKAGE = "@earendil-works/pi-coding-agent"; -const SHELL_PACKAGE = "gentle-pi"; +export const PI_PACKAGE = "@earendil-works/pi-coding-agent"; +export const SHELL_PACKAGE = "gentle-pi"; const SECOND = 1000; const MINUTE = 60 * SECOND; @@ -27,6 +29,11 @@ const knownActions = Object.freeze({ "acquire-pnpm": { kind: "acquire", target: "pnpm", version: requirements.pnpm }, "verify-pnpm": { kind: "verify", target: "pnpm" }, "setup-global-bin": { kind: "setup", target: "globalBin" }, + "persist-node": { kind: "persist-runtime", target: "node", version: persistencePins.node }, + "persist-package-managers": { kind: "install-global", target: "package-managers" }, + "configure-npm-prefix": { kind: "configure", target: "npm-prefix" }, + "persist-npm": { kind: "install-global", target: "npm", version: persistencePins.npm }, + "persist-pnpm": { kind: "install-global", target: "pnpm", version: persistencePins.pnpm }, "acquire-go": { kind: "acquire", target: "go", version: requirements.go }, "verify-go": { kind: "verify", target: "go" }, "install-pi": { kind: "install-global", target: "pi", version: requirements.pi }, @@ -38,6 +45,15 @@ const knownActions = Object.freeze({ // T4 implements only the clean-stack path: both global packages are missing. const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"]; const optionalActions = ["setup-global-bin"]; +// Runtime persistence is one of these exact sets planPreflight emits, or nothing: +// bootstrap-only Node, or a persistent Node missing npm and/or pnpm (one add -g). +const persistenceVariants = Object.freeze([ + ["persist-node", "persist-package-managers", "configure-npm-prefix"], + ["persist-package-managers"], + ["persist-npm"], + ["persist-pnpm"], +]); +const persistenceActions = [...new Set(persistenceVariants.flat())]; function stable(version) { const match = typeof version === "string" && /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version); @@ -80,8 +96,11 @@ function planGate(plan, platform) { const ids = plan.actions.map((action) => action.id); // gentle-pi's postinstall may build Gentle AI from source on Windows; T4 never acquires Go. if (platform === "win32" && (ids.includes("acquire-go") || plan.tools.go?.status !== "reusable")) return "go-required"; + const persisting = persistenceActions.filter((id) => ids.includes(id)); + if (persisting.length > 0 && !persistenceVariants.some((variant) => + variant.length === persisting.length && variant.every((id) => persisting.includes(id)))) return "unsupported-plan"; const supported = requiredActions.every((id) => ids.includes(id)) && - ids.every((id) => requiredActions.includes(id) || optionalActions.includes(id)); + ids.every((id) => requiredActions.includes(id) || optionalActions.includes(id) || persistenceActions.includes(id)); return supported ? null : "unsupported-plan"; } @@ -94,15 +113,21 @@ function envValue(env, name, platform) { } /** Child env: the user's env plus PNPM_HOME and `$PNPM_HOME/bin` first on PATH. */ -function childEnvironment(env, platform, globalBin) { +export function childEnvironment(env, platform, globalBin) { const path = platform === "win32" ? win32 : posix; const key = pathKeyOf(env, platform); const rest = String(env[key] ?? "").split(path.delimiter).filter((entry) => entry.length > 0); return { ...env, PNPM_HOME: globalBin.pnpmHome, [key]: [globalBin.path, ...rest].join(path.delimiter) }; } +/** gentle-shell setup gets no bootstrap handoff keys (case-insensitive on Windows). */ +function setupEnvironment(child, platform) { + const bootstrap = platform === "win32" ? /^GENTLE_(BOOTSTRAP|INSTALL)_/i : /^GENTLE_(BOOTSTRAP|INSTALL)_/; + return Object.fromEntries(Object.entries(child).filter(([key]) => !bootstrap.test(key))); +} + /** pnpm comes from the bootstrap handoff, or a PATH executable on POSIX only. */ -async function pnpmInvocation(env, platform, fs) { +export async function pnpmInvocation(env, platform, fs) { const path = platform === "win32" ? win32 : posix; const node = env.GENTLE_INSTALL_PNPM_NODE; const entry = env.GENTLE_INSTALL_PNPM_ENTRY; @@ -119,61 +144,151 @@ async function pnpmInvocation(env, platform, fs) { return null; } -/** First `npm` the way Go's exec.LookPath (used by Gentle AI) finds it: each +/** First `name` the way Go's exec.LookPath (used by Gentle AI) finds it: each * absolute PATH directory in order and, on Windows, every PATHEXT extension in * PATHEXT order (lowercased, dot-prefixed, default .com/.exe/.bat/.cmd). */ -async function firstNpm(child, platform, fs) { +export async function lookPath(name, env, platform, fs) { const path = platform === "win32" ? win32 : posix; const extensions = platform === "win32" - ? String(envValue(child, "PATHEXT", platform) || ".com;.exe;.bat;.cmd").toLowerCase().split(";") + ? String(envValue(env, "PATHEXT", platform) || ".com;.exe;.bat;.cmd").toLowerCase().split(";") .filter((extension) => extension.length > 0).map((extension) => extension.startsWith(".") ? extension : `.${extension}`) : [""]; - for (const directory of String(child[pathKeyOf(child, platform)] ?? "").split(path.delimiter)) { + for (const directory of String(env[pathKeyOf(env, platform)] ?? "").split(path.delimiter)) { if (!path.isAbsolute(directory)) continue; for (const extension of extensions) { - const candidate = path.join(directory, `npm${extension}`); + const candidate = path.join(directory, `${name}${extension}`); if (await fs.isFile(candidate)) return candidate; } } return null; } +/** A Windows `.cmd`/`.bat` shim cannot run with shell:false; executables can. */ +export function spawnable(file, platform) { + return platform !== "win32" || [".exe", ".com"].includes(win32.extname(file).toLowerCase()); +} + +/** `.../node_modules//bin/`: a package's own bin entry. */ +function entryShape(entry, path, name, file) { + const packageDir = path.dirname(path.dirname(entry)); + return path.basename(entry) === file && path.basename(path.dirname(entry)) === "bin" && + path.basename(packageDir) === name && path.basename(path.dirname(packageDir)) === "node_modules"; +} + +function escapeRegExp(text) { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** The single `node_modules//bin/` target a pnpm shim names, absolute + * or relative to the shim directory (`$basedir/` on POSIX, `%~dp0\` or `%dp0%\` + * on Windows). + */ +function shimTarget(text, shim, platform, name, file) { + const path = platform === "win32" ? win32 : posix; + const tail = `node_modules${path.sep}${name}${path.sep}bin${path.sep}${file}`; + const pattern = platform === "win32" + ? new RegExp(`"(%~?dp0%?\\\\)?([^"%]*\\\\${escapeRegExp(tail)})"`, "gi") + : new RegExp(`"(\\$basedir\\/)?([^"$]*\\/${escapeRegExp(tail)})"`, "g"); + const targets = new Set(); + for (const [, relative, target] of String(text).matchAll(pattern)) { + const resolved = relative ? path.join(path.dirname(shim), target) : target; + if (!path.isAbsolute(resolved)) return null; + targets.add(path.normalize(resolved)); + } + return targets.size === 1 ? [...targets][0] : null; +} + +/** The package entry a pnpm global shim in `$PNPM_HOME/bin` runs: a POSIX + * symlink to it, or the single target named in the shim text. Either way its + * realpath must be a `` bin entry inside PNPM_HOME. Returns it or null. + */ +async function globalShimEntry(shim, name, file, platform, globalBin, fs) { + const path = platform === "win32" ? win32 : posix; + let entry = platform === "win32" ? null : await fs.realpath(shim); + if (entry === null || !entryShape(entry, path, name, file)) { + const target = shimTarget(await fs.readText(shim), shim, platform, name, file); + if (!target) return null; + entry = await fs.realpath(target); + } + if (!entryShape(entry, path, name, file)) return null; + return contains(await fs.realpath(globalBin.pnpmHome), entry, platform) ? entry : null; +} + +/** The entry's package.json names `name` at `version` (any stable version when + * null), and `node --version` prints that version. + */ +async function runsAsPackage(entry, name, version, nodePath, child, platform, adapters) { + const path = platform === "win32" ? win32 : posix; + if (!(await adapters.fs.isFile(entry))) return false; + const metadata = JSON.parse(await adapters.fs.readText(path.join(path.dirname(path.dirname(entry)), "package.json"))); + if (metadata?.name !== name || !stable(metadata.version) || (version !== null && metadata.version !== version)) return false; + const result = await adapters.run(nodePath, [entry, "--version"], { env: child, deadlineMs: deadlines.probe }); + return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version; +} + +function inGlobalBin(file, platform, globalBin) { + const path = platform === "win32" ? win32 : posix; + return globalBin !== null && samePath(path.dirname(file), globalBin.path, platform); +} + /** Genuine npm: the first resolved npm is the npm package's own CLI, which then runs. - * Returns true, "npm-shadowed" (Windows: an earlier non-.cmd npm wins) or false. + * Accepted: a Node-bundled npm (POSIX symlink target or Windows `npm.cmd` with + * `node_modules/npm` beside it), or, for any npm in `$PNPM_HOME/bin` (shim or + * symlink), pnpm's global npm resolving inside PNPM_HOME at the persistence pin. + * Returns { cli }, "npm-shadowed" (Windows: an earlier non-.cmd npm wins) or false. */ -async function genuineNpm(child, platform, nodePath, adapters) { +export async function genuineNpm(child, platform, nodePath, adapters, globalBin = null) { const path = platform === "win32" ? win32 : posix; - const first = await firstNpm(child, platform, adapters.fs); + const { fs } = adapters; + const first = await lookPath("npm", child, platform, fs); if (!first) return false; + if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return "npm-shadowed"; let cli; - if (platform === "win32") { - if (path.extname(first).toLowerCase() !== ".cmd") return "npm-shadowed"; + let pinned = null; + if (inGlobalBin(first, platform, globalBin)) { + cli = await globalShimEntry(first, "npm", "npm-cli.js", platform, globalBin, fs); + if (!cli) return false; + pinned = persistencePins.npm; + } else if (platform === "win32") { cli = path.join(path.dirname(first), "node_modules", "npm", "bin", "npm-cli.js"); } else { - cli = await adapters.fs.realpath(first); + cli = await fs.realpath(first); } - if (!(await adapters.fs.isFile(cli))) return false; - const packageDir = path.dirname(path.dirname(cli)); - if (path.basename(cli) !== "npm-cli.js" || path.basename(packageDir) !== "npm" || - path.basename(path.dirname(packageDir)) !== "node_modules") return false; - const metadata = JSON.parse(await adapters.fs.readText(path.join(packageDir, "package.json"))); - if (metadata?.name !== "npm" || !stable(metadata.version)) return false; - const result = await adapters.run(nodePath, [cli, "--version"], { env: child, deadlineMs: deadlines.probe }); - return succeeded(result) && String(result.stdout ?? "").trim() === metadata.version; + if (!entryShape(cli, path, "npm", "npm-cli.js")) return false; + return (await runsAsPackage(cli, "npm", pinned, nodePath, child, platform, adapters)) ? { cli } : false; +} + +/** After adding pnpm, the first pnpm in the child env is pnpm's global shim in + * `$PNPM_HOME/bin` for pnpm at the persistence pin, which then runs. + */ +async function persistentPnpm(child, platform, nodePath, globalBin, adapters) { + const path = platform === "win32" ? win32 : posix; + const first = await lookPath("pnpm", child, platform, adapters.fs); + if (!first || !inGlobalBin(first, platform, globalBin)) return false; + if (platform === "win32" && path.extname(first).toLowerCase() !== ".cmd") return false; + const entry = await globalShimEntry(first, "pnpm", "pnpm.mjs", platform, globalBin, adapters.fs); + return entry !== null && runsAsPackage(entry, "pnpm", persistencePins.pnpm, nodePath, child, platform, adapters); } -function succeeded(result) { +export function succeeded(result) { return result?.code === 0 && !result.signal && result.timedOut !== true; } -function samePath(left, right, platform) { +export function samePath(left, right, platform) { const path = platform === "win32" ? win32 : posix; const normal = (value) => path.normalize(value).replace(/(.)[\\/]+$/, "$1"); const [a, b] = [normal(left), normal(right)]; return platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b; } +/** True when `child` is strictly inside `parent` (case-insensitive on Windows). */ +export function contains(parent, child, platform) { + const path = platform === "win32" ? win32 : posix; + const inside = path.relative(parent, child); + return inside.length > 0 && !inside.startsWith("..") && !path.isAbsolute(inside); +} + /** Pre-install `list -g --json`: true when neither package is listed in any * project, "existing-stack" when one is, false when the shape is unknown. */ @@ -192,20 +307,72 @@ function noExistingStack(stdout) { return true; } -/** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME. */ +/** Installed gentle-pi root from `list -g --json`, confined under PNPM_HOME. + * Exactly one listed project may own gentle-pi, and Pi must resolve beside it; + * other projects (such as the persisted npm and pnpm) are ignored. + */ async function verifiedPackageRoot(stdout, pnpmHome, platform, fs) { const path = platform === "win32" ? win32 : posix; const projects = JSON.parse(stdout); - const dependencies = Array.isArray(projects) && projects.length === 1 ? projects[0]?.dependencies : null; + if (!Array.isArray(projects)) return null; + const owners = projects.filter((project) => plainObject(project?.dependencies) && Object.hasOwn(project.dependencies, SHELL_PACKAGE)); + const dependencies = owners.length === 1 ? owners[0].dependencies : null; if (!plainObject(dependencies)) return null; const pi = dependencies[PI_PACKAGE]; const shell = dependencies[SHELL_PACKAGE]; if (pi?.version !== PI_INSTALL_VERSION || shell?.version !== requirements.shell) return null; if (typeof shell.path !== "string" || !path.isAbsolute(shell.path)) return null; const [root, home] = [await fs.realpath(shell.path), await fs.realpath(pnpmHome)]; - const inside = path.relative(home, root); - if (!inside || inside.startsWith("..") || path.isAbsolute(inside)) return null; - return root; + return contains(home, root, platform) ? root : null; +} + +/** After persistence, node and npm must resolve from `$PNPM_HOME/bin` in the + * child env and that node must report the pinned version. Returns its path. + */ +async function persistentRuntime(child, platform, globalBin, adapters) { + const path = platform === "win32" ? win32 : posix; + const node = await lookPath("node", child, platform, adapters.fs); + const npm = await lookPath("npm", child, platform, adapters.fs); + const fromBin = (file) => file !== null && samePath(path.dirname(file), globalBin.path, platform); + if (!fromBin(node) || !fromBin(npm) || !spawnable(node, platform)) return null; + const result = await adapters.run(node, ["--version"], { env: child, deadlineMs: deadlines.probe }); + return succeeded(result) && String(result.stdout ?? "").trim() === `v${persistencePins.node}` ? node : null; +} + +/** npm's prefix setting from the environment, which npm reads case-insensitively. */ +function explicitNpmPrefix(env, platform) { + return Object.keys(env).some((key) => key.toLowerCase() === "npm_config_prefix" || + (platform === "win32" ? key.toUpperCase() === "PREFIX" : key === "PREFIX")); +} + +/** With a pnpm-managed node, npm's default prefix is derived from a path inside + * pnpm's store, so `npm install -g` would write there. Only then, and only when + * the user has no explicit prefix (otherwise npm would report it), set the + * user-level prefix to PNPM_HOME. The store is the one `pnpm store path` + * reports; when it is unknown the step fails. Returns "configured", + * "unchanged" or null. + */ +async function configureNpmPrefix({ node, cli, child, platform, globalBin, adapters, storePath }) { + const path = platform === "win32" ? win32 : posix; + const npm = (args) => adapters.run(node, [cli, ...args], { env: child, deadlineMs: deadlines.probe }); + const effective = async () => { + const result = await npm(["config", "get", "prefix"]); + const value = String(result.stdout ?? "").trim(); + return succeeded(result) && path.isAbsolute(value) ? value : null; + }; + const current = await effective(); + if (current === null) return null; + if (explicitNpmPrefix(child, platform)) return "unchanged"; + const reported = await storePath(); + const value = String(reported?.stdout ?? "").trim(); + if (!succeeded(reported) || !path.isAbsolute(value) || /[\r\n]/.test(value)) return null; + const store = await adapters.fs.realpath(value).catch(() => null); + if (store === null) return null; + const resolved = await adapters.fs.realpath(current).catch(() => current); + if (!contains(store, resolved, platform)) return "unchanged"; + if (!succeeded(await npm(["config", "set", "prefix", globalBin.pnpmHome, "--location=user"]))) return null; + const after = await effective(); + return after !== null && samePath(after, globalBin.pnpmHome, platform) ? "configured" : null; } /** Default integrity adapter: the package-local resolver, never a dev override. @@ -227,7 +394,8 @@ export async function packageNativeGentleAi({ packageRoot, platform, env, home } * runStandardInstall({ plan, consent }, adapters) -> { outcome, ... } * Outcomes: blocked (nothing installed), failed (stopped after `completed`), * terminal-action-required (installed; user PATH persisted, open a new - * terminal) or ready. Adapters: platform, nodePath, env (user env), home?, + * terminal) or ready. When the plan persists the Node runtime, successful + * outcomes also report npmPrefix: "configured" or "unchanged". Adapters: platform, nodePath, env (user env), home?, * run(command, argv, { env, deadlineMs }) with shell:false semantics returning * { code, signal, timedOut, stdout }, fs { isFile, realpath, readText }, * verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }). @@ -256,11 +424,24 @@ export async function runStandardInstall(request, adapters) { const home = adapters.home ?? (platform === "win32" ? env.USERPROFILE : env.HOME); const list = () => runPnpm(["list", "-g", "--depth", "0", "--json"], deadlines.probe); + const ids = request.plan.actions.map((action) => action.id); + // Fixed variants (planGate): runtime + both managers, or one add of the missing ones. + const persistRuntime = ids.includes("persist-node"); + const addNpm = ids.includes("persist-package-managers") || ids.includes("persist-npm"); + const addPnpm = ids.includes("persist-package-managers") || ids.includes("persist-pnpm"); + let npmCli = null; + const npmCheck = async () => { + const verdict = await genuineNpm(child, platform, adapters.nodePath, adapters, globalBin); + if (typeof verdict !== "object" || verdict === null) return verdict; + npmCli = verdict.cli; + return true; + }; // Pre-install gates: a false check or adapter exception blocks before mutation; // a check may return a more specific blocked reason instead of false. + // An npm that is about to be persisted is checked only after it has been added. const checks = [ - ["check-npm", "npm-unavailable", () => genuineNpm(child, platform, adapters.nodePath, adapters)], + ...(addNpm ? [] : [["check-npm", "npm-unavailable", npmCheck]]), ["check-global-bin", "global-bin-mismatch", async () => { const result = await runPnpm(["bin", "-g"], deadlines.probe); const reported = String(result.stdout ?? "").trim(); @@ -281,7 +462,28 @@ export async function runStandardInstall(request, adapters) { // Mutating and post-install steps: a false result or exception is a failure. let packageRoot = null; + let persistentNode = null; + let npmPrefix = null; + const persistence = [ + ["persist-node", async () => succeeded(await runPnpm(["runtime", "set", "node", persistencePins.node, "-g"], deadlines.install))], + // Fixed versions only: never a blanket build approval. + ["persist-package-managers", async () => succeeded(await runPnpm(["add", "-g", `npm@${persistencePins.npm}`, + `pnpm@${persistencePins.pnpm}`], deadlines.install))], + ["verify-persistent-runtime", async () => (persistentNode = await persistentRuntime(child, platform, globalBin, adapters)) !== null], + ["check-npm", async () => (await npmCheck()) === true], + ["configure-npm-prefix", async () => (npmPrefix = await configureNpmPrefix({ node: persistentNode, cli: npmCli, child, + platform, globalBin, adapters, storePath: () => runPnpm(["store", "path"], deadlines.probe) })) !== null], + ]; + // A persistent Node is never replaced: one add of only the missing managers. + const addOnly = ids.find((id) => ["persist-package-managers", "persist-npm", "persist-pnpm"].includes(id)); + const packageManagers = [ + [addOnly, async () => succeeded(await runPnpm(["add", "-g", ...(addNpm ? [`npm@${persistencePins.npm}`] : []), + ...(addPnpm ? [`pnpm@${persistencePins.pnpm}`] : [])], deadlines.install))], + ...(addNpm ? [["check-npm", async () => (await npmCheck()) === true]] : []), + ...(addPnpm ? [["verify-persistent-pnpm", () => persistentPnpm(child, platform, adapters.nodePath, globalBin, adapters)]] : []), + ]; const steps = [ + ...(persistRuntime ? persistence : addOnly ? packageManagers : []), ["install-global", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`, `${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], deadlines.install))], ["verify-global-list", async () => { @@ -293,7 +495,7 @@ export async function runStandardInstall(request, adapters) { ["verify-shell-bin", () => adapters.fs.isFile(path.join(globalBin.path, platform === "win32" ? "gentle-shell.cmd" : "gentle-shell"))], ["verify-gentle-ai", async () => (await adapters.verifyGentleAi({ packageRoot, platform, env, home }))?.ok === true], ["shell-setup", async () => succeeded(await adapters.run(adapters.nodePath, - [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"], { env: child, deadlineMs: deadlines.setup }))], + [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"], { env: setupEnvironment(child, platform), deadlineMs: deadlines.setup }))], ]; // A child PATH never proves a fresh terminal; persist it with pnpm's own setup. // globalBin.onPath was computed from the user's own PATH, not the child env. @@ -307,6 +509,7 @@ export async function runStandardInstall(request, adapters) { completed.push(step); log({ step, status: "done" }); } - if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed }; - return { outcome: "ready", completed }; + const prefix = persistRuntime ? { npmPrefix } : {}; + if (persistPath) return { outcome: "terminal-action-required", action: "open-new-terminal", completed, ...prefix }; + return { outcome: "ready", completed, ...prefix }; } diff --git a/tests/installer-preflight.test.ts b/tests/installer-preflight.test.ts index 9af46bcb9..bb6fbc0f9 100644 --- a/tests/installer-preflight.test.ts +++ b/tests/installer-preflight.test.ts @@ -125,3 +125,36 @@ test("collector invokes only injected named probes, catches failures without log assert.equal(JSON.stringify(inventory).includes("private diagnostic"), false); assert.ok(planPreflight(inventory).blockers.length > 0); }); + +test("runtime persistence intents persist only what is missing", async () => { + const { persistencePins } = await import("../scripts/installer-preflight.mjs"); + const full = ["persist-node", "persist-package-managers", "configure-npm-prefix"]; + const persistence = [...full, "persist-npm", "persist-pnpm"]; + const stack = (node: object, pnpmPersistent?: boolean) => ({ ...clean(), node, + pnpm: { ...tool("11.1.1"), compatible: true, ...(pnpmPersistent === undefined ? {} : { persistent: pnpmPersistent }) }, + globalBin: { available: true, path: "/disposable/bin", writable: true, onPath: true } }); + const rest = ["install-pi", "install-shell", "setup-shell", "verify-readiness"]; + // Bootstrap-only Node: the full group, whatever npm and pnpm report. + for (const npm of [true, false]) { + for (const pnpm of [true, false, undefined]) { + assert.deepEqual(ids(stack({ ...tool("24.18.0"), persistent: false, npm }, pnpm)), [...full, ...rest]); + } + } + assert.deepEqual(planPreflight(stack({ ...tool("24.18.0"), persistent: false, npm: true })).actions[0], + { id: "persist-node", kind: "persist-runtime", target: "node", version: persistencePins.node }); + // Persistent Node: never runtime persistence; one add for the missing npm and/or pnpm. + const node = (npm: boolean | null) => ({ ...tool("24.18.0"), persistent: true, npm }); + assert.deepEqual(planPreflight(stack(node(false), true)).actions[0], + { id: "persist-npm", kind: "install-global", target: "npm", version: persistencePins.npm }); + assert.deepEqual(planPreflight(stack(node(true), false)).actions[0], + { id: "persist-pnpm", kind: "install-global", target: "pnpm", version: persistencePins.pnpm }); + assert.deepEqual(ids(stack(node(false), false)), ["persist-package-managers", ...rest]); + for (const [npm, pnpm] of [[true, true], [true, undefined], [null, true]] as const) { + assert.deepEqual(ids(stack(node(npm), pnpm)), rest); + } + assert.equal(ids(stack(tool("24.18.0"))).some((id) => persistence.includes(id)), false); + assert.deepEqual(ids(stack({ ...tool("20.0.0"), persistent: false, npm: false })), []); + const unreachable = { ...stack({ ...tool("24.18.0"), persistent: false, npm: false }), + globalBin: { available: true, path: "/disposable/bin", writable: true, onPath: false } }; + assert.deepEqual(ids(unreachable).slice(0, 4), ["setup-global-bin", ...full]); +}); diff --git a/tests/installer-probes.test.ts b/tests/installer-probes.test.ts new file mode 100644 index 000000000..956c2234c --- /dev/null +++ b/tests/installer-probes.test.ts @@ -0,0 +1,335 @@ +import assert from "node:assert/strict"; +import { chmodSync, mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs"; +import { collectInventory, planPreflight, requirements } from "../scripts/installer-preflight.mjs"; +import { bootstrapRoots, createProbes, hostAdapters, userEnvironment } from "../scripts/installer-probes.mjs"; + +const HOME = "/home/u"; +const PNPM_HOME = "/home/u/.local/share/pnpm"; +const BIN = `${PNPM_HOME}/bin`; +const TOOLS = "/home/u/.gentle-shell-bootstrap-tools.Ab12Cd34"; +const TOOLS_NODE = `${TOOLS}/node/bin/node`; +const TOOLS_PNPM = `${TOOLS}/pnpm/bin/pnpm`; +const USER_NODE = "/usr/bin/node"; +const USER_NPM_CLI = "/usr/lib/node_modules/npm/bin/npm-cli.js"; +const TOOLS_NPM_CLI = `${TOOLS}/node/lib/node_modules/npm/bin/npm-cli.js`; +const SHELL_ROOT = `${PNPM_HOME}/global/v11/def/node_modules/gentle-pi`; +const LIST = "list -g --depth 0 --json"; +const npmPackage = JSON.stringify({ name: "npm", version: "11.19.0" }); + +type Result = { code: number | null; signal?: string | null; timedOut?: boolean; truncated?: boolean; stdout?: string }; +type Call = { command: string; args: string[]; env: Record; deadlineMs: number }; + +// A bootstrap-acquired Node and pnpm in the temporary tools directory, ahead of the user's PATH. +const bootstrapEnv = { HOME, PATH: `${TOOLS}/node/bin:${TOOLS}/pnpm/bin:/usr/bin:/bin` }; + +function probes({ env = bootstrapEnv as Record, files = [] as string[], dirs = [HOME] as string[], + writable = [HOME] as string[], others = [] as string[], realpaths = {} as Record, + texts = {} as Record, results = {} as Record Result)>, + integrity = { ok: true } as object, platform = "linux" } = {}) { + const calls: Call[] = []; + const unexpected: string[] = []; + const fileSet = new Set(files); + const dirSet = new Set(dirs); + const integrityCalls: object[] = []; + const instance = createProbes({ + platform, + env, + run: async (command: string, args: string[], options: { env: Record; deadlineMs: number }) => { + calls.push({ command, args, env: options.env, deadlineMs: options.deadlineMs }); + const key = [command, ...args].join(" "); + const entry = results[key]; + if (!entry) { + unexpected.push(key); + return { code: 127, signal: null, timedOut: false, stdout: "" }; + } + return { signal: null, timedOut: false, stdout: "", ...(typeof entry === "function" ? entry() : entry) }; + }, + fs: { + isFile: async (path: string) => fileSet.has(path), + isDirectory: async (path: string) => dirSet.has(path), + exists: async (path: string) => fileSet.has(path) || dirSet.has(path) || others.includes(path), + realpath: async (path: string) => { + if (path in realpaths) return realpaths[path]; + if (fileSet.has(path) || dirSet.has(path)) return path; + throw new Error(`ENOENT ${path}`); + }, + readText: async (path: string) => { + if (!(path in texts)) throw new Error(`ENOENT ${path}`); + return texts[path]; + }, + writable: async (path: string) => writable.includes(path), + }, + verifyGentleAi: async (request: object) => { + integrityCalls.push(request); + return integrity; + }, + }); + return { probes: instance, calls, unexpected, integrityCalls }; +} + +const bootstrapNode = { + files: [TOOLS_NODE, `${TOOLS}/node/bin/npm`, TOOLS_NPM_CLI, TOOLS_PNPM], + realpaths: { [`${TOOLS}/node/bin/npm`]: TOOLS_NPM_CLI }, + texts: { [`${TOOLS}/node/lib/node_modules/npm/package.json`]: npmPackage }, + results: { [`${TOOLS_NODE} --version`]: { code: 0, stdout: "v24.18.0\n" } } as Record, +}; +const userNode = { + files: [USER_NODE, "/usr/bin/npm", USER_NPM_CLI], + realpaths: { "/usr/bin/npm": USER_NPM_CLI }, + texts: { "/usr/lib/node_modules/npm/package.json": JSON.stringify({ name: "npm", version: "10.9.2" }) }, + results: { [`${USER_NODE} --version`]: { code: 0, stdout: "v24.18.0\n" }, + [`${USER_NODE} ${USER_NPM_CLI} --version`]: { code: 0, stdout: "10.9.2\n" } } as Record, +}; + +test("bootstrap tool roots come from GENTLE_BOOTSTRAP_TOOLS and PATH segments, and are removed from the user PATH", () => { + assert.deepEqual(bootstrapRoots({ platform: "linux", env: bootstrapEnv }), [TOOLS]); + assert.deepEqual(userEnvironment({ platform: "linux", env: bootstrapEnv }), { HOME, PATH: "/usr/bin:/bin" }); + const tools = "C:\\Users\\u\\AppData\\Local\\.gentle-shell-bootstrap-tools.1.2.3"; + const env = { GENTLE_BOOTSTRAP_TOOLS: tools, Path: `${tools}\\pnpm;C:\\Users\\U\\APPDATA\\Local\\.GENTLE-SHELL-BOOTSTRAP-TOOLS.9\\node;C:\\Windows` }; + assert.deepEqual(bootstrapRoots({ platform: "win32", env }), [tools, "C:\\Users\\U\\APPDATA\\Local\\.GENTLE-SHELL-BOOTSTRAP-TOOLS.9"]); + assert.equal(userEnvironment({ platform: "win32", env }).Path, "C:\\Windows"); + assert.deepEqual(bootstrapRoots({ platform: "linux", env: { PATH: "/usr/bin" } }), []); + assert.equal(userEnvironment({ platform: "linux", env: { PATH: `${TOOLS}/:/usr/bin` } }).PATH, "/usr/bin"); +}); + +test("node on the user's real PATH is persistent with genuine npm evidence", async () => { + const h = probes({ env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/bin` }, ...userNode }); + assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }); + assert.deepEqual(h.unexpected, []); + for (const call of h.calls) assert.ok(call.deadlineMs > 0); +}); + +test("a node only inside the bootstrap tools directory is bootstrap-only and its npm does not count", async () => { + const h = probes(bootstrapNode); + assert.deepEqual(await h.probes.node(), { available: true, version: "24.18.0", usable: true, persistent: false, npm: false }); + assert.deepEqual(h.unexpected, []); + assert.equal(h.calls.some((call) => call.args[0] === TOOLS_NPM_CLI), false); +}); + +test("node absent, unparseable, failing, timed out or truncated", async () => { + assert.deepEqual(await probes({ env: { HOME, PATH: "/usr/bin" } }).probes.node(), { available: false }); + for (const result of [{ code: 0, stdout: "v24\n" }, { code: 0, stdout: "v24.18.0-nightly\n" }, { code: 1, stdout: "v24.18.0" }, + { code: 0, timedOut: true, stdout: "v24.18.0" }, { code: null, signal: "SIGKILL" }, { code: 0, truncated: true, stdout: "v24.18.0" }]) { + const h = probes({ ...userNode, env: { HOME, PATH: "/usr/bin" }, results: { [`${USER_NODE} --version`]: result } }); + assert.deepEqual(await h.probes.node(), { available: null }); + } + // An adapter exception becomes the unknown shape and never leaks its message. + const instance = createProbes({ platform: "linux", env: { HOME, PATH: "/usr/bin" }, run: async () => { throw new Error("private /home/u/secret"); }, + fs: { isFile: async () => true, isDirectory: async () => false, exists: async () => true, realpath: async (p: string) => p, + readText: async () => "", writable: async () => false } }); + for (const name of ["node", "pnpm", "pi", "shell", "gentleAi", "go", "setup"] as const) { + assert.deepEqual(await instance[name](), { available: null }, name); + } +}); + +test("pnpm from the bootstrap is bootstrap-only; one on the user's PATH is persistent", async () => { + const results = { [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" } }; + const h = probes({ files: [TOOLS_PNPM], results }); + assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }); + assert.equal(h.calls[0].env.PNPM_HOME, PNPM_HOME); + assert.equal(h.calls[0].env.PATH.split(":")[0], BIN); + const persistent = probes({ env: { HOME, PATH: "/usr/bin" }, files: ["/usr/bin/pnpm"], + results: { "/usr/bin/pnpm --version": { code: 0, stdout: "11.4.0\n" } } }); + assert.deepEqual(await persistent.probes.pnpm(), { available: true, version: "11.4.0", usable: true, compatible: true, persistent: true }); +}); + +test("pnpm outside the verified major, unparseable, timed out or absent", async () => { + for (const [stdout, compatible] of [["10.30.0", false], ["12.0.0", false], ["11.0.9", false], ["11.1.1", true]] as const) { + const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} --version`]: { code: 0, stdout } } }); + assert.equal((await h.probes.pnpm()).compatible, compatible); + } + for (const result of [{ code: 0, stdout: "pnpm 11" }, { code: 0, timedOut: true, stdout: "11.1.1" }, { code: 2 }]) { + const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} --version`]: result } }); + assert.deepEqual(await h.probes.pnpm(), { available: null }); + } + assert.deepEqual(await probes({ env: { HOME, PATH: "/usr/bin" } }).probes.pnpm(), { available: false }); +}); + +test("Windows pnpm uses the direct bootstrap handoff; a .cmd shim alone is unknown", async () => { + const local = "C:\\Users\\u\\AppData\\Local"; + const node = `${local}\\.gentle-shell-bootstrap-tools.1\\node\\node.exe`; + const entry = `${local}\\.gentle-shell-bootstrap-tools.1\\pnpm\\package\\bin\\pnpm.mjs`; + const env = { LOCALAPPDATA: local, USERPROFILE: "C:\\Users\\u", Path: "C:\\Windows", GENTLE_INSTALL_PNPM_NODE: node, GENTLE_INSTALL_PNPM_ENTRY: entry }; + const h = probes({ platform: "win32", env, results: { [`${node} ${entry} --version`]: { code: 0, stdout: "11.1.1\r\n" } } }); + assert.deepEqual(await h.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }); + assert.equal(h.calls[0].env.Path.split(";")[0], `${local}\\pnpm\\bin`); + const shim = probes({ platform: "win32", env: { LOCALAPPDATA: local, Path: "C:\\Tools" }, files: ["C:\\Tools\\pnpm.cmd"] }); + assert.deepEqual(await shim.probes.pnpm(), { available: null }); + assert.deepEqual(shim.calls, []); +}); + +function listing(dependencies: object) { + return JSON.stringify([{ path: `${PNPM_HOME}/global/v11/def`, dependencies }]); +} +const pnpmVersion = { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: "[]" } }; + +test("clean global list: Pi, Shell and Gentle AI absent and setup needed, from one list call", async () => { + const h = probes({ files: [TOOLS_PNPM], results: pnpmVersion }); + assert.deepEqual(await h.probes.pi(), { available: false }); + assert.deepEqual(await h.probes.shell(), { available: false }); + assert.deepEqual(await h.probes.gentleAi(), { available: false }); + assert.equal(await h.probes.setup(), false); + assert.equal(h.calls.length, 1); + assert.equal(h.calls[0].env.PATH.split(":")[0], BIN); +}); + +test("pnpm-global Pi and Shell report versions; verified package-native Gentle AI is compatible", async () => { + const stdout = listing({ "@earendil-works/pi-coding-agent": { version: "1.0.0", path: `${PNPM_HOME}/global/v11/def/node_modules/pi` }, + "gentle-pi": { version: requirements.shell, path: SHELL_ROOT } }); + const files = [TOOLS_PNPM, `${BIN}/gentle-shell`, gentleAiBinaryPath(SHELL_ROOT, "linux")]; + const h = probes({ files, dirs: [HOME, PNPM_HOME, SHELL_ROOT], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } }); + assert.deepEqual(await h.probes.pi(), { available: true, version: "1.0.0", usable: true }); + assert.deepEqual(await h.probes.shell(), { available: true, version: requirements.shell, usable: true, global: true }); + assert.deepEqual(await h.probes.gentleAi(), { available: true, version: requirements.gentleAi, usable: true, compatible: true }); + assert.deepEqual(h.integrityCalls, [{ packageRoot: SHELL_ROOT, platform: "linux", env: bootstrapEnv, home: HOME }]); + assert.deepEqual(await h.probes.setup(), { available: null }); + const missingBin = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout } } }); + assert.equal((await missingBin.probes.shell()).usable, false); +}); + +test("Gentle AI is absent without its binary, unknown when unverified, unconfined or another Shell version", async () => { + const shell = (version: string, path = SHELL_ROOT) => ({ [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: listing({ "gentle-pi": { version, path } }) } }); + const binary = gentleAiBinaryPath(SHELL_ROOT, "linux"); + const dirs = [HOME, PNPM_HOME, SHELL_ROOT]; + assert.deepEqual(await probes({ files: [TOOLS_PNPM], dirs, results: shell(requirements.shell) }).probes.gentleAi(), { available: false }); + const unverified = probes({ files: [TOOLS_PNPM, binary], dirs, results: shell(requirements.shell), integrity: { ok: false } }); + assert.deepEqual(await unverified.probes.gentleAi(), { available: null }); + assert.deepEqual(await probes({ files: [TOOLS_PNPM, binary], dirs, results: shell("3.0.0") }).probes.gentleAi(), { available: null }); + const outside = probes({ files: [TOOLS_PNPM, "/elsewhere/gentle-pi"], dirs: [...dirs, "/elsewhere/gentle-pi"], + results: shell(requirements.shell, "/elsewhere/gentle-pi") }); + assert.deepEqual(await outside.probes.gentleAi(), { available: null }); +}); + +test("a failed, unparseable or ambiguous global list makes Pi, Shell, Gentle AI and setup unknown", async () => { + const duplicated = JSON.stringify([{ dependencies: { "gentle-pi": { version: "4.0.0" } } }, { dependencies: { "gentle-pi": { version: "4.0.0" } } }]); + for (const result of [{ code: 1, stdout: "[]" }, { code: 0, stdout: "" }, { code: 0, stdout: "{}" }, { code: 0, stdout: "not json" }, + { code: 0, timedOut: true, stdout: "[]" }, { code: 0, truncated: true, stdout: "[]" }]) { + const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: result } }); + for (const name of ["pi", "shell", "gentleAi", "setup"] as const) assert.deepEqual(await h.probes[name](), { available: null }, name); + } + const h = probes({ files: [TOOLS_PNPM], results: { [`${TOOLS_PNPM} ${LIST}`]: { code: 0, stdout: duplicated } } }); + assert.deepEqual(await h.probes.shell(), { available: null }); +}); + +test("Pi or Shell on the user's PATH but not pnpm-global is unknown, never absent", async () => { + const h = probes({ files: [TOOLS_PNPM, "/usr/local/bin/pi", "/usr/local/bin/gentle-shell"], + env: { HOME, PATH: `${TOOLS}/pnpm/bin:/usr/local/bin` }, results: pnpmVersion }); + assert.deepEqual(await h.probes.pi(), { available: null }); + assert.deepEqual(await h.probes.shell(), { available: null }); + assert.deepEqual(await h.probes.setup(), { available: null }); +}); + +test("go version parsing, absence, devel builds and timeouts", async () => { + const env = { HOME, PATH: "/usr/local/go/bin:/usr/bin" }; + for (const [stdout, version] of [["go version go1.26.0 linux/amd64\n", "1.26.0"], ["go version go1.22 linux/arm64", "1.22.0"]]) { + const h = probes({ env, files: ["/usr/local/go/bin/go"], results: { "/usr/local/go/bin/go version": { code: 0, stdout } } }); + assert.deepEqual(await h.probes.go(), { available: true, version, usable: true }); + } + for (const result of [{ code: 0, stdout: "go version devel go1.27-abc linux/amd64" }, { code: 0, stdout: "go version go1.26rc1 linux/amd64" }, + { code: 0, timedOut: true, stdout: "go version go1.26.0 linux/amd64" }]) { + const h = probes({ env, files: ["/usr/local/go/bin/go"], results: { "/usr/local/go/bin/go version": result } }); + assert.deepEqual(await h.probes.go(), { available: null }); + } + assert.deepEqual(await probes({ env }).probes.go(), { available: false }); +}); + +test("globalBin writability comes from the nearest existing ancestor of $PNPM_HOME/bin, without writing", async () => { + const share = "/home/u/.local/share"; + assert.deepEqual(await probes({ dirs: [HOME, share], writable: [share] }).probes.globalBin(), + { available: true, path: BIN, writable: true, onPath: false }); + assert.equal((await probes({ dirs: [HOME, share], writable: [HOME] }).probes.globalBin()).writable, false); + assert.equal((await probes({ dirs: [HOME, PNPM_HOME, BIN], writable: [BIN] }).probes.globalBin()).writable, true); + assert.equal((await probes({ dirs: [HOME], others: [`${HOME}/.local`], writable: [HOME, `${HOME}/.local`] }).probes.globalBin()).writable, false); + const onPath = await probes({ env: { HOME, PATH: `${BIN}:/usr/bin` }, dirs: [HOME, PNPM_HOME, BIN], writable: [BIN] }).probes.globalBin(); + assert.deepEqual(onPath, { available: true, path: BIN, writable: true, onPath: true }); + assert.deepEqual(await probes({ env: { PATH: "/usr/bin" } }).probes.globalBin(), { available: null }); + assert.deepEqual(await probes({ dirs: [], writable: [] }).probes.globalBin(), { available: null }); +}); + +test("real probes feed collectInventory; a bootstrap-only clean Linux host plans runtime persistence", async () => { + const h = probes({ files: [...bootstrapNode.files], realpaths: bootstrapNode.realpaths, texts: bootstrapNode.texts, + dirs: [HOME, "/home/u/.local/share"], writable: ["/home/u/.local/share"], + results: { ...bootstrapNode.results, [`${TOOLS_PNPM} --version`]: { code: 0, stdout: "11.1.1\n" }, ...pnpmVersion } }); + const inventory = await collectInventory({ platform: "linux", arch: "x64", probes: h.probes }); + assert.deepEqual(h.unexpected, []); + const plan = planPreflight(inventory); + assert.deepEqual(plan.blockers, []); + assert.deepEqual(plan.actions.map((action: { id: string }) => action.id), ["setup-global-bin", "persist-node", "persist-package-managers", + "configure-npm-prefix", "install-pi", "install-shell", "setup-shell", "verify-readiness"]); + // Only fixed read-only argv ever runs: versions and the global listing. + for (const call of h.calls) assert.match(call.args.join(" "), /^(--version|list -g --depth 0 --json)$/); +}); + +test("host adapters: argv without a shell, exit codes, deadlines and bounded output", async () => { + const { run } = hostAdapters({ maxOutputBytes: 64 }); + const env = { PATH: process.env.PATH ?? "" }; + const ok = await run(process.execPath, ["-e", "process.stdout.write('ok $HOME')"], { env, deadlineMs: 10_000 }); + assert.deepEqual(ok, { code: 0, signal: null, timedOut: false, truncated: false, stdout: "ok $HOME" }); + assert.equal((await run(process.execPath, ["-e", "process.exit(3)"], { env, deadlineMs: 10_000 })).code, 3); + const started = Date.now(); + const slow = await run(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { env, deadlineMs: 200 }); + assert.equal(slow.timedOut, true); + assert.equal(slow.signal, "SIGKILL"); + assert.ok(Date.now() - started < 10_000); + const big = await run(process.execPath, ["-e", "process.stdout.write('x'.repeat(5000))"], { env, deadlineMs: 10_000 }); + assert.equal(big.truncated, true); + assert.equal(big.stdout.length, 64); + const missing = await run(join(tmpdir(), "gentle-probe-missing-command"), [], { env, deadlineMs: 10_000 }); + assert.equal(missing.code, null); + assert.equal(missing.timedOut, false); +}); + +test("host fs adapter is read-only and bounded", async () => { + const dir = mkdtempSync(join(tmpdir(), "gentle-probes-fs-")); + try { + const { fs } = hostAdapters({ maxTextBytes: 16 }); + const file = join(dir, "small.txt"); + writeFileSync(file, "hello"); + writeFileSync(join(dir, "large.txt"), "x".repeat(64)); + mkdirSync(join(dir, "locked")); + chmodSync(join(dir, "locked"), 0o500); + symlinkSync(join(dir, "missing-target"), join(dir, "dangling")); + assert.equal(await fs.isFile(file), true); + assert.equal(await fs.isDirectory(dir), true); + assert.equal(await fs.isFile(join(dir, "nope")), false); + assert.equal(await fs.exists(join(dir, "dangling")), true); + assert.equal(await fs.isDirectory(join(dir, "dangling")), false); + assert.equal(await fs.exists(join(dir, "nope")), false); + assert.equal(await fs.readText(file), "hello"); + await assert.rejects(fs.readText(join(dir, "large.txt"))); + assert.equal(await fs.writable(dir), true); + if (process.getuid?.() !== 0) assert.equal(await fs.writable(join(dir, "locked")), false); + assert.equal(await fs.realpath(join(dir, ".", "small.txt")), realpathSync(file)); + } finally { + chmodSync(join(dir, "locked"), 0o700); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("host run settles at the deadline even when the child's stdout never closes", async () => { + const { EventEmitter } = await import("node:events"); + const killed: string[] = []; + let destroyed = false; + const stdout = Object.assign(new EventEmitter(), { destroy: () => { destroyed = true; } }); + // A child whose direct process ignores the kill and whose stdout stays open (as with a grandchild holding it). + const child = Object.assign(new EventEmitter(), { stdout, stdin: null, stderr: null, kill: (signal: string) => { killed.push(signal); return true; } }); + const spawned: unknown[][] = []; + const { run } = hostAdapters({ spawn: (...args: unknown[]) => { spawned.push(args); setTimeout(() => stdout.emit("data", Buffer.from("partial")), 5); return child; } }); + const started = Date.now(); + const result = await Promise.race([ + run("/fake/tool", ["--version"], { env: {}, deadlineMs: 50 }), + new Promise((resolve) => setTimeout(() => resolve("still pending"), 2000)), + ]); + assert.deepEqual(result, { code: null, signal: "SIGKILL", timedOut: true, truncated: false, stdout: "partial" }); + assert.ok(Date.now() - started < 1500); + assert.deepEqual(killed, ["SIGKILL"]); + assert.equal(destroyed, true); + assert.deepEqual(spawned[0].slice(0, 2), ["/fake/tool", ["--version"]]); + assert.equal((spawned[0][2] as { shell: boolean }).shell, false); + // A late close after the deadline does not change the settled result. + child.emit("close", 0, null); +}); diff --git a/tests/installer-runner.test.ts b/tests/installer-runner.test.ts index 0adf60e96..ed29013d6 100644 --- a/tests/installer-runner.test.ts +++ b/tests/installer-runner.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs"; -import { planPreflight, requirements } from "../scripts/installer-preflight.mjs"; +import { persistencePins, planPreflight, requirements } from "../scripts/installer-preflight.mjs"; import { PI_INSTALL_VERSION, packageNativeGentleAi, runStandardInstall } from "../scripts/installer-runner.mjs"; const HOME = "/home/u"; @@ -25,18 +25,57 @@ const W_PNPM_HOME = "C:\\Users\\u\\AppData\\Local\\pnpm"; const W_BIN = `${W_PNPM_HOME}\\bin`; const W_ROOT = `${W_PNPM_HOME}\\global\\v11\\node_modules\\gentle-pi`; +// Runtime persisted through pnpm: node in $PNPM_HOME/bin plus a pnpm-global npm shim. +const PERSISTENT_NODE = `${BIN}/node`; +const PM_NPM_DIR = `${PNPM_HOME}/global/v11/abc/node_modules/npm`; +const PM_NPM_CLI = `${PM_NPM_DIR}/bin/npm-cli.js`; +const STORE = `${PNPM_HOME}/store`; +// `pnpm store path` reports the versioned store directory. +const STORE_PATH = `${STORE}/v11`; +const STORE_PREFIX = `${STORE_PATH}/links/node/24.21.0/hash`; +const PM_PNPM_DIR = `${PNPM_HOME}/global/v11/abc/node_modules/pnpm`; +const PM_PNPM_ENTRY = `${PM_PNPM_DIR}/bin/pnpm.mjs`; +const POSIX_SHIM = `#!/bin/sh +basedir=$(dirname "$(echo "$0" | sed -e 's,\\\\,/,g')") +if [ -x "$basedir/node" ]; then + exec "$basedir/node" "$basedir/../global/v11/abc/node_modules/npm/bin/npm-cli.js" "$@" +else + exec node "$basedir/../global/v11/abc/node_modules/npm/bin/npm-cli.js" "$@" +fi +`; +const POSIX_PNPM_SHIM = POSIX_SHIM.replaceAll("npm/bin/npm-cli.js", "pnpm/bin/pnpm.mjs"); +const W_PERSISTENT_NODE = `${W_BIN}\\node.exe`; +const W_PM_PNPM_DIR = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\pnpm`; +const W_PM_PNPM_ENTRY = `${W_PM_PNPM_DIR}\\bin\\pnpm.mjs`; +const W_PM_NPM_DIR = `${W_PNPM_HOME}\\global\\v11\\abc\\node_modules\\npm`; +const W_PM_NPM_CLI = `${W_PM_NPM_DIR}\\bin\\npm-cli.js`; +const W_STORE = `${W_PNPM_HOME}\\store`; +const W_STORE_PATH = `${W_STORE}\\v11`; +const W_STORE_PREFIX = `${W_STORE_PATH}\\links\\node\\24.21.0\\hash`; +const WINDOWS_SHIM = `@IF EXIST "%~dp0\\node.exe" (\r\n "%~dp0\\node.exe" "%~dp0\\..\\global\\v11\\abc\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n) ELSE (\r\n node "%~dp0\\..\\global\\v11\\abc\\node_modules\\npm\\bin\\npm-cli.js" %*\r\n)\r\n`; + type Call = { command: string; args: string[]; env: Record; deadlineMs: number }; type Result = { code: number | null; signal?: string | null; timedOut?: boolean; stdout?: string }; type Layout = { platform: string; node: string; entry: string; npmCli: string; shellEntry: string; bin: string; - root: string; env: Record; files: string[]; realpaths: Record; texts: Record }; + root: string; env: Record; files: string[]; realpaths: Record; texts: Record; + persistentNode: string; pmNpmCli: string; pmPnpmEntry: string; pmHome: string; storePath: string; storePrefix: string; + creates: Record }; const npmPackage = JSON.stringify({ name: "npm", version: "11.19.0" }); +const pnpmPackage = JSON.stringify({ name: "pnpm", version: "11.1.1" }); const posixLayout: Layout = { platform: "linux", node: NODE, entry: ENTRY, npmCli: NPM_CLI, shellEntry: SHELL_ENTRY, bin: BIN, root: PACKAGE_ROOT, env: { HOME, PATH: `${BIN}:/opt/node/bin:/usr/bin`, GENTLE_INSTALL_PNPM_NODE: NODE, GENTLE_INSTALL_PNPM_ENTRY: ENTRY }, files: ["/opt/node/bin/npm", NPM_CLI, `${BIN}/gentle-shell`], - realpaths: { "/opt/node/bin/npm": NPM_CLI, [PNPM_HOME]: PNPM_HOME, [PACKAGE_ROOT]: PACKAGE_ROOT }, - texts: { "/opt/node/lib/node_modules/npm/package.json": npmPackage }, + realpaths: { "/opt/node/bin/npm": NPM_CLI, [PNPM_HOME]: PNPM_HOME, [PACKAGE_ROOT]: PACKAGE_ROOT, + [`${BIN}/npm`]: `${BIN}/npm`, [PM_NPM_CLI]: PM_NPM_CLI, [STORE]: STORE, [STORE_PATH]: STORE_PATH, [STORE_PREFIX]: STORE_PREFIX, + [`${BIN}/pnpm`]: `${BIN}/pnpm`, [PM_PNPM_ENTRY]: PM_PNPM_ENTRY }, + texts: { "/opt/node/lib/node_modules/npm/package.json": npmPackage, [`${BIN}/npm`]: POSIX_SHIM, + [`${PM_NPM_DIR}/package.json`]: npmPackage, [`${BIN}/pnpm`]: POSIX_PNPM_SHIM, [`${PM_PNPM_DIR}/package.json`]: pnpmPackage }, + persistentNode: PERSISTENT_NODE, pmNpmCli: PM_NPM_CLI, pmPnpmEntry: PM_PNPM_ENTRY, pmHome: PNPM_HOME, storePath: STORE_PATH, + storePrefix: STORE_PREFIX, + creates: { "runtime set node 24.21.0 -g": [PERSISTENT_NODE], "add-pm": [`${BIN}/npm`, PM_NPM_CLI, `${BIN}/pnpm`, PM_PNPM_ENTRY], + "add-npm": [`${BIN}/npm`, PM_NPM_CLI], "add-pnpm": [`${BIN}/pnpm`, PM_PNPM_ENTRY] }, }; // The user's Path already holds the global bin, spelled with different case. const windowsLayout: Layout = { @@ -46,8 +85,16 @@ const windowsLayout: Layout = { Path: `c:\\users\\u\\appdata\\local\\PNPM\\bin\\;${W_NODE_DIR};C:\\Windows`, GENTLE_INSTALL_PNPM_NODE: W_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_ENTRY }, files: [W_NPM_CMD, W_NPM_CLI, `${W_BIN}\\gentle-shell.cmd`], - realpaths: { [W_PNPM_HOME]: W_PNPM_HOME, [W_ROOT]: W_ROOT }, - texts: { [`${W_NODE_DIR}\\node_modules\\npm\\package.json`]: npmPackage }, + realpaths: { [W_PNPM_HOME]: W_PNPM_HOME, [W_ROOT]: W_ROOT, [W_PM_NPM_CLI]: W_PM_NPM_CLI, [W_STORE]: W_STORE, + [W_STORE_PATH]: W_STORE_PATH, [W_STORE_PREFIX]: W_STORE_PREFIX, [W_PM_PNPM_ENTRY]: W_PM_PNPM_ENTRY }, + texts: { [`${W_NODE_DIR}\\node_modules\\npm\\package.json`]: npmPackage, [`${W_BIN}\\npm.cmd`]: WINDOWS_SHIM, + [`${W_PM_NPM_DIR}\\package.json`]: npmPackage, + [`${W_BIN}\\pnpm.cmd`]: WINDOWS_SHIM.replaceAll("npm\\bin\\npm-cli.js", "pnpm\\bin\\pnpm.mjs"), + [`${W_PM_PNPM_DIR}\\package.json`]: pnpmPackage }, + persistentNode: W_PERSISTENT_NODE, pmNpmCli: W_PM_NPM_CLI, pmPnpmEntry: W_PM_PNPM_ENTRY, + pmHome: W_PNPM_HOME, storePath: W_STORE_PATH, storePrefix: W_STORE_PREFIX, + creates: { "runtime set node 24.21.0 -g": [W_PERSISTENT_NODE], "add-pm": [`${W_BIN}\\npm.cmd`, W_PM_NPM_CLI], + "add-pnpm": [`${W_BIN}\\pnpm.cmd`, W_PM_PNPM_ENTRY] }, }; const absent = { available: false }; @@ -68,11 +115,14 @@ function listing(pi = PI_INSTALL_VERSION, shell = requirements.shell, path = PAC const LIST = "list -g --depth 0 --json"; const emptyList = { code: 0, stdout: "[]" }; -function harness({ env = {}, results = {}, files = [] as string[], integrity = { ok: true } as object, layout = posixLayout } = {}) { +function harness({ env = {}, results = {}, files = [] as string[], integrity = { ok: true } as object, layout = posixLayout, + realpaths: extraRealpaths = {} as Record, texts: extraTexts = {} as Record, + creates = layout.creates } = {}) { const calls: Call[] = []; const logs: object[] = []; const fileSet = new Set([...layout.files, ...files]); - const { realpaths, texts } = layout; + const realpaths = { ...layout.realpaths, ...extraRealpaths }; + const texts = { ...layout.texts, ...extraTexts }; const defaults: Record = { "--version": { code: 0, stdout: "11.19.0\n" }, "bin -g": { code: 0, stdout: `${layout.bin}\n` }, @@ -80,14 +130,27 @@ function harness({ env = {}, results = {}, files = [] as string[], integrity = { [LIST]: [emptyList, { code: 0, stdout: listing(PI_INSTALL_VERSION, requirements.shell, layout.root) }], "gentle-shell setup": { code: 0 }, "pnpm setup": { code: 0 }, + "runtime set node 24.21.0 -g": { code: 0 }, + "add-pm": { code: 0 }, + "add-npm": { code: 0 }, + "add-pnpm": { code: 0 }, + "store path": { code: 0, stdout: `${layout.storePath}\n` }, + "pm-pnpm --version": { code: 0, stdout: "11.1.1\n" }, + "persistent-node --version": { code: 0, stdout: "v24.21.0\n" }, + "pm-npm --version": { code: 0, stdout: "11.19.0\n" }, + "pm-npm config get prefix": [{ code: 0, stdout: `${layout.storePrefix}\n` }, { code: 0, stdout: `${layout.pmHome}\n` }], + [`pm-npm config set prefix ${layout.pmHome} --location=user`]: { code: 0 }, }; const responses = { ...defaults, ...results } as Record; const seen: Record = {}; function key(command: string, args: string[]) { if (command === layout.node && args[0] === layout.npmCli) return args.slice(1).join(" "); + if (args[0] === layout.pmNpmCli) return `pm-npm ${args.slice(1).join(" ")}`; + if (args[0] === layout.pmPnpmEntry) return `pm-pnpm ${args.slice(1).join(" ")}`; + if (command === layout.persistentNode) return `persistent-node ${args.join(" ")}`; if (command === layout.node && args[0] === layout.shellEntry) return `gentle-shell ${args.slice(1).join(" ")}`; const rest = command === layout.node && args[0] === layout.entry ? args.slice(1) : args; - if (rest[0] === "add") return "add"; + if (rest[0] === "add") return ({ [PM_ADD]: "add-pm", [NPM_ADD]: "add-npm", [PNPM_ADD]: "add-pnpm" } as Record)[rest.join(" ")] ?? "add"; if (rest.join(" ") === "setup") return "pnpm setup"; return rest.join(" "); } @@ -104,6 +167,7 @@ function harness({ env = {}, results = {}, files = [] as string[], integrity = { const index = seen[k] ?? 0; seen[k] = index + 1; const response = Array.isArray(entry) ? entry[Math.min(index, entry.length - 1)] : entry; + if (response.code === 0) for (const file of creates[k] ?? []) fileSet.add(file); return { signal: null, timedOut: false, stdout: "", stderr: "", ...response }; }, fs: { @@ -128,6 +192,10 @@ function harness({ env = {}, results = {}, files = [] as string[], integrity = { } const INSTALL = `add -g @earendil-works/pi-coding-agent@${PI_INSTALL_VERSION} gentle-pi@${requirements.shell} --allow-build=gentle-pi`; +const PM_ADD = "add -g npm@11.19.0 pnpm@11.1.1"; +const NPM_ADD = "add -g npm@11.19.0"; +const PNPM_ADD = "add -g pnpm@11.1.1"; +const RUNTIME_SET = "runtime set node 24.21.0 -g"; test("declined or missing consent runs no commands", async () => { for (const consent of [false, undefined, "yes", 1]) { @@ -438,3 +506,330 @@ test("package-native integrity rejects development overrides and unverified pack rmSync(dir, { recursive: true, force: true }); } }); + +// Plans whose inventory says Node is bootstrap-only (or npm is not genuine) include fixed persistence steps. +const bootstrapNode = { ...tool("24.18.0"), persistent: false, npm: false }; +function persistPlan(platform = "linux", node: object = bootstrapNode) { + return plan(platform, { node }); +} +// pnpm 11 keeps each global add in its own project: npm+pnpm and the stack are listed separately. +function persistedListing(root = PACKAGE_ROOT, home = PNPM_HOME) { + return JSON.stringify([ + { path: `${home}/global/v11/abc`, dependencies: { npm: { version: "11.19.0" }, pnpm: { version: "11.1.1" } } }, + { path: `${home}/global/v11/def`, dependencies: { "@earendil-works/pi-coding-agent": { version: PI_INSTALL_VERSION }, + "gentle-pi": { version: requirements.shell, path: root } } }, + ]); +} +const persistedList = { [LIST]: [emptyList, { code: 0, stdout: persistedListing() }] }; +const PERSISTED_STEPS = ["check-global-bin", "check-existing-stack", "persist-node", "persist-package-managers", + "verify-persistent-runtime", "check-npm", "configure-npm-prefix"]; + +test("persistence pins are fixed: Node 24.21.0 with its bundled npm 11.19.0 and the pnpm acquisition pin", () => { + assert.deepEqual(persistencePins, { node: "24.21.0", npm: "11.19.0", pnpm: requirements.pnpm }); +}); + +test("bootstrap-only Node persists node, npm and pnpm under PNPM_HOME before the stack install", async () => { + const h = harness({ results: persistedList }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(result.npmPrefix, "configured"); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, RUNTIME_SET, PM_ADD, "store path", INSTALL, LIST]); + assert.deepEqual(result.completed, [...PERSISTED_STEPS, "install-global", "verify-global-list", "verify-shell-bin", + "verify-gentle-ai", "shell-setup"]); + for (const call of h.calls.filter((c) => c.args[1] === "add" || c.args[1] === "runtime")) { + assert.equal(call.command, NODE); + assert.equal(call.args.some((arg) => /dangerously|allow-build=\*|--allow-build$|approve-builds/.test(arg)), false); + assert.equal(call.env.PNPM_HOME, PNPM_HOME); + assert.equal(call.env.PATH.split(":")[0], BIN); + } + assert.equal(h.pnpmCalls().filter((call) => call === PM_ADD)[0].includes("allow-build"), false); + // After persistence, node and npm resolve from $PNPM_HOME/bin in the child env. + assert.deepEqual(h.calls.filter((call) => call.args[0] === PM_NPM_CLI).map((call) => [call.command, ...call.args]), [ + [NODE, PM_NPM_CLI, "--version"], + [PERSISTENT_NODE, PM_NPM_CLI, "config", "get", "prefix"], + [PERSISTENT_NODE, PM_NPM_CLI, "config", "set", "prefix", PNPM_HOME, "--location=user"], + [PERSISTENT_NODE, PM_NPM_CLI, "config", "get", "prefix"], + ]); + assert.deepEqual(h.calls.find((call) => call.command === PERSISTENT_NODE)?.args, ["--version"]); +}); + +test("the full persistence group runs exactly when Node is bootstrap-only", async () => { + for (const pnpm of [{ ...tool("11.1.1"), compatible: true, persistent: true }, { ...tool("11.1.1"), compatible: true, persistent: false }]) { + const h = harness({ results: persistedList }); + const result = await runStandardInstall({ plan: plan("linux", { node: { ...tool("24.18.0"), persistent: false, npm: true }, pnpm }), + consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.deepEqual(h.pnpmCalls().slice(2, 4), [RUNTIME_SET, PM_ADD]); + } + const h = harness(); + const result = await runStandardInstall({ plan: plan("linux", { node: { ...tool("24.18.0"), persistent: true, npm: true } }), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(result.npmPrefix, undefined); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]); + assert.equal(h.calls.some((call) => call.args[0] === PM_NPM_CLI || call.command === PERSISTENT_NODE), false); +}); + +test("a failed persistence step stops before the stack install and reports completed steps", async () => { + const cases: [string, object, string[]][] = [ + ["persist-node", { [RUNTIME_SET]: { code: 1 } }, [RUNTIME_SET]], + ["persist-node", { [RUNTIME_SET]: { code: 0, timedOut: true } }, [RUNTIME_SET]], + ["persist-package-managers", { "add-pm": { code: 1 } }, [RUNTIME_SET, PM_ADD]], + ["verify-persistent-runtime", { "persistent-node --version": { code: 0, stdout: "v24.18.0\n" } }, [RUNTIME_SET, PM_ADD]], + ]; + for (const [failedStep, results, mutations] of cases) { + const h = harness({ results: { ...persistedList, ...results } }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, failedStep); + assert.deepEqual(result.completed, PERSISTED_STEPS.slice(0, PERSISTED_STEPS.indexOf(failedStep))); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, ...mutations]); + } + // pnpm reports success but node or npm is not in $PNPM_HOME/bin. + for (const creates of [{ "add-pm": [`${BIN}/npm`, PM_NPM_CLI] }, { [RUNTIME_SET]: [PERSISTENT_NODE] }]) { + const h = harness({ results: persistedList, creates }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.failedStep, "verify-persistent-runtime"); + assert.equal(h.pnpmCalls().includes(INSTALL), false); + } +}); + +test("pnpm-global npm shim: wrong version, foreign target or ambiguous shim is rejected", async () => { + // Each variant is otherwise a working npm, so only the rule under test rejects it. + const foreign = "/elsewhere/node_modules/npm/bin/npm-cli.js"; + const variants = [ + { texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) }, + results: { "pm-npm --version": { code: 0, stdout: "11.18.0\n" } } }, + { texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "not-npm", version: "11.19.0" }) } }, + { realpaths: { [PM_NPM_CLI]: foreign }, files: [foreign], + texts: { "/elsewhere/node_modules/npm/package.json": npmPackage }, + results: { [`${foreign} --version`]: { code: 0, stdout: "11.19.0\n" } } }, + { texts: { [`${BIN}/npm`]: POSIX_SHIM.replace("abc/node_modules/npm/bin/npm-cli.js\" \"$@\"\nelse", "xyz/node_modules/npm/bin/npm-cli.js\" \"$@\"\nelse") } }, + { texts: { [`${BIN}/npm`]: "#!/bin/sh\nexec /opt/fake/npm \"$@\"\n" } }, + { results: { "pm-npm --version": { code: 0, stdout: "10.0.0\n" } } }, + ]; + for (const variant of variants) { + const h = harness({ ...variant, results: { ...persistedList, ...(variant as { results?: object }).results } }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "check-npm"); + assert.equal(h.pnpmCalls().includes(INSTALL), false); + } +}); + +test("an existing pnpm-global npm shim first on PATH satisfies check-npm without persistence", async () => { + const files = [`${BIN}/npm`, PM_NPM_CLI]; + const h = harness({ files }); + const result = await runStandardInstall({ plan: plan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]); + assert.deepEqual(h.calls[0].args, [PM_NPM_CLI, "--version"]); + const wrong = harness({ files, texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.20.0" }) }, + results: { "pm-npm --version": { code: 0, stdout: "11.20.0\n" } } }); + const blocked = await runStandardInstall({ plan: plan(), consent: true }, wrong.adapters); + assert.equal(blocked.outcome, "blocked"); + assert.equal(blocked.reason, "npm-unavailable"); +}); + +test("npm prefix is set only when inside the pnpm store and not explicit; never overwritten", async () => { + const set = `pm-npm config set prefix ${PNPM_HOME} --location=user`; + const cases: [object, object][] = [ + [{ "pm-npm config get prefix": { code: 0, stdout: "/home/u/.npm-global\n" } }, {}], + [{}, { NPM_CONFIG_PREFIX: STORE_PREFIX }], + [{}, { npm_config_prefix: STORE_PREFIX }], + [{}, { Npm_Config_Prefix: STORE_PREFIX }], + [{}, { PREFIX: STORE_PREFIX }], + ]; + for (const [results, env] of cases) { + const h = harness({ env, results: { ...persistedList, ...results } }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(result.npmPrefix, "unchanged"); + assert.equal(h.calls.some((call) => call.args[0] === PM_NPM_CLI && call.args.includes("set")), false); + assert.ok(result.completed.includes("configure-npm-prefix")); + // An explicit prefix never needs the store; an effective one is compared with `pnpm store path`. + assert.equal(h.pnpmCalls().includes("store path"), Object.keys(env).length === 0); + } + const failures = [ + { [set]: { code: 1 } }, + { "pm-npm config get prefix": [{ code: 0, stdout: `${STORE_PREFIX}\n` }, { code: 0, stdout: `${STORE_PREFIX}\n` }] }, + { "pm-npm config get prefix": { code: 1, stdout: "" } }, + { "pm-npm config get prefix": { code: 0, stdout: "relative/prefix\n" } }, + // The store must be resolved by pnpm itself; an unknown store is a failure, never "unchanged". + { "store path": { code: 1, stdout: "" } }, + { "store path": { code: 0, timedOut: true, stdout: `${STORE_PATH}\n` } }, + { "store path": { code: 0, stdout: "store/v11\n" } }, + { "store path": { code: 0, stdout: "/missing/store/v11\n" } }, + ]; + for (const results of failures) { + const h = harness({ results: { ...persistedList, ...results } }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "configure-npm-prefix"); + assert.deepEqual(result.completed, PERSISTED_STEPS.slice(0, -1)); + assert.equal(h.pnpmCalls().includes(INSTALL), false); + } +}); + +test("Windows persistence resolves node.exe and the npm.cmd shim from $PNPM_HOME\\bin", async () => { + const h = harness({ layout: windowsLayout, results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } }); + const result = await runStandardInstall({ plan: persistPlan("win32"), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(result.npmPrefix, "configured"); + assert.deepEqual(h.calls.filter((call) => call.args[0] === W_PM_NPM_CLI).map((call) => [call.command, ...call.args]), [ + [W_NODE, W_PM_NPM_CLI, "--version"], + [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "get", "prefix"], + [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "set", "prefix", W_PNPM_HOME, "--location=user"], + [W_PERSISTENT_NODE, W_PM_NPM_CLI, "config", "get", "prefix"], + ]); + // An npm.exe beside the shim resolves first in PATHEXT order and is not accepted. + const shadowed = harness({ layout: windowsLayout, creates: { ...windowsLayout.creates, "add-pm": [`${W_BIN}\\npm.exe`, `${W_BIN}\\npm.cmd`, W_PM_NPM_CLI] }, + results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } }); + const failed = await runStandardInstall({ plan: persistPlan("win32"), consent: true }, shadowed.adapters); + assert.equal(failed.outcome, "failed"); + assert.equal(failed.failedStep, "check-npm"); +}); + +test("partial or altered persistence actions are rejected before any command", async () => { + const base = persistPlan(); + const partial = { ...base, actions: base.actions.filter((action: { id: string }) => action.id !== "configure-npm-prefix") }; + const h = harness(); + assert.equal((await runStandardInstall({ plan: partial, consent: true }, h.adapters)).reason, "unsupported-plan"); + const altered = { ...base, actions: base.actions.map((action: { id: string }) => action.id === "persist-node" ? { ...action, version: "25.0.0" } : action) }; + assert.equal((await runStandardInstall({ plan: altered, consent: true }, h.adapters)).reason, "invalid-request"); + assert.deepEqual(h.calls, []); +}); + +test("gentle-shell setup receives no GENTLE_BOOTSTRAP_* or GENTLE_INSTALL_* keys", async () => { + const h = harness({ env: { GENTLE_BOOTSTRAP_TOOLS: "/home/u/.gentle-shell-bootstrap-tools.x", gentle_install_lower: "kept", KEEP: "1" } }); + assert.equal((await runStandardInstall({ plan: plan(), consent: true }, h.adapters)).outcome, "ready"); + const setup = h.calls.find((call) => call.args[0] === SHELL_ENTRY); + assert.ok(setup); + assert.deepEqual(Object.keys(setup.env).filter((key) => /^gentle_/i.test(key)), ["gentle_install_lower"]); + assert.equal(setup.env.KEEP, "1"); + assert.equal(setup.env.PNPM_HOME, PNPM_HOME); + // pnpm still receives the bootstrap handoff; only setup is stripped. + assert.equal(h.calls.find((call) => call.args[1] === "add")?.env.GENTLE_INSTALL_PNPM_ENTRY, ENTRY); + const w = harness({ layout: windowsLayout, env: { gentle_bootstrap_tools: "C:\\x", Gentle_Install_Other: "y" } }); + assert.equal((await runStandardInstall({ plan: plan("win32"), consent: true }, w.adapters)).outcome, "ready"); + const wSetup = w.calls.find((call) => call.args[0] === `${W_ROOT}\\bin\\gentle-shell.mjs`); + assert.deepEqual(Object.keys(wSetup?.env ?? {}).filter((key) => /^gentle_/i.test(key)), []); +}); + +test("npm prefix compares with the store pnpm reports, not an assumed $PNPM_HOME/store", async () => { + const custom = "/srv/pnpm-store/v11"; + const prefix = `${custom}/links/node/24.21.0/hash`; + const h = harness({ realpaths: { [custom]: custom, [prefix]: prefix }, results: { ...persistedList, + "store path": { code: 0, stdout: `${custom}\n` }, + "pm-npm config get prefix": [{ code: 0, stdout: `${prefix}\n` }, { code: 0, stdout: `${PNPM_HOME}\n` }] } }); + const result = await runStandardInstall({ plan: persistPlan(), consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.equal(result.npmPrefix, "configured"); + const storeCall = h.calls.find((call) => call.args[1] === "store"); + assert.deepEqual(storeCall?.args, [ENTRY, "store", "path"]); + assert.equal(storeCall?.env.PNPM_HOME, PNPM_HOME); + assert.ok((storeCall?.deadlineMs ?? 0) > 0); + // The default $PNPM_HOME/store is no longer assumed when pnpm reports another store. + const elsewhere = harness({ realpaths: { [custom]: custom }, results: { ...persistedList, "store path": { code: 0, stdout: `${custom}\n` } } }); + const unchanged = await runStandardInstall({ plan: persistPlan(), consent: true }, elsewhere.adapters); + assert.equal(unchanged.npmPrefix, "unchanged"); +}); + +test("a symlinked $PNPM_HOME/bin/npm must resolve inside PNPM_HOME to npm@11.19.0", async () => { + const outside = "/usr/lib/node_modules/npm/bin/npm-cli.js"; + const foreign = harness({ files: [`${BIN}/npm`, outside], realpaths: { [`${BIN}/npm`]: outside }, + texts: { "/usr/lib/node_modules/npm/package.json": npmPackage }, + results: { [`${outside} --version`]: { code: 0, stdout: "11.19.0\n" } } }); + const blocked = await runStandardInstall({ plan: plan(), consent: true }, foreign.adapters); + assert.equal(blocked.outcome, "blocked"); + assert.equal(blocked.reason, "npm-unavailable"); + assert.equal(foreign.pnpmCalls().some((call) => call.startsWith("add")), false); + const old = harness({ files: [`${BIN}/npm`, PM_NPM_CLI], realpaths: { [`${BIN}/npm`]: PM_NPM_CLI }, + texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) }, + results: { "pm-npm --version": { code: 0, stdout: "11.18.0\n" } } }); + assert.equal((await runStandardInstall({ plan: plan(), consent: true }, old.adapters)).reason, "npm-unavailable"); + const linked = harness({ files: [`${BIN}/npm`, PM_NPM_CLI], realpaths: { [`${BIN}/npm`]: PM_NPM_CLI } }); + assert.equal((await runStandardInstall({ plan: plan(), consent: true }, linked.adapters)).outcome, "ready"); + assert.deepEqual(linked.calls[0].args, [PM_NPM_CLI, "--version"]); +}); + +// Node already persistent: only the missing package managers, in one add -g, never runtime set. +const persistentNode = (npm: boolean) => ({ ...tool("24.18.0"), persistent: true, npm }); +const pnpmTool = (persistent: boolean) => ({ ...tool("11.1.1"), compatible: true, persistent }); +const AFTER_STACK = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]; + +test("persistent Node adds only the missing npm and/or pnpm with exact fixed argv", async () => { + const cases: [boolean, boolean, string, string[]][] = [ + [false, true, NPM_ADD, ["check-global-bin", "check-existing-stack", "persist-npm", "check-npm"]], + [true, false, PNPM_ADD, ["check-npm", "check-global-bin", "check-existing-stack", "persist-pnpm", "verify-persistent-pnpm"]], + [false, false, PM_ADD, ["check-global-bin", "check-existing-stack", "persist-package-managers", "check-npm", "verify-persistent-pnpm"]], + ]; + for (const [npm, pnpmPersistent, add, steps] of cases) { + const fixed = plan("linux", { node: persistentNode(npm), pnpm: pnpmTool(pnpmPersistent) }); + const h = harness({ results: persistedList }); + const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters); + assert.equal(result.outcome, "ready", add); + assert.deepEqual(h.pnpmCalls(), ["bin -g", LIST, add, INSTALL, LIST]); + assert.deepEqual(result.completed, [...steps, ...AFTER_STACK]); + assert.equal(result.npmPrefix, undefined); + assert.equal(h.calls.some((call) => call.command === PERSISTENT_NODE || call.args.includes("config")), false); + const persisted = h.calls.find((call) => call.args[1] === "add" && call.args.join(" ") !== `${ENTRY} ${INSTALL}`); + assert.equal(persisted?.env.PATH.split(":")[0], BIN); + if (!pnpmPersistent) assert.ok(h.calls.some((call) => call.command === NODE && call.args.join(" ") === `${PM_PNPM_ENTRY} --version`)); + } + const none = harness(); + const reused = await runStandardInstall({ plan: plan("linux", { node: persistentNode(true), pnpm: pnpmTool(true) }), consent: true }, none.adapters); + assert.deepEqual(none.pnpmCalls(), ["bin -g", LIST, INSTALL, LIST]); + assert.equal(reused.outcome, "ready"); +}); + +test("an added pnpm must resolve from $PNPM_HOME/bin at 11.1.1", async () => { + const fixed = plan("linux", { node: persistentNode(true), pnpm: pnpmTool(false) }); + const variants = [ + { creates: { "add-pnpm": [] } }, + { texts: { [`${PM_PNPM_DIR}/package.json`]: JSON.stringify({ name: "pnpm", version: "11.2.0" }) }, + results: { "pm-pnpm --version": { code: 0, stdout: "11.2.0\n" } } }, + { results: { "pm-pnpm --version": { code: 0, stdout: "10.0.0\n" } } }, + { realpaths: { [PM_PNPM_ENTRY]: "/elsewhere/node_modules/pnpm/bin/pnpm.mjs" } }, + { texts: { [`${BIN}/pnpm`]: "#!/bin/sh\nexec /opt/fake/pnpm \"$@\"\n" } }, + ]; + for (const variant of variants) { + const h = harness({ ...variant, results: { ...persistedList, ...(variant as { results?: object }).results } }); + const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters); + assert.equal(result.outcome, "failed"); + assert.equal(result.failedStep, "verify-persistent-pnpm"); + assert.equal(h.pnpmCalls().includes(INSTALL), false); + } + const npmFixed = plan("linux", { node: persistentNode(false), pnpm: pnpmTool(true) }); + const wrongNpm = harness({ results: { ...persistedList, "pm-npm --version": { code: 0, stdout: "11.18.0\n" } }, + texts: { [`${PM_NPM_DIR}/package.json`]: JSON.stringify({ name: "npm", version: "11.18.0" }) } }); + const failed = await runStandardInstall({ plan: npmFixed, consent: true }, wrongNpm.adapters); + assert.equal(failed.failedStep, "check-npm"); + assert.equal(wrongNpm.pnpmCalls().includes(RUNTIME_SET), false); +}); + +test("Windows persistent Node adds only pnpm and verifies the pnpm.cmd shim target", async () => { + const fixed = plan("win32", { node: persistentNode(true), pnpm: pnpmTool(false) }); + const h = harness({ layout: windowsLayout, results: { [LIST]: [emptyList, { code: 0, stdout: persistedListing(W_ROOT, W_PNPM_HOME) }] } }); + const result = await runStandardInstall({ plan: fixed, consent: true }, h.adapters); + assert.equal(result.outcome, "ready"); + assert.ok(result.completed.includes("verify-persistent-pnpm")); + assert.ok(h.calls.some((call) => call.command === W_NODE && call.args.join(" ") === `${W_PM_PNPM_ENTRY} --version`)); +}); + +test("persistence variants are accepted only as fixed sets", async () => { + const npmAction = { id: "persist-npm", kind: "install-global", target: "npm", version: "11.19.0" }; + const pnpmAction = { id: "persist-pnpm", kind: "install-global", target: "pnpm", version: "11.1.1" }; + const both = { id: "persist-package-managers", kind: "install-global", target: "package-managers" }; + const prefix = { id: "configure-npm-prefix", kind: "configure", target: "npm-prefix" }; + const node = { id: "persist-node", kind: "persist-runtime", target: "node", version: "24.21.0" }; + const base = plan(); + for (const extra of [[npmAction, pnpmAction], [both, prefix], [node, npmAction, prefix], [node, both], [npmAction, prefix]]) { + const h = harness(); + const result = await runStandardInstall({ plan: { ...base, actions: [...extra, ...base.actions] }, consent: true }, h.adapters); + assert.equal(result.outcome, "blocked"); + assert.equal(result.reason, "unsupported-plan"); + assert.deepEqual(h.calls, []); + } + const altered = { ...base, actions: [{ ...npmAction, version: "11.20.0" }, ...base.actions] }; + assert.equal((await runStandardInstall({ plan: altered, consent: true }, harness().adapters)).reason, "invalid-request"); +}); From 0c17bac89cb0c5f023646138771282e6908fb59a Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 22:32:24 +0200 Subject: [PATCH 09/24] docs(odd): record T5a verification and review --- odd/tasks/browser-install-wizard.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index 344c18d5d..9c3132b23 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -39,7 +39,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - [x] T2 — POSIX clean-machine bootstrap foundation. Status: closed for bounded implementation; commit `03e770f75d5ce8a02f16d125b590e41c743c8101`, writer/independent/parent 76/76, native high review approved and acknowledged. Native macOS/clean-machine installation evidence remains an explicit T7 prerequisite, not a T2 support claim. Route: delegated direct. Surfaces: `scripts/bootstrap.sh`, `scripts/installer-downloads.mjs`, `tests/installer-posix-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; returned 860 lines before bounded correction, retaining integrity/security tests and docs. Checks: focused Linux-executed POSIX fixture tests, shell syntax and whitespace; integrity, spaces and environment refresh. Native macOS and live installation checks deferred explicitly to T7. - [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. - [x] T4 — Standard installation driver. Status: bounded implementation closed; commit `0d9dac2cc90db922283f40cffa03958ba827fd42`. Writer, independent verifier and parent passed (final 134 tests/121 pass/0 fail/13 native Windows skips); native review approved and exactly acknowledged. Real-machine behavior remains T7. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. -- [ ] T5 — Secure local wizard host and packaged entry. Status: split into T5a (probes and runtime persistence, corrected, native review pending) and T5b (secure host and entry, pending); see "T5 settled design". Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. +- [ ] T5 — Secure local wizard host and packaged entry. Status: split into T5a (probes and runtime persistence, closed in `e4cac184`) and T5b (secure host and entry, pending); see "T5 settled design". Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. - [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. - [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. @@ -66,7 +66,7 @@ Planned closure checks: `pnpm test`, `pnpm run typecheck`, `pnpm run check:runti - T1 writer observed RED (`ERR_MODULE_NOT_FOUND`), then GREEN/refactor with 21/21 focused tests and whitespace checks passing. Independent verifier also passed 21/21 with no severe deterministic candidate-caused findings. T1 native assessment was medium; its single reliability review approved and exact acknowledgement consumed authority. Full suites, builds, actual installs and packaging checks remain pending. ## Progress and next step -T1–T4 are closed with commits, independent verification and native review. T5 is split: T5a (real probes and runtime persistence) is implemented and independently verified, with one scoped correction applied (`murerdbi-z-5qwl`) and native review pending; T5b (secure local host and entry) follows. T6 uses gentlemanprogramming.com as the visual reference. Native Windows/macOS and clean-machine acceptance remain T7. Development stays in the isolated worktree; product/runtime/Engram roots are unchanged. +T1–T4 are closed with commits, independent verification and native review. T5 is split: T5a (real probes and runtime persistence) is implemented and independently verified, closed in commit `e4cac184` after one scoped correction and native review; T5b (secure local host and entry) follows. T6 uses gentlemanprogramming.com as the visual reference. Native Windows/macOS and clean-machine acceptance remain T7. Development stays in the isolated worktree; product/runtime/Engram roots are unchanged. ## Work-unit evidence ### T1 (closed) @@ -153,3 +153,9 @@ Split into two work units, each with its own verification, review and commit. - Recorded, not corrected: `npm config set --location=user` may rewrite `~/.npmrc` formatting; npm global bins share `$PNPM_HOME/bin` on POSIX; underlying tools may write logs/update-notifier state during probes (T7). - Correction `murerdbi-z-5qwl` applied: persistent user Node → no `runtime set`, one fixed `add -g` with `npm@11.19.0` and/or `pnpm@11.1.1` (4 exact accepted variants) plus pnpm verification; probe deadline settles without `close`; npm prefix uses `pnpm store path` and fails closed; every npm in `$PNPM_HOME/bin` (symlink or shim) is confined and pinned. RED observed per change; 8 mutations killed. Writer and parent spot check: 169 tests/156 pass/0 fail/13 native skips (26.2s); diff check clean; no stray files. - New T7 checks: pnpm-managed persistent Node missing npm skips the prefix check; pnpm shim target assumed from pin metadata; `pnpm store path` containment; full-group added pnpm still unverified. + +### T5a closure evidence +- Native review `review-5ad487c03b1146fc`: controller-selected medium, one consolidated `review-reliability` run; approved and exactly acknowledged (authority burned) for target `sha256:08ca8533db2b06f5d47ae44bf87001335fd86bef0a68915da291e9fa1a98f428`, consumed revision `sha256:0b97bdd062e7fe8dae5dc2d3fb6da70aca3377b80b225964f5a299c531b9f7b6`. +- Commit `e4cac184c7d707d44ef036f517cc2d85a9eb4c8d` (`feat(installer): add host probes and pnpm runtime persistence`): staged and committed trees equal frozen `d4a70b589b337b2470fa3e3eb09be82eb04b9b63`; 8 files, 1,575 insertions/201 deletions (diff counted against the compacted ledger). +- Nonblocking advisories for later: `R3-full-group-pnpm-unverified` (`scripts/installer-runner.mjs:470-473`), `R3-incompatible-user-pnpm-persistent` (`scripts/installer-probes.mjs:245`), `R3-npm-pin-downgrade` (`scripts/installer-runner.mjs:249-252`), `R3-old-persistent-node-blocks` (`scripts/installer-probes.mjs:222-223`), `R3-readtext-bound-toctou` (`scripts/installer-probes.mjs:155-158`). +- Next: T5b secure local host and packaged entry. From 00360106be63dd61c0d9826a4947caef5693824a Mon Sep 17 00:00:00 2001 From: Alan-TheGentleman Date: Fri, 2 Oct 2026 23:25:50 +0200 Subject: [PATCH 10/24] feat(installer): add secure local wizard host and entry --- assets/install-wizard/index.html | 25 + bin/gentle-shell-install.mjs | 189 +++++++ docs/install-wizard.md | 158 +++++- odd/tasks/browser-install-wizard.md | 11 + scripts/installer-runner.mjs | 34 ++ scripts/installer-server.mjs | 493 ++++++++++++++++++ scripts/verify-package-files.mjs | 31 +- tests/installer-runner.test.ts | 39 +- tests/installer-server.test.ts | 759 ++++++++++++++++++++++++++++ tests/verify-package-files.test.ts | 40 ++ 10 files changed, 1765 insertions(+), 14 deletions(-) create mode 100644 assets/install-wizard/index.html create mode 100644 bin/gentle-shell-install.mjs create mode 100644 scripts/installer-server.mjs create mode 100644 tests/installer-server.test.ts diff --git a/assets/install-wizard/index.html b/assets/install-wizard/index.html new file mode 100644 index 000000000..2f2702d5d --- /dev/null +++ b/assets/install-wizard/index.html @@ -0,0 +1,25 @@ + + + + + + +Gentle Shell installation wizard + + + +
+

Gentle Shell installation wizard

+

The local installation server is running and this browser session is connected.

+

The interactive installation steps are not available in this version yet, so nothing will be installed from this page.

+

What to do now

+
    +
  • Close this tab and return to the terminal.
  • +
  • Press Ctrl+C in the terminal to stop the wizard. The temporary installer tools are then removed.
  • +
  • To install Gentle Shell today, follow the installation instructions in the README.
  • +
+
+ + diff --git a/bin/gentle-shell-install.mjs b/bin/gentle-shell-install.mjs new file mode 100644 index 000000000..983387a59 --- /dev/null +++ b/bin/gentle-shell-install.mjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node +import { spawn as spawnChild } from "node:child_process"; +import * as fsPromises from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, posix, resolve, win32 } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { collectInventory, planPreflight } from "../scripts/installer-preflight.mjs"; +import { createProbes, hostAdapters, userEnvironment } from "../scripts/installer-probes.mjs"; +import { packageNativeGentleAi, pnpmInvocation, runStandardInstall } from "../scripts/installer-runner.mjs"; +import { createInstallerServer } from "../scripts/installer-server.mjs"; + +// Browser installation wizard entry, started by the bootstrap with no argv. +// Thin wiring only: real probes and adapters, the standard runner and the +// loopback host. Exit 0 keeps the bootstrap's tools; nonzero removes them. + +/** Fixed per-platform browser opener for a local file, spawned with shell:false; null when none. */ +export function openerFor(platform, file, env) { + if (platform === "darwin") return { command: "/usr/bin/open", args: [file] }; + if (platform === "linux") return { command: "xdg-open", args: [file] }; + if (platform === "win32") { + // Absolute path: Windows would otherwise search the current directory first. + const systemRoot = env.SystemRoot ?? env.SYSTEMROOT; + if (typeof systemRoot !== "string" || !win32.isAbsolute(systemRoot)) return null; + return { command: win32.join(systemRoot, "System32", "rundll32.exe"), args: ["url.dll,FileProtocolHandler", file] }; + } + return null; +} + +function escapeHtml(value) { + return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]); +} + +/** Static page that refreshes to the session URL; no script. */ +export function redirectPage(url) { + const href = escapeHtml(url); + return [ + "", + '', + '', + ``, + "Gentle Shell installation wizard", + `

Continue to the Gentle Shell installation wizard

`, + "", + ].join("\n"); +} + +/** + * Writes the redirect page into a fresh private directory (0700, file 0600) + * under tempDir, so the one-time code never appears in the opener's argv. + * remove() is idempotent and best effort: it deletes the file, then the directory. + */ +export async function writeRedirect(url, { tempDir = tmpdir(), files = fsPromises } = {}) { + const dir = await files.mkdtemp(join(tempDir, "gentle-shell-install-")); + const file = join(dir, "open.html"); + let removed = false; + const remove = async () => { + if (removed) return; + removed = true; + await files.unlink(file).catch(() => {}); + await files.rmdir(dir).catch(() => {}); + }; + try { + await files.chmod(dir, 0o700); + await files.writeFile(file, redirectPage(url), { mode: 0o600, flag: "wx" }); + } catch (error) { + await remove(); + throw error; + } + return { dir, file, remove }; +} + +/** + * Opens url through a private redirect file with the fixed opener (shell:false, + * detached). Returns { file, remove } while the file exists, or null when nothing + * was opened; a failed opener removes the file. The printed URL stays the fallback. + */ +export async function openBrowser(url, { platform = process.platform, env = process.env, tempDir = tmpdir(), files = fsPromises, + spawn = spawnChild } = {}) { + // Resolve the opener first so no file is written when there is none. + if (openerFor(platform, "", env) === null) return null; + let redirect; + try { + redirect = await writeRedirect(url, { tempDir, files }); + } catch { + return null; + } + const opener = openerFor(platform, redirect.file, env); + try { + const child = spawn(opener.command, opener.args, { shell: false, detached: true, stdio: "ignore", windowsHide: true }); + child.once("error", () => { + void redirect.remove(); + }); + child.unref(); + } catch { + await redirect.remove(); + return null; + } + return { file: redirect.file, remove: redirect.remove }; +} + +/** 0 only for a completed installation; blocked, failed or none is 1. */ +export function exitCodeFor(outcome) { + return ["ready", "terminal-action-required"].includes(outcome?.outcome) ? 0 : 1; +} + +/** The runner's environment: the user's real PATH, so a temporary bootstrap npm + * or node never counts as persistent and globalBin.onPath stays truthful. A + * POSIX bootstrap pnpm (passed only through PATH) stays reachable as the last + * PATH entry; Windows passes pnpm through the GENTLE_INSTALL_PNPM_* handoff. + */ +export async function runnerEnvironment({ platform, env, fs }) { + const user = userEnvironment({ platform, env }); + if (platform === "win32" || (await pnpmInvocation(user, platform, fs)) !== null) return user; + const wizard = await pnpmInvocation(env, platform, fs); + if (wizard === null || wizard.prefix.length > 0) return user; + const rest = String(user.PATH ?? "").split(posix.delimiter).filter((entry) => entry.length > 0); + return { ...user, PATH: [...rest, posix.dirname(wizard.command)].join(posix.delimiter) }; +} + +async function main() { + const { platform, arch, env } = process; + const { run, fs } = hostAdapters(); + // The redirect file is removed once the code is redeemed, or when the host closes. + let redeemed = false; + let redirect = null; + const host = createInstallerServer({ + onRedeemed: () => { + redeemed = true; + void redirect?.remove(); + }, + assetsDir: fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)), + collectPlan: async () => { + // Fresh probes every time: createProbes caches its global package listing. + const inventory = await collectInventory({ platform, arch, probes: createProbes({ platform, env, run, fs }) }); + return { inventory, plan: planPreflight(inventory) }; + }, + runInstall: async (request, log) => runStandardInstall(request, { + platform, + nodePath: process.execPath, + env: await runnerEnvironment({ platform, env, fs }), + run, + fs, + verifyGentleAi: packageNativeGentleAi, + log, + }), + }); + let started; + try { + started = await host.listen(); + } catch { + console.error("The installation wizard could not start its local server. Nothing was installed."); + process.exitCode = 1; + return; + } + console.log("Gentle Shell installation wizard"); + console.log(`Open this link in your browser (it works once and expires in 2 minutes):\n ${started.url}`); + console.log("Press Ctrl+C to stop the wizard."); + + let signalled = false; + const stop = () => { + signalled = true; + host.close("signal"); + }; + process.once("SIGINT", stop); + process.once("SIGTERM", stop); + const opening = openBrowser(started.url).then((opened) => { + redirect = opened; + // The printed URL may have been redeemed while the file was being written. + if (redeemed) void opened?.remove(); + return opened; + }); + const { reason, outcome } = await host.closed; + process.off("SIGINT", stop); + process.off("SIGTERM", stop); + await (await opening)?.remove(); + const code = exitCodeFor(outcome); + if (outcome?.guidance) console.log(outcome.guidance); + else if (reason === "idle") console.log("The wizard closed after 30 minutes without activity. Nothing was installed."); + else console.log("The wizard closed without installing anything."); + if (signalled) process.exit(code); + process.exitCode = code; +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch(() => { + console.error("The installation wizard stopped unexpectedly."); + process.exitCode = 1; + }); +} diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 365236bc2..0abfdeb36 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1,11 +1,12 @@ # Installation wizard groundwork The preflight planner, real host probes, POSIX bootstrap, Windows prerequisite -foundation and the standard installation runner module (including runtime -persistence) are implemented. Windows native validation remains unavailable -locally. -**There is no working browser wizard or complete installation path yet.** -The bootstrap stops explicitly when the future wizard entry is absent. +foundation, the standard installation runner module (including runtime +persistence) and the secure local wizard host with its packaged entry are +implemented. Windows native validation remains unavailable locally. +**There is no interactive browser wizard or complete installation path yet:** +the served page is a static placeholder until the wizard UI ships (T6), so +nothing can be installed from the browser. For ordinary installation and terminal use, follow the [README](../README.md). ## What is available @@ -120,15 +121,15 @@ companion installer belongs in this plan. `npmCommand` supports pnpm, but independent upstream `npm exec` control remains unverified. **This preflight does not establish an npm-free installation chain.** -Local server, consent UI, distribution packaging and native acceptance evidence -remain future work in the +The consent UI, distribution packaging and native acceptance evidence remain +future work in the [feature plan](../odd/tasks/browser-install-wizard.md). Deterministic injected tests do not prove clean-machine installation on Windows, macOS or Linux. Focused verification: ```sh -node --experimental-strip-types --test tests/installer-probes.test.ts tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts +node --experimental-strip-types --test tests/installer-server.test.ts tests/verify-package-files.test.ts tests/installer-probes.test.ts tests/installer-runner.test.ts tests/installer-preflight.test.ts tests/installer-posix-bootstrap.test.ts tests/installer-windows-bootstrap.test.ts sh -n scripts/bootstrap.sh ``` @@ -399,6 +400,147 @@ must establish on real Windows, macOS and Linux machines: - the probes' `access(W_OK)` writability check, which ignores Windows ACLs, and process deadlines that kill only the direct child. +## Local wizard host and entry + +`bin/gentle-shell-install.mjs` is the entry the bootstrap's `launchWizard` +starts with no argv and inherited stdio. It is not a `package.json` `bin` +command; it ships inside the package's `bin/` directory and is listed in +`scripts/verify-package-files.mjs` with the other installer files +(`installerPaths`). + +### Entry wiring + +- Probes: `createProbes` over the wizard's own environment with + `hostAdapters()` (argv spawn with `shell:false`, deadlines, bounded output, + read-only fs). Each plan request creates fresh probes, then + `collectInventory` and `planPreflight` run server-side. +- Runner: `runStandardInstall` with `nodePath: process.execPath`, the same + adapters, `packageNativeGentleAi` and `runnerEnvironment`: the user's real + PATH from `userEnvironment`, so a temporary bootstrap npm or node never counts + as persistent and `onPath` stays truthful. A POSIX bootstrap pnpm, which the + bootstrap passes only through PATH, stays reachable as the **last** PATH + entry (its directory holds only the `pnpm` wrapper); Windows uses the + `GENTLE_INSTALL_PNPM_*` handoff, which `userEnvironment` keeps. +- The entry prints the session URL, then opens it through a private redirect + file so the one-time code never appears in the opener's argv (visible to + local process listings). `writeRedirect` creates a fresh `mkdtemp` directory + `gentle-shell-install-*` under the OS temp dir (mode 0700) holding + `open.html` (mode 0600, exclusive create): a static page with a meta refresh + and a link to the session URL, no script. `openBrowser` passes only that file + path to a fixed opener spawned with `shell:false` and detached: + `/usr/bin/open` on macOS, `xdg-open` on Linux, `%SystemRoot%\System32\rundll32.exe + url.dll,FileProtocolHandler` on Windows (absolute, so the current directory is + never searched). Without an opener nothing is written; a failed write opens + nothing; an opener that fails to start removes the file. The file and its + directory are removed (file, then directory, best effort) once the code is + redeemed (`onRedeemed`) or when the host closes. File modes are POSIX only; + on Windows the per-user `%TEMP%` ACLs apply. The printed URL stays the + fallback. + +| Exit code | When | +| --- | --- | +| 0 | The last installation outcome was `ready` or `terminal-action-required`. The bootstrap keeps its tools. | +| 1 | `blocked`, `failed`, a shutdown or idle close without a completed installation, a signal, or a host start failure. The bootstrap removes its tools. | + +SIGINT and SIGTERM close the server and exit with the code above. An +installation that is still running is not cancelled cleanly: Ctrl+C reaches the +runner's children through the terminal's process group, but SIGTERM does not. + +### Security model + +`scripts/installer-server.mjs` exports `createInstallerServer({ collectPlan, +runInstall, assetsDir, now?, random?, limits?, onRedeemed? })`, a +dependency-free `node:http` host. Every effect is injected; tests use fake +plans and runners. `onRedeemed()` runs once when the one-time code is used; an +exception from it is ignored. + +- Binds `127.0.0.1` on port 0 only and asserts the bound IPv4 address; never + `localhost`, `::` or `0.0.0.0`. +- Every request needs `Host` exactly `127.0.0.1:`, otherwise 421 before + any authorization (a missing Host included). +- Every response carries `Content-Security-Policy: default-src 'self'; + frame-ancestors 'none'; base-uri 'none'; form-action 'none'`, + `X-Content-Type-Options: nosniff`, `Cache-Control: no-store`, + `Referrer-Policy: no-referrer`, `X-Frame-Options: DENY`, + `Cross-Origin-Resource-Policy: same-origin` and + `Cross-Origin-Opener-Policy: same-origin`. No CORS header is ever sent; error + bodies are generic codes such as `{ "error": "forbidden" }`. +- Session: a 32-byte random secret becomes the cookie + `gentle_install_session` (`HttpOnly; SameSite=Strict; Path=/`). The printed + URL `http://127.0.0.1:/session?code=` carries a separate random + one-time code that expires after 2 minutes; a valid `GET /session` consumes it + and answers 200 with a minimal HTML page that sets the cookie and refreshes + to `/` (``, no script or + style, same strict headers). It is not a 303: reached from the `file:` + redirect page, a redirect chain counts as cross-site and the + `SameSite=Strict` cookie could be withheld on `/`, while a navigation started + by this same-origin page carries it. Code and cookie comparisons use + `timingSafeEqual`. Every asset and `/api/*` request needs the cookie (401). +- A request with an `Origin` other than `http://127.0.0.1:` is rejected + (403), including GET. Every `/api/*` request, GET included, needs the header + `X-Gentle-Install: 1` (403 otherwise, before any probe or plan rotation), so + a cross-origin fetch always needs a CORS preflight, which never succeeds. + POST also requires that exact Origin and `Content-Type: application/json` + (415 otherwise). +- Bodies are limited to 1 KiB (413, by declared length and while streaming). + Server `headersTimeout` is 10 s and `requestTimeout` 30 s. +- The host closes after 30 minutes without activity while no installation + runs (`limits.idleMs`, checked every 15 s with the injected clock). Only a + request with the right Host and a valid session cookie, a successful code + redemption or a finished installation counts as activity; wrong-Host, + unauthenticated or bad-code traffic never extends the timeout. + +### Endpoints + +Fixed allowlist, matched on the raw request path: anything else is 404, a +known path with another method is 405. Only `/session` and `/api/progress` +take a query string. Assets map to fixed file names in `assetsDir`; no request +path reaches the filesystem, so traversal forms (`/../`, `%2e%2e`, `%2f`) are +simply unknown paths. + +| Route | Contract | +| --- | --- | +| `GET /session?code=` | Consumes the one-time code, sets the cookie and returns 200 `text/html` that refreshes to `/`; 401 for a missing, wrong, used or expired code. | +| `GET /`, `/wizard.js`, `/wizard.css` | `index.html`, `wizard.js`, `wizard.css` from `assetsDir`; 404 when absent. | +| `GET /api/plan` | Runs `collectPlan()` server-side, stores the plan under a new opaque `planId` and returns a view model: `actions` (`id` plus a fixed English description), `blockers` (`code`, `tool`, guidance), `profileChange` (whether `pnpm setup` edits the shell profile or Windows user PATH, and the bin directory), `persistence` (which of node, npm and pnpm go under `$PNPM_HOME`) and `ready`. 409 while installing. Accepts no input. | +| `POST /api/install` | Body exactly `{ "planId": string, "consent": true }` (400 otherwise, including extra keys or a plan). 409 `install-running` while an installation runs; 409 `already-completed` once an installation has a final outcome (one installation per wizard run; the outcome is never replaced); 409 `plan-changed` for a stale `planId` or when a fresh re-inventory differs from the stored plan. Otherwise 202, and the runner receives the server-stored plan with `consent: true`. | +| `GET /api/progress?after=` | Entries `{ seq, step, status, reason }` after `seq` from a ring buffer of the last 200; values outside `[a-z][a-z0-9-]*` become `unknown`, and no other runner field is kept. Also `running` and the final `outcome` with fixed guidance. | +| `POST /api/shutdown` | Body empty or `{}`. Closes the host (409 while installing). | + +`guidance` (exported) holds fixed English text for every runner blocked +reason, every failed step, every preflight blocker code and both successful +outcomes, plus a generic fallback; a runner exception becomes a `failed` +outcome with the fallback. `scripts/installer-runner.mjs` exports the frozen +arrays `blockedReasons` and `failedSteps`; a test requires guidance for exactly +those entries, and the runner tests check that every reason and step their +scenarios observe is listed. + +### Placeholder page + +`assets/install-wizard/index.html` is static HTML: it confirms the session, +says that installation steps are not available yet and points to the README. +The strict CSP blocks inline script and style, so it uses no script and only +legacy color attributes (background `#1a1218`, text `#f6eff3`, links `#f095c8`) +and `color-scheme: dark`; the system font stack and real styling arrive with +`wizard.css` in T6. With this placeholder the wizard always ends with exit 1 +(Ctrl+C or the idle close). + +### Remaining T6/T7 checks + +- T6: the interactive `wizard.js`/`wizard.css` (plan, single consent, install, + progress polling, outcome, shutdown) must work under the CSP above; add both + to `installerPaths` when they ship; verify in a real browser. +- T7: the opener on each desktop (including a missing `xdg-open`); that the + redirect file opens in the browser rather than another `.html` handler, and + that sandboxed browsers with a private `/tmp` (for example snap or flatpak + packages on Linux) can read it; that a meta refresh from a `file:` page + reaches the session and the session page's same-origin refresh to `/` + carries the `SameSite=Strict` cookie in each browser; browsers honouring + `SameSite=Strict` and the cookie on `127.0.0.1`; DNS-rebinding and + cross-site request rejection in real browsers; the bootstrap's handling of + exit 0 and 1 end to end; SIGTERM during a running installation; Windows + `rundll32` behavior; real probe and runner execution through the host. + ## POSIX bootstrap: bundle-local tooling only Run `sh scripts/bootstrap.sh` from a trusted extracted installation bundle or diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index 9c3132b23..9097349a3 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -159,3 +159,14 @@ Split into two work units, each with its own verification, review and commit. - Commit `e4cac184c7d707d44ef036f517cc2d85a9eb4c8d` (`feat(installer): add host probes and pnpm runtime persistence`): staged and committed trees equal frozen `d4a70b589b337b2470fa3e3eb09be82eb04b9b63`; 8 files, 1,575 insertions/201 deletions (diff counted against the compacted ledger). - Nonblocking advisories for later: `R3-full-group-pnpm-unverified` (`scripts/installer-runner.mjs:470-473`), `R3-incompatible-user-pnpm-persistent` (`scripts/installer-probes.mjs:245`), `R3-npm-pin-downgrade` (`scripts/installer-runner.mjs:249-252`), `R3-old-persistent-node-blocks` (`scripts/installer-probes.mjs:222-223`), `R3-readtext-bound-toctou` (`scripts/installer-probes.mjs:155-158`). - Next: T5b secure local host and packaged entry. + +### T5b implementation evidence (uncommitted) +- Writer `murf6rft-10-azav`: `scripts/installer-server.mjs` (new, 461), `bin/gentle-shell-install.mjs` (new, 114), `tests/installer-server.test.ts` (new, 567, 18 tests), `assets/install-wizard/index.html` (new, 25, static placeholder), `scripts/verify-package-files.mjs` and its test (exports `installerPaths`, `requiredPaths`, `missingRequiredPaths`), `docs/install-wizard.md` (+186/-13 across modified files). +- RED: `ERR_MODULE_NOT_FOUND` and missing `installerPaths` export; then 3 real assertion failures (missing Host 400 vs 421, plan body 413 vs 400, guidance regex). GREEN 29/29 focused. 14 server mutations killed; one equivalent survivor (early Content-Length check). Real-entry smoke: session 303, index 200, bad Host 421, SIGINT exit 1 (no probes, no network). +- Writer verification: 198 tests/185 pass/0 fail/13 native skips; `node scripts/verify-package-files.mjs` passed (166 files); diff check clean; temporary `/dev/shm` files removed. +- Writer-noted risks: bootstrap pnpm directory appended at the END of the runner PATH on POSIX (children may see the temporary pnpm when it is not persisted); placeholder has no JS, so the wizard currently always exits 1 and the bootstrap removes its tools (expected until T6); CSP blocks inline style so the placeholder uses legacy color attributes; the one-time URL is visible in the opener argv until used (single-use, 2 min TTL); SIGTERM during install does not cancel runner children; guidance coverage test parses runner source with regex because reasons/steps are not exported. +- Risk: high (local mutation API). +- Independent verifier `murflaxp-11-2zi2`: PASS (198 tests/185 pass/0 fail/13 native skips; package check passed); offensive loopback probing found no command/URL/path/env/plan influence and no single-flight bypass. The user asked to finish and correct, so one scoped hardening correction is accepted: private 0600 redirect file instead of the one-time code in opener argv; idle timestamp only after Host/session validation; `X-Gentle-Install` required on every `/api/*` request; reject install after completion with 409; export runner blocked reasons and failed steps and test guidance coverage against them; add `Cross-Origin-Resource-Policy` and `Cross-Origin-Opener-Policy` headers. Not a defect: bootstrap pnpm appended at the end of PATH. T7: Ctrl+C reaching the helper process group and SIGTERM leaving runner children. +- Hardening correction: first writer `murfrx6l-12-blwa` was lost in a session restart after partial edits (server CORP/COOP and `already-completed`, runner exports); writer `murg28n0-1-7ybw` finishes the remaining items from the partial tree. +- Writer `murg28n0-1-7ybw` finished the hardening: item 1 (private 0600 redirect file instead of the code in opener argv) implemented with RED→GREEN; items 2–6 were already applied by the lost writer (no RED available, covered by existing tests). 208 tests/195 pass/0 fail/13 native skips; package check passed. Raised risk: the `file:` redirect makes `/session`→303→`/` cross-site so the Strict cookie may not reach `/`; parent decided `/session` returns 200 with a same-origin meta refresh (same correction round, `murg8ko0-2-a7or`). +- `murg8ko0-2-a7or`: `/session` now returns 200 with a same-origin meta refresh and the Strict cookie (RED: 19 tests expected 200, got 303; GREEN 26/26). Parent spot check: 208 tests/195 pass/0 fail/13 native skips; package check passed; diff and whitespace checks clean; no stray temp files. Test note: some server tests call `login()` outside try/finally, so failures can hang the run without `--test-timeout` (advisory). Next: native review and commit. diff --git a/scripts/installer-runner.mjs b/scripts/installer-runner.mjs index 4bcaa9aac..e7ae40aab 100644 --- a/scripts/installer-runner.mjs +++ b/scripts/installer-runner.mjs @@ -18,6 +18,40 @@ export const PI_INSTALL_VERSION = "1.0.0"; export const PI_PACKAGE = "@earendil-works/pi-coding-agent"; export const SHELL_PACKAGE = "gentle-pi"; +/** Every `reason` a `blocked` outcome can carry (for host guidance; no behavior). */ +export const blockedReasons = Object.freeze([ + "invalid-request", + "consent-required", + "preflight-blocked", + "go-required", + "unsupported-plan", + "pnpm-home-unknown", + "node-unavailable", + "pnpm-unavailable", + "npm-unavailable", + "npm-shadowed", + "global-bin-mismatch", + "global-list-unavailable", + "existing-stack", +]); +/** Every `failedStep` a `failed` outcome can carry (for host guidance; no behavior). */ +export const failedSteps = Object.freeze([ + "persist-node", + "persist-package-managers", + "persist-npm", + "persist-pnpm", + "verify-persistent-runtime", + "verify-persistent-pnpm", + "check-npm", + "configure-npm-prefix", + "install-global", + "verify-global-list", + "verify-shell-bin", + "verify-gentle-ai", + "shell-setup", + "persist-path", +]); + const SECOND = 1000; const MINUTE = 60 * SECOND; const deadlines = Object.freeze({ probe: 30 * SECOND, install: 20 * MINUTE, setup: 20 * MINUTE }); diff --git a/scripts/installer-server.mjs b/scripts/installer-server.mjs new file mode 100644 index 000000000..8f019b82a --- /dev/null +++ b/scripts/installer-server.mjs @@ -0,0 +1,493 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { dirname, join } from "node:path"; + +// Local wizard host: a dependency-free loopback HTTP server with a fixed route +// allowlist. The browser never sends commands, paths, plans or environment: +// plans are built here, stored here and handed to the injected runner here. + +const HOST = "127.0.0.1"; +const COOKIE = "gentle_install_session"; +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const defaultLimits = Object.freeze({ + codeTtlMs: 2 * MINUTE, + idleMs: 30 * MINUTE, + idleCheckMs: 15 * SECOND, + bodyBytes: 1024, + logEntries: 200, + headersTimeoutMs: 10 * SECOND, + requestTimeoutMs: 30 * SECOND, +}); +const securityHeaders = Object.freeze({ + "Content-Security-Policy": "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'", + "X-Content-Type-Options": "nosniff", + "Cache-Control": "no-store", + "Referrer-Policy": "no-referrer", + "X-Frame-Options": "DENY", + "Cross-Origin-Resource-Policy": "same-origin", + "Cross-Origin-Opener-Policy": "same-origin", +}); +// Fixed asset names only: the request path never reaches the filesystem. +const assets = Object.freeze({ + "/": { file: "index.html", type: "text/html; charset=utf-8" }, + "/wizard.js": { file: "wizard.js", type: "text/javascript; charset=utf-8" }, + "/wizard.css": { file: "wizard.css", type: "text/css; charset=utf-8" }, +}); +const routes = Object.freeze({ + ...Object.fromEntries(Object.keys(assets).map((path) => [path, "GET"])), + "/session": "GET", + "/api/plan": "GET", + "/api/progress": "GET", + "/api/install": "POST", + "/api/shutdown": "POST", +}); +// Served on a valid code instead of a 303: when the session URL is reached from +// the file: redirect page, a redirect chain counts as cross-site and a +// SameSite=Strict cookie could be withheld on `/`. This same-origin page starts +// the navigation, so the cookie is sent. No script or style, as the CSP requires. +const sessionPage = [ + "", + '', + '', + "Gentle Shell installation wizard", + '

Continue to the Gentle Shell installation wizard

', + "", +].join("\n"); + +/** Fixed English descriptions for every action planPreflight can emit. */ +export const actionDescriptions = Object.freeze({ + "acquire-node": "Download a verified Node.js runtime.", + "verify-node": "Check that the downloaded Node.js runtime runs.", + "acquire-pnpm": "Download a verified pnpm package manager.", + "verify-pnpm": "Check that the downloaded pnpm runs.", + "setup-global-bin": "Run `pnpm setup` so new terminals find globally installed commands.", + "persist-node": "Install Node.js under PNPM_HOME with `pnpm runtime set node -g`.", + "persist-package-managers": "Install npm and pnpm under PNPM_HOME with one `pnpm add -g`.", + "configure-npm-prefix": "Point npm's user-level global prefix at PNPM_HOME when its default would be inside pnpm's store.", + "persist-npm": "Install npm under PNPM_HOME with `pnpm add -g`.", + "persist-pnpm": "Install pnpm under PNPM_HOME with `pnpm add -g`.", + "acquire-go": "Download a verified Go toolchain.", + "verify-go": "Check that the Go toolchain runs.", + "install-pi": "Install the Pi coding agent globally with pnpm.", + "install-shell": "Install Gentle Shell (gentle-pi) globally with pnpm.", + "provision-native": "Provision the package-native Gentle AI binary with the existing installer.", + "setup-shell": "Run the normal `gentle-shell setup`.", + "verify-readiness": "Verify that the installed stack is ready.", +}); + +const tryAgain = "Fix the cause, then run the installer again."; +/** Fixed English guidance for every runner blocked reason, failed step and preflight blocker. */ +export const guidance = Object.freeze({ + blocked: Object.freeze({ + "invalid-request": "The installation request was not an unmodified plan. Reload the wizard to get a fresh plan.", + "consent-required": "Nothing was installed because consent was not given. Review the plan and confirm to continue.", + "preflight-blocked": "Preflight found a blocker, so nothing was installed. Resolve the listed blockers and run the installer again.", + "go-required": "Windows needs Go 1.25.10 or newer on PATH before Gentle AI can be provisioned. Install Go, then run the installer again.", + "unsupported-plan": "This machine needs steps the wizard does not run yet, such as updating an existing installation. Use `gentle-shell update` or follow the README.", + "pnpm-home-unknown": "The pnpm home directory could not be determined. Set PNPM_HOME to an absolute directory, then run the installer again.", + "node-unavailable": "The installer could not find its own Node.js executable. Run the installer again from the bootstrap script.", + "pnpm-unavailable": "pnpm could not be started. Run the installer again from the bootstrap script so it can provide pnpm.", + "npm-unavailable": "A genuine npm was not found on PATH. Gentle AI needs it; reinstall Node.js with npm, then run the installer again.", + "npm-shadowed": "Another `npm` program appears on PATH before Node.js's npm. Remove or reorder it, then run the installer again.", + "global-bin-mismatch": "pnpm reported a different global bin directory than expected. Check PNPM_HOME, then run the installer again.", + "global-list-unavailable": "pnpm could not list global packages. Check that `pnpm list -g` works, then run the installer again.", + "existing-stack": "Pi or Gentle Shell is already installed globally. Nothing was changed; use `gentle-shell update` instead.", + }), + failed: Object.freeze({ + "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}`, + "persist-npm": `Installing npm under PNPM_HOME failed. Check your network connection. ${tryAgain}`, + "persist-pnpm": `Installing pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`, + "verify-persistent-runtime": `Node.js and npm were not found in the pnpm global bin directory after installation. ${tryAgain}`, + "verify-persistent-pnpm": `pnpm was not found in the pnpm global bin directory after installation. ${tryAgain}`, + "check-npm": `The installed npm could not be verified as genuine npm. ${tryAgain}`, + "configure-npm-prefix": `npm's global prefix could not be checked or set to PNPM_HOME. Your npm configuration was left as it was. ${tryAgain}`, + "install-global": `Installing Pi and Gentle Shell with pnpm failed. Check your network connection. ${tryAgain}`, + "verify-global-list": `The installed Pi and Gentle Shell packages could not be verified under PNPM_HOME. ${tryAgain}`, + "verify-shell-bin": `The gentle-shell command was not found in the pnpm global bin directory. ${tryAgain}`, + "verify-gentle-ai": `The package-native Gentle AI binary could not be verified. ${tryAgain}`, + "shell-setup": "`gentle-shell setup` did not finish. Run `gentle-shell setup` in a terminal to see the details.", + "persist-path": "`pnpm setup` could not add the global bin directory to your PATH. Run `pnpm setup` in a terminal, then open a new terminal.", + }), + blockers: Object.freeze({ + "unsupported-target": "This operating system or CPU is not supported by the wizard. Follow the README for a manual installation.", + "unknown-tool": "A required tool could not be checked safely. Make sure it runs from a terminal, or remove the broken installation, then run the installer again.", + "incompatible-tool": "A required tool is installed at an incompatible version. Update it, then run the installer again.", + }), + outcomes: Object.freeze({ + ready: "Gentle Shell is installed. Run `gentle-shell` in a terminal.", + "terminal-action-required": "Gentle Shell is installed. Open a new terminal so it picks up the updated PATH, then run `gentle-shell`.", + }), + fallback: "The installation stopped for an unexpected reason. Nothing else will run; check the terminal and run the installer again.", +}); + +const ID = /^[a-z][a-z0-9-]{0,63}$/; +function identifier(value, fallback = "unknown") { + return typeof value === "string" && ID.test(value) ? value : fallback; +} +function plainObject(value) { + return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; +} +function token(random, bytes) { + return random(bytes).toString("base64url"); +} +function sameSecret(expected, actual) { + const [a, b] = [Buffer.from(expected), Buffer.from(String(actual))]; + return a.length === b.length && timingSafeEqual(a, b); +} +function fingerprint(collected) { + return createHash("sha256").update(JSON.stringify({ plan: collected.plan, binDir: collected.inventory?.globalBin?.path ?? null })) + .digest("hex"); +} + +/** Browser view of a server-held plan: fixed descriptions, never commands. */ +function planView(planId, { inventory, plan }) { + const ids = plan.actions.map((action) => action.id); + const binDir = typeof inventory?.globalBin?.path === "string" ? inventory.globalBin.path : null; + const pnpmHome = binDir === null ? null : dirname(binDir); + const changesProfile = ids.includes("setup-global-bin"); + let tools = []; + if (ids.includes("persist-node")) tools = ["node", "npm", "pnpm"]; + else if (ids.includes("persist-package-managers")) tools = ["npm", "pnpm"]; + else if (ids.includes("persist-npm")) tools = ["npm"]; + else if (ids.includes("persist-pnpm")) tools = ["pnpm"]; + return { + planId, + ready: plan.ready === true, + actions: plan.actions.map((action) => ({ + id: identifier(action.id), + description: actionDescriptions[action.id] ?? "Prepare the installation.", + })), + blockers: plan.blockers.map((blocker) => ({ + code: identifier(blocker.code), + tool: identifier(blocker.tool), + guidance: guidance.blockers[blocker.code] ?? guidance.fallback, + })), + profileChange: { + changesProfile, + command: "pnpm setup", + binDir, + description: 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.` + : "Your PATH already contains the pnpm global bin directory; no shell profile or PATH change is planned.", + }, + persistence: { + tools, + pnpmHome, + description: tools.length > 0 + ? `${tools.join(", ")} will be installed under $PNPM_HOME (${pnpmHome ?? "pnpm's home directory"}) so new terminals keep working after the temporary installer tools are removed. Existing installations are not replaced.` + : "No runtime needs to be installed under $PNPM_HOME; your existing Node.js, npm and pnpm are reused.", + }, + }; +} + +function outcomeView(result) { + if (!plainObject(result) || !["ready", "terminal-action-required", "blocked", "failed"].includes(result.outcome)) { + return { outcome: "failed", failedStep: null, completed: [], guidance: guidance.fallback }; + } + const view = { outcome: result.outcome, completed: Array.isArray(result.completed) ? result.completed.map((step) => identifier(step)) : [] }; + if (result.outcome === "blocked") { + view.reason = identifier(result.reason); + view.guidance = guidance.blocked[result.reason] ?? guidance.fallback; + } else if (result.outcome === "failed") { + view.failedStep = identifier(result.failedStep); + view.guidance = guidance.failed[result.failedStep] ?? guidance.fallback; + } else { + view.guidance = guidance.outcomes[result.outcome]; + if (result.action === "open-new-terminal") view.action = "open-new-terminal"; + if (["configured", "unchanged"].includes(result.npmPrefix)) view.npmPrefix = result.npmPrefix; + } + return view; +} + +/** + * createInstallerServer({ collectPlan, runInstall, assetsDir, now?, random?, limits?, onRedeemed? }) + * collectPlan() -> { inventory, plan } (fresh preflight, trusted local code); + * runInstall({ plan, consent: true }, log) -> runner result; onRedeemed() runs once + * when the one-time code is used (it must not throw). Returns + * { listen(), close(reason), closed, checkIdle(), outcome() }. + */ +export function createInstallerServer({ collectPlan, runInstall, assetsDir, now = Date.now, random = randomBytes, limits = {}, + onRedeemed = () => {} }) { + const limit = { ...defaultLimits, ...limits }; + const secret = token(random, 32); + let code = token(random, 32); + let codeExpires = 0; + let port = 0; + let origin = ""; + let lastActivity = now(); + let stored = null; + let planning = null; + let installing = false; + let lastOutcome = null; + let seq = 0; + const log = []; + let idleTimer = null; + let closing = null; + let resolveClosed; + const closed = new Promise((resolve) => { resolveClosed = resolve; }); + + // requireHostHeader:false lets a missing Host get the same 421 as a wrong one. + const server = createServer({ maxHeaderSize: 16 * 1024, requireHostHeader: false }, (req, res) => { + handle(req, res).catch(() => { + if (!res.headersSent) reply(res, 500, { error: "internal" }); + else res.destroy(); + }); + }); + server.headersTimeout = limit.headersTimeoutMs; + server.requestTimeout = limit.requestTimeoutMs; + server.keepAliveTimeout = 5 * SECOND; + + function reply(res, status, body, headers = {}) { + const payload = typeof body === "string" || Buffer.isBuffer(body) ? body : JSON.stringify(body); + res.writeHead(status, { + ...securityHeaders, + "Content-Type": "application/json; charset=utf-8", + ...headers, + "Content-Length": Buffer.byteLength(payload), + }); + res.end(payload); + } + + function hasSession(req) { + const pairs = String(req.headers.cookie ?? "").split(";").map((part) => part.trim()) + .filter((part) => part.startsWith(`${COOKIE}=`)); + return pairs.length === 1 && sameSecret(secret, pairs[0].slice(COOKIE.length + 1)); + } + + async function readJson(req, res) { + const declared = Number(req.headers["content-length"]); + if (Number.isFinite(declared) && declared > limit.bodyBytes) { + reply(res, 413, { error: "body-too-large" }, { Connection: "close" }); + return undefined; + } + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > limit.bodyBytes) { + reply(res, 413, { error: "body-too-large" }, { Connection: "close" }); + return undefined; + } + chunks.push(chunk); + } + try { + return { value: JSON.parse(Buffer.concat(chunks).toString("utf8") || "null") }; + } catch { + return { value: undefined }; + } + } + + function record(entry) { + const value = plainObject(entry) ? entry : {}; + seq += 1; + log.push({ seq, step: identifier(value.step), status: identifier(value.status), + reason: value.reason === undefined || value.reason === null ? null : identifier(value.reason) }); + while (log.length > limit.logEntries) log.shift(); + } + + function collect() { + planning ??= Promise.resolve().then(collectPlan).finally(() => { planning = null; }); + return planning; + } + + async function getPlan(res) { + // Probing while the runner mutates the machine would describe a moving target. + if (installing) { + reply(res, 409, { error: "install-running" }); + return; + } + let collected; + try { + collected = await collect(); + } catch { + reply(res, 500, { error: "plan-unavailable" }); + return; + } + if (installing) { + reply(res, 409, { error: "install-running" }); + return; + } + stored = { planId: token(random, 18), collected: structuredClone(collected), fingerprint: fingerprint(collected) }; + reply(res, 200, planView(stored.planId, stored.collected)); + } + + async function install(req, res) { + const body = await readJson(req, res); + if (body === undefined) return; + const { value } = body; + const keys = plainObject(value) ? Object.keys(value).sort() : []; + if (keys.length !== 2 || keys[0] !== "consent" || keys[1] !== "planId" || value.consent !== true || + typeof value.planId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(value.planId)) { + reply(res, 400, { error: "invalid-request" }); + return; + } + if (installing) { + reply(res, 409, { error: "install-running" }); + return; + } + // One installation per wizard run: a final outcome is never replaced. + if (lastOutcome !== null) { + reply(res, 409, { error: "already-completed" }); + return; + } + if (stored === null || !sameSecret(stored.planId, value.planId)) { + reply(res, 409, { error: "plan-changed" }); + return; + } + installing = true; + const current = stored; + let fresh; + try { + // Re-inventory: install only when the machine still matches the consented plan. + fresh = await collect(); + } catch { + fresh = null; + } + if (fresh === null || stored !== current || fingerprint(fresh) !== current.fingerprint) { + installing = false; + stored = null; + reply(res, 409, { error: "plan-changed" }); + return; + } + stored = null; + const request = { plan: structuredClone(current.collected.plan), consent: true }; + Promise.resolve() + .then(() => runInstall(request, record)) + .then(outcomeView, () => outcomeView(null)) + .then((view) => { + lastOutcome = view; + installing = false; + lastActivity = now(); + }); + reply(res, 202, { started: true }); + } + + function progress(query, res) { + const params = new URLSearchParams(query); + const keys = [...params.keys()]; + const after = params.get("after") ?? "0"; + if (keys.some((key) => key !== "after") || keys.length > 1 || !/^(0|[1-9]\d{0,15})$/.test(after)) { + reply(res, 400, { error: "invalid-request" }); + return; + } + const from = Number(after); + reply(res, 200, { entries: log.filter((entry) => entry.seq > from), running: installing, outcome: lastOutcome }); + } + + async function session(query, res) { + const params = new URLSearchParams(query); + const supplied = params.get("code"); + const valid = code !== null && now() <= codeExpires && [...params.keys()].length === 1 && supplied !== null && sameSecret(code, supplied); + if (!valid) { + reply(res, 401, { error: "unauthorized" }); + return; + } + code = null; + lastActivity = now(); + try { + onRedeemed(); + } catch { + // Cleanup hooks are best effort and never break the session. + } + reply(res, 200, sessionPage, { + "Content-Type": "text/html; charset=utf-8", + "Set-Cookie": `${COOKIE}=${secret}; HttpOnly; SameSite=Strict; Path=/`, + }); + } + + async function asset(path, res) { + let content; + try { + content = await readFile(join(assetsDir, assets[path].file)); + } catch { + reply(res, 404, { error: "not-found" }); + return; + } + reply(res, 200, content, { "Content-Type": assets[path].type }); + } + + async function handle(req, res) { + // Only authenticated requests (and a code redemption) count as activity. + if (req.headers.host !== `${HOST}:${port}`) return reply(res, 421, { error: "misdirected" }); + const raw = String(req.url ?? ""); + const mark = raw.indexOf("?"); + const path = mark === -1 ? raw : raw.slice(0, mark); + const query = mark === -1 ? "" : raw.slice(mark + 1); + const method = routes[Object.hasOwn(routes, path) ? path : ""]; + if (method === undefined) return reply(res, 404, { error: "not-found" }); + if (req.method !== method) return reply(res, 405, { error: "method-not-allowed" }, { Allow: method }); + // Browsers send Origin on cross-origin GETs; only our own origin may. + if (req.headers.origin !== undefined && req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" }); + if (path === "/session") return session(query, res); + if (!hasSession(req)) return reply(res, 401, { error: "unauthorized" }); + lastActivity = now(); + // A custom header makes every cross-origin API fetch need a CORS preflight, which never succeeds. + if (path.startsWith("/api/") && req.headers["x-gentle-install"] !== "1") return reply(res, 403, { error: "forbidden" }); + if (method === "POST") { + if (req.headers.origin !== origin) return reply(res, 403, { error: "forbidden" }); + const type = String(req.headers["content-type"] ?? "").split(";")[0].trim().toLowerCase(); + if (type !== "application/json") return reply(res, 415, { error: "unsupported-media-type" }); + } + if (query !== "" && path !== "/api/progress") return reply(res, 400, { error: "invalid-request" }); + if (Object.hasOwn(assets, path)) return asset(path, res); + if (path === "/api/plan") return getPlan(res); + if (path === "/api/progress") return progress(query, res); + if (path === "/api/install") return install(req, res); + // /api/shutdown + const body = await readJson(req, res); + if (body === undefined) return undefined; + if (body.value !== null && !(plainObject(body.value) && Object.keys(body.value).length === 0)) { + return reply(res, 400, { error: "invalid-request" }); + } + if (installing) return reply(res, 409, { error: "install-running" }); + res.once("finish", () => close("shutdown")); + reply(res, 200, { closing: true }); + return undefined; + } + + function checkIdle() { + if (closing || installing || now() - lastActivity < limit.idleMs) return false; + close("idle"); + return true; + } + + function close(reason) { + closing ??= new Promise((resolve) => { + if (idleTimer) clearInterval(idleTimer); + code = null; + server.close(() => resolve()); + server.closeIdleConnections(); + setImmediate(() => server.closeAllConnections()); + }).then(() => { + resolveClosed({ reason, outcome: lastOutcome }); + }); + return closing; + } + + async function listen() { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen({ host: HOST, port: 0, exclusive: true }, () => { + server.off("error", reject); + resolve(); + }); + }); + const address = server.address(); + if (typeof address !== "object" || address === null || address.address !== HOST || address.family !== "IPv4") { + await close("bind-mismatch"); + throw new Error("Wizard host is not bound to 127.0.0.1"); + } + port = address.port; + origin = `http://${HOST}:${port}`; + codeExpires = now() + limit.codeTtlMs; + lastActivity = now(); + idleTimer = setInterval(checkIdle, limit.idleCheckMs); + idleTimer.unref(); + return { port, address: address.address, origin, url: `${origin}/session?code=${code}` }; + } + + return { listen, close, closed, checkIdle, outcome: () => lastOutcome }; +} diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index e1a726171..4f7e380d5 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -7,8 +7,24 @@ import { fileURLToPath, pathToFileURL } from "node:url"; const root = join(fileURLToPath(new URL("..", import.meta.url))); -const requiredPaths = [ +// Browser installation wizard: bootstrap scripts, entry, host and static assets. +export const installerPaths = Object.freeze([ + "bin/gentle-shell-install.mjs", + "scripts/installer-server.mjs", + "scripts/installer-runner.mjs", + "scripts/installer-preflight.mjs", + "scripts/installer-probes.mjs", + "scripts/installer-downloads.mjs", + "scripts/installer-windows.mjs", + "scripts/installer-windows-artifacts.json", + "scripts/bootstrap.sh", + "scripts/bootstrap.cmd", + "assets/install-wizard/index.html", +]); + +export const requiredPaths = [ "bin/gentle-shell.mjs", + ...installerPaths, "assets/orchestrator.md", "assets/orchestrator-delegation.md", "assets/orchestrator-memory.md", @@ -173,6 +189,14 @@ const contractHashes = { requiredPaths.push(...Object.keys(contractHashes)); +// Required package-relative paths that are missing or are not regular files. +export function missingRequiredPaths(packageRoot, paths) { + return paths.filter((relativePath) => { + const absolutePath = join(packageRoot, relativePath); + return !existsSync(absolutePath) || !statSync(absolutePath).isFile(); + }); +} + function listFilesRecursively(directory) { return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { const absolutePath = join(directory, entry.name); @@ -287,10 +311,7 @@ async function main() { process.exit(1); } - const missing = requiredPaths.filter((relativePath) => { - const absolutePath = join(root, relativePath); - return !existsSync(absolutePath) || !statSync(absolutePath).isFile(); - }); + const missing = missingRequiredPaths(root, requiredPaths); if (missing.length > 0) { console.error("gentle-pi package is missing required Pi resources:"); diff --git a/tests/installer-runner.test.ts b/tests/installer-runner.test.ts index ed29013d6..2ebf15133 100644 --- a/tests/installer-runner.test.ts +++ b/tests/installer-runner.test.ts @@ -5,7 +5,23 @@ import { join } from "node:path"; import test from "node:test"; import { gentleAiBinaryPath } from "../runtime/gentle-ai-binary.mjs"; import { persistencePins, planPreflight, requirements } from "../scripts/installer-preflight.mjs"; -import { PI_INSTALL_VERSION, packageNativeGentleAi, runStandardInstall } from "../scripts/installer-runner.mjs"; +import { + PI_INSTALL_VERSION, + blockedReasons, + failedSteps, + packageNativeGentleAi, + runStandardInstall as runUnobserved, +} from "../scripts/installer-runner.mjs"; + +// Every scenario below runs through this wrapper, which records the blocked +// reasons and failed steps the runner actually returns. +const observed = { reasons: new Set(), steps: new Set() }; +async function runStandardInstall(...args: Parameters) { + const result = await runUnobserved(...args); + if (result.outcome === "blocked") observed.reasons.add(result.reason); + if (result.outcome === "failed") observed.steps.add(result.failedStep); + return result; +} const HOME = "/home/u"; const PNPM_HOME = "/home/u/.local/share/pnpm"; @@ -833,3 +849,24 @@ test("persistence variants are accepted only as fixed sets", async () => { const altered = { ...base, actions: [{ ...npmAction, version: "11.20.0" }, ...base.actions] }; assert.equal((await runStandardInstall({ plan: altered, consent: true }, harness().adapters)).reason, "invalid-request"); }); + +test("gates without PNPM_HOME or an absolute host Node block before any command", async () => { + const h = harness(); + const noHome = await runStandardInstall({ plan: plan(), consent: true }, { ...h.adapters, env: { PATH: "/usr/bin" } }); + assert.equal(noHome.reason, "pnpm-home-unknown"); + const relativeNode = await runStandardInstall({ plan: plan(), consent: true }, { ...h.adapters, nodePath: "node" }); + assert.equal(relativeNode.reason, "node-unavailable"); + assert.deepEqual(h.calls, []); +}); + +// Declared last: node:test runs a file's top-level tests in order. +test("exported blocked reasons and failed steps match what the scenarios observed", () => { + assert.ok(Object.isFrozen(blockedReasons) && Object.isFrozen(failedSteps)); + assert.equal(new Set(blockedReasons).size, blockedReasons.length); + assert.equal(new Set(failedSteps).size, failedSteps.length); + for (const reason of observed.reasons) assert.ok(blockedReasons.includes(reason), reason); + for (const step of observed.steps) assert.ok(failedSteps.includes(step), step); + // Every reason is reached; only the single-manager add failures have no scenario above. + assert.deepEqual(blockedReasons.filter((reason) => !observed.reasons.has(reason)), []); + assert.deepEqual(failedSteps.filter((step) => !observed.steps.has(step)), ["persist-npm", "persist-pnpm"]); +}); diff --git a/tests/installer-server.test.ts b/tests/installer-server.test.ts new file mode 100644 index 000000000..9261aa571 --- /dev/null +++ b/tests/installer-server.test.ts @@ -0,0 +1,759 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import * as fsPromises from "node:fs/promises"; +import { request as httpRequest } from "node:http"; +import { connect } from "node:net"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { exitCodeFor, openBrowser, openerFor, redirectPage, runnerEnvironment, writeRedirect } from "../bin/gentle-shell-install.mjs"; +import { planPreflight } from "../scripts/installer-preflight.mjs"; +import { blockedReasons, failedSteps } from "../scripts/installer-runner.mjs"; +import { createInstallerServer, guidance } from "../scripts/installer-server.mjs"; + +type Response = { status: number; headers: Record; body: string }; +type Plan = ReturnType; +type Collected = { inventory: Record; plan: Plan }; +type Log = (entry: Record) => void; +type RunInstall = (request: { plan: Plan; consent: boolean }, log: Log) => Promise>; + +const BIN = "/home/u/.local/share/pnpm/bin"; +// Every /api/* request carries the custom header, so cross-origin fetches need a preflight. +const API = { "x-gentle-install": "1" }; +const cleanInventory = { + platform: "linux", + arch: "x64", + node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false }, + pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }, + pi: { available: false }, + shell: { available: false }, + gentleAi: { available: false }, + go: { available: false }, + globalBin: { available: true, path: BIN, writable: true, onPath: false }, + setup: false, +}; +function collected(changes: Record = {}): Collected { + const inventory = { ...cleanInventory, ...changes }; + return { inventory, plan: planPreflight(inventory) }; +} + +const assetsDir = mkdtempSync(join(tmpdir(), "gentle-install-server-")); +writeFileSync(join(assetsDir, "index.html"), "fixture\n"); +writeFileSync(join(assetsDir, "wizard.js"), "// fixture\n"); +test.after(() => rmSync(assetsDir, { recursive: true, force: true })); + +function send(port: number, { method = "GET", path = "/", headers = {} as Record, body = undefined as string | undefined, + host = `127.0.0.1:${port}` as string | null } = {}): Promise { + return new Promise((resolve, reject) => { + const all: Record = { ...headers }; + if (host !== null) all.host = host; + if (body !== undefined) all["content-length"] = Buffer.byteLength(body); + const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); + }); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} + +async function start({ collect = async () => collected(), runInstall = (async () => ({ outcome: "ready", completed: [] })) as RunInstall, + limits = {}, clock = { value: 1_000_000 } } = {}) { + let collects = 0; + const runs: Array<{ request: { plan: Plan; consent: boolean } }> = []; + const host = createInstallerServer({ + assetsDir, + collectPlan: async () => { + collects += 1; + return collect(); + }, + runInstall: async (request: { plan: Plan; consent: boolean }, log: Log) => { + runs.push({ request }); + return runInstall(request, log); + }, + now: () => clock.value, + limits, + }); + const { port, url, address } = await host.listen(); + const origin = `http://127.0.0.1:${port}`; + const code = new URL(url).searchParams.get("code") ?? ""; + const login = async () => { + const response = await send(port, { path: `/session?code=${code}` }); + assert.equal(response.status, 200); + const cookie = String(response.headers["set-cookie"]).split(";")[0]; + return cookie; + }; + return { host, port, url, address, origin, code, login, runs, clock, collects: () => collects }; +} + +function post(port: number, path: string, cookie: string, body: unknown, headers: Record = {}) { + return send(port, { + method: "POST", + path, + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { cookie, origin: `http://127.0.0.1:${port}`, "x-gentle-install": "1", "content-type": "application/json", ...headers }, + }); +} +async function plan(port: number, cookie: string) { + const response = await send(port, { path: "/api/plan", headers: { cookie, ...API } }); + assert.equal(response.status, 200); + return JSON.parse(response.body); +} +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +async function waitFor(check: () => Promise | boolean) { + for (let i = 0; i < 200; i += 1) { + if (await check()) return; + await new Promise((done) => setTimeout(done, 5)); + } + throw new Error("condition not reached"); +} + +test("binds only 127.0.0.1 on an ephemeral port and prints a one-time session URL", async () => { + const { host, port, url, address } = await start(); + try { + assert.equal(address, "127.0.0.1"); + assert.ok(port > 0); + assert.match(url, new RegExp(`^http://127\\.0\\.0\\.1:${port}/session\\?code=[A-Za-z0-9_-]{43}$`)); + const ipv6 = await new Promise((resolve) => { + const socket = connect({ host: "::1", port }); + socket.once("connect", () => { socket.destroy(); resolve("connected"); }); + socket.once("error", () => resolve("refused")); + }); + assert.equal(ipv6, "refused"); + } finally { + await host.close("test"); + } +}); + +test("Host must be exactly 127.0.0.1: before any authorization", async () => { + const { host, port, login } = await start(); + try { + const cookie = await login(); + for (const value of [`localhost:${port}`, `evil:${port}`, "127.0.0.1", `127.0.0.1:${port + 1}`, `[::1]:${port}`, null]) { + const response = await send(port, { path: "/api/plan", headers: { cookie, ...API }, host: value }); + assert.equal(response.status, 421, String(value)); + assert.doesNotMatch(response.body, /actions|planId/); + } + } finally { + await host.close("test"); + } +}); + +test("session code is single-use, expires, and sets a strict HttpOnly cookie", async () => { + const fresh = await start(); + try { + const response = await send(fresh.port, { path: `/session?code=${fresh.code}` }); + // 200 with a same-origin refresh, not a 303: a redirect chain started from the + // file: redirect page is cross-site, so the Strict cookie could be withheld on `/`. + assert.equal(response.status, 200); + assert.equal(response.headers.location, undefined); + assert.equal(response.headers["content-type"], "text/html; charset=utf-8"); + assert.equal(response.headers["content-security-policy"], + "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); + assert.ok(response.body.includes('')); + assert.ok(!/ { + const { host, port, login } = await start(); + try { + for (const path of ["/", "/wizard.js", "/api/plan", "/api/progress"]) { + assert.equal((await send(port, { path })).status, 401, path); + assert.equal((await send(port, { path, headers: { cookie: "gentle_install_session=forged" } })).status, 401, path); + } + const cookie = await login(); + const page = await send(port, { path: "/", headers: { cookie } }); + assert.equal(page.status, 200); + assert.match(String(page.headers["content-type"]), /^text\/html/); + assert.equal(page.body, readFileSync(join(assetsDir, "index.html"), "utf8")); + const script = await send(port, { path: "/wizard.js", headers: { cookie } }); + assert.equal(script.status, 200); + assert.match(String(script.headers["content-type"]), /^text\/javascript/); + assert.equal((await send(port, { path: "/wizard.css", headers: { cookie } })).status, 404); + } finally { + await host.close("test"); + } +}); + +test("POST needs the exact Origin and custom header; GET rejects a foreign Origin", async () => { + const { host, port, login, runs } = await start(); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + const body = { planId, consent: true }; + assert.equal((await post(port, "/api/install", cookie, body, { origin: "http://evil.test" })).status, 403); + assert.equal((await post(port, "/api/install", cookie, body, { origin: `http://localhost:${port}` })).status, 403); + assert.equal((await post(port, "/api/install", cookie, body, { origin: "null" })).status, 403); + const noOrigin = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body), + headers: { cookie, "x-gentle-install": "1", "content-type": "application/json" } }); + assert.equal(noOrigin.status, 403); + const noHeader = await send(port, { method: "POST", path: "/api/install", body: JSON.stringify(body), + headers: { cookie, origin: `http://127.0.0.1:${port}`, "content-type": "application/json" } }); + assert.equal(noHeader.status, 403); + assert.equal((await post(port, "/api/install", cookie, body, { "x-gentle-install": "true" })).status, 403); + assert.equal((await post(port, "/api/install", cookie, body, { "content-type": "text/plain" })).status, 415); + assert.equal((await send(port, { path: "/api/plan", headers: { cookie, ...API, origin: "http://evil.test" } })).status, 403); + assert.equal(runs.length, 0); + } finally { + await host.close("test"); + } +}); + +test("/api/plan is built server-side and explains profile and runtime persistence", async () => { + const { host, port, login, collects } = await start(); + try { + const cookie = await login(); + const view = await plan(port, cookie); + assert.equal(collects(), 1); + assert.match(view.planId, /^[A-Za-z0-9_-]{16,}$/); + assert.deepEqual(view.actions.map((action: { id: string }) => action.id), collected().plan.actions.map((action) => action.id)); + for (const action of view.actions) { + assert.deepEqual(Object.keys(action).sort(), ["description", "id"]); + assert.ok(action.description.length > 10); + } + assert.equal(view.profileChange.changesProfile, true); + assert.equal(view.profileChange.binDir, BIN); + assert.equal(view.profileChange.command, "pnpm setup"); + assert.match(view.profileChange.description, /pnpm setup/); + assert.deepEqual(view.persistence.tools, ["node", "npm", "pnpm"]); + assert.equal(view.persistence.pnpmHome, "/home/u/.local/share/pnpm"); + assert.match(view.persistence.description, /PNPM_HOME/); + assert.deepEqual(view.blockers, []); + // The plan endpoint accepts no client input. + assert.equal((await post(port, "/api/plan", cookie, { plan: collected().plan })).status, 405); + assert.equal((await send(port, { path: "/api/plan?plan=x", headers: { cookie, ...API } })).status, 400); + } finally { + await host.close("test"); + } +}); + +test("/api/plan reports blockers with guidance and no profile change when already on PATH", async () => { + const { host, port, login } = await start({ collect: async () => collected({ + node: { available: true, version: "24.18.0", usable: true, persistent: true, npm: true }, + pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true }, + globalBin: { available: true, path: BIN, writable: true, onPath: true }, + pi: { available: null }, + }) }); + try { + const view = await plan(port, await login()); + assert.deepEqual(view.actions, []); + assert.equal(view.blockers.length, 1); + assert.equal(view.blockers[0].code, "unknown-tool"); + assert.equal(view.blockers[0].tool, "pi"); + assert.ok(view.blockers[0].guidance.length > 20); + assert.equal(view.profileChange.changesProfile, false); + assert.deepEqual(view.persistence.tools, []); + } finally { + await host.close("test"); + } +}); + +test("install body must be exactly { planId, consent: true }; the runner is not called otherwise", async () => { + const { host, port, login, runs } = await start(); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + for (const body of [ + { planId }, + { planId, consent: false }, + { planId, consent: "true" }, + { planId, consent: true, extra: 1 }, + { planId, consent: true, plan: { actions: [] } }, + { consent: true }, + [planId, true], + "not json", + "null", + ]) { + const response = await post(port, "/api/install", cookie, body); + assert.equal(response.status, 400, JSON.stringify(body)); + } + const oversized = await post(port, "/api/install", cookie, JSON.stringify({ planId, consent: true, pad: "x".repeat(2048) })); + assert.equal(oversized.status, 413); + // Without Content-Length, the streamed body is bounded too. + const chunked = await new Promise((resolve, reject) => { + const req = httpRequest({ host: "127.0.0.1", port, method: "POST", path: "/api/install", agent: false, headers: { + cookie, origin: `http://127.0.0.1:${port}`, "x-gentle-install": "1", "content-type": "application/json", + "transfer-encoding": "chunked" } }, (res) => { + res.resume(); + resolve(res.statusCode ?? 0); + }); + req.on("error", reject); + req.write(`{"planId":"${planId}","pad":"${"x".repeat(700)}`); + req.end(`${"y".repeat(700)}","consent":true}`); + }); + assert.equal(chunked, 413); + // A whole client plan never fits the 1 KiB body bound. + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true, plan: collected().plan })).status, 413); + assert.equal(runs.length, 0); + } finally { + await host.close("test"); + } +}); + +test("stale planId or a changed re-inventory returns 409 plan-changed without running", async () => { + let current = collected(); + const { host, port, login, runs } = await start({ collect: async () => current }); + try { + const cookie = await login(); + const first = await plan(port, cookie); + const second = await plan(port, cookie); + assert.notEqual(first.planId, second.planId); + const stale = await post(port, "/api/install", cookie, { planId: first.planId, consent: true }); + assert.equal(stale.status, 409); + assert.equal(JSON.parse(stale.body).error, "plan-changed"); + current = collected({ globalBin: { available: true, path: BIN, writable: true, onPath: true } }); + const changed = await post(port, "/api/install", cookie, { planId: second.planId, consent: true }); + assert.equal(changed.status, 409); + assert.equal(JSON.parse(changed.body).error, "plan-changed"); + assert.equal(runs.length, 0); + } finally { + await host.close("test"); + } +}); + +test("install runs the server-stored plan once, single-flight, and reports progress with guidance", async () => { + const gate = deferred(); + const runInstall: RunInstall = async (_request, log) => { + log({ step: "check-npm", status: "done" }); + await gate.promise; + log({ step: "install-global", status: "failed" }); + return { outcome: "failed", failedStep: "install-global", completed: ["check-npm"] }; + }; + const { host, port, login, runs } = await start({ runInstall }); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + const started = await post(port, "/api/install", cookie, { planId, consent: true }); + assert.equal(started.status, 202); + assert.equal(runs.length, 1); + assert.deepEqual(runs[0].request, { plan: collected().plan, consent: true }); + const again = await post(port, "/api/install", cookie, { planId, consent: true }); + assert.equal(again.status, 409); + assert.equal(JSON.parse(again.body).error, "install-running"); + assert.equal((await send(port, { path: "/api/plan", headers: { cookie, ...API } })).status, 409); + const shutdown = await post(port, "/api/shutdown", cookie, {}); + assert.equal(shutdown.status, 409); + gate.resolve(); + let progress: { entries: Array>; running: boolean; outcome: Record | null } = { + entries: [], running: true, outcome: null }; + await waitFor(async () => { + progress = JSON.parse((await send(port, { path: "/api/progress?after=0", headers: { cookie, ...API } })).body); + return progress.running === false; + }); + assert.deepEqual(progress.entries, [ + { seq: 1, step: "check-npm", status: "done", reason: null }, + { seq: 2, step: "install-global", status: "failed", reason: null }, + ]); + assert.equal(progress.outcome?.outcome, "failed"); + assert.equal(progress.outcome?.failedStep, "install-global"); + assert.equal(progress.outcome?.guidance, guidance.failed["install-global"]); + const later = JSON.parse((await send(port, { path: "/api/progress?after=1", headers: { cookie, ...API } })).body); + assert.deepEqual(later.entries.map((entry: { seq: number }) => entry.seq), [2]); + assert.equal((await send(port, { path: "/api/progress?after=-1", headers: { cookie, ...API } })).status, 400); + assert.equal((await send(port, { path: "/api/progress?after=1&x=2", headers: { cookie, ...API } })).status, 400); + assert.equal(host.outcome()?.outcome, "failed"); + } finally { + gate.resolve(); + await host.close("test"); + } +}); + +test("progress log is a bounded ring buffer with restricted fields", async () => { + const runInstall: RunInstall = async (_request, log) => { + for (let i = 0; i < 300; i += 1) log({ step: `step-${i}`, status: "done", stdout: "secret /home/u/.npmrc", reason: undefined }); + log({ step: "Bad Step; rm -rf", status: "done\n", reason: "x".repeat(500) }); + return { outcome: "blocked", reason: "existing-stack", completed: [], stdout: "secret" }; + }; + const { host, port, login } = await start({ runInstall }); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202); + let progress: { entries: Array>; running: boolean; outcome: Record } | null = null; + await waitFor(async () => { + progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body); + return progress?.running === false; + }); + const { entries, outcome } = progress!; + assert.equal(entries.length, 200); + assert.equal(entries[0].seq, 102); + for (const entry of entries) assert.deepEqual(Object.keys(entry).sort(), ["reason", "seq", "status", "step"]); + assert.deepEqual(entries.at(-1), { seq: 301, step: "unknown", status: "unknown", reason: "unknown" }); + assert.doesNotMatch(JSON.stringify(progress), /secret|npmrc/); + assert.equal(outcome.outcome, "blocked"); + assert.equal(outcome.reason, "existing-stack"); + assert.equal(outcome.guidance, guidance.blocked["existing-stack"]); + } finally { + await host.close("test"); + } +}); + +test("a throwing runner becomes a failed outcome with generic guidance", async () => { + const { host, port, login } = await start({ runInstall: async () => { throw new Error("/home/u/secret path"); } }); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202); + await waitFor(() => host.outcome() !== null); + const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body); + assert.equal(progress.outcome.outcome, "failed"); + assert.equal(progress.outcome.guidance, guidance.fallback); + assert.doesNotMatch(JSON.stringify(progress), /secret/); + } finally { + await host.close("test"); + } +}); + +test("only the fixed allowlist is routed: traversal and unknown paths 404, wrong methods 405", async () => { + const { host, port, login } = await start(); + try { + const cookie = await login(); + for (const path of ["/../package.json", "/%2e%2e/package.json", "/..%2fpackage.json", "/api%2fplan", "/api/run", "/index.html/..", + "/wizard.js/../../package.json", "//wizard.js", "/api/plan/", "/favicon.ico", "/assets/index.html"]) { + const response = await send(port, { path, headers: { cookie } }); + assert.equal(response.status, 404, path); + assert.doesNotMatch(response.body, /gentle-pi|"version"/); + } + assert.equal((await post(port, "/api/run", cookie, {})).status, 404); + assert.equal((await send(port, { method: "DELETE", path: "/api/plan", headers: { cookie, ...API } })).status, 405); + assert.equal((await send(port, { method: "GET", path: "/api/install", headers: { cookie } })).status, 405); + assert.equal((await send(port, { method: "GET", path: "/api/shutdown", headers: { cookie } })).status, 405); + assert.equal((await post(port, "/", cookie, {})).status, 405); + assert.equal((await send(port, { method: "PUT", path: "/session?code=x" })).status, 405); + } finally { + await host.close("test"); + } +}); + +test("every response carries strict headers and never CORS headers", async () => { + const { host, port, login } = await start(); + try { + const cookie = await login(); + const responses = [ + await send(port, { path: "/", headers: { cookie } }), + await send(port, { path: "/api/plan", headers: { cookie, ...API } }), + await send(port, { path: "/api/plan" }), + await send(port, { path: "/nope", headers: { cookie } }), + await send(port, { path: "/", host: "evil:1" }), + await send(port, { method: "OPTIONS", path: "/api/install", + headers: { cookie, origin: "http://evil.test", "access-control-request-method": "POST" } }), + await send(port, { path: "/session?code=bad" }), + ]; + for (const response of responses) { + assert.equal(response.headers["content-security-policy"], + "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"); + assert.equal(response.headers["x-content-type-options"], "nosniff"); + assert.equal(response.headers["cache-control"], "no-store"); + assert.equal(response.headers["referrer-policy"], "no-referrer"); + assert.equal(response.headers["x-frame-options"], "DENY"); + assert.equal(response.headers["cross-origin-resource-policy"], "same-origin"); + assert.equal(response.headers["cross-origin-opener-policy"], "same-origin"); + assert.deepEqual(Object.keys(response.headers).filter((name) => name.startsWith("access-control-")), []); + } + assert.equal(responses[5].status, 405); + } finally { + await host.close("test"); + } +}); + +test("shutdown closes the server; closed reports the last outcome", async () => { + const { host, port, login } = await start(); + const cookie = await login(); + const { planId } = await plan(port, cookie); + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202); + await waitFor(() => host.outcome() !== null); + const response = await post(port, "/api/shutdown", cookie, {}); + assert.equal(response.status, 200); + const closed = await host.closed; + assert.equal(closed.reason, "shutdown"); + assert.equal(closed.outcome?.outcome, "ready"); + await assert.rejects(send(port, { path: "/api/plan", headers: { cookie, ...API } })); +}); + +test("idle timeout closes the server, but never while installing", async () => { + const gate = deferred(); + const clock = { value: 5_000 }; + const { host, port, login } = await start({ clock, limits: { idleMs: 60_000 }, runInstall: async () => { + await gate.promise; + return { outcome: "ready", completed: [] }; + } }); + try { + const cookie = await login(); + const { planId } = await plan(port, cookie); + clock.value += 59_000; + assert.equal(host.checkIdle(), false); + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202); + clock.value += 120_000; + assert.equal(host.checkIdle(), false); + gate.resolve(); + await waitFor(() => host.outcome() !== null); + clock.value += 60_000; + assert.equal(host.checkIdle(), true); + const closed = await host.closed; + assert.equal(closed.reason, "idle"); + } finally { + gate.resolve(); + await host.close("test"); + } +}); + +test("guidance covers every runner blocked reason and failed step", () => { + assert.ok(blockedReasons.length >= 13 && failedSteps.length >= 14); + for (const reason of blockedReasons) assert.ok(typeof guidance.blocked[reason] === "string" && guidance.blocked[reason].length > 20, reason); + for (const step of failedSteps) assert.ok(typeof guidance.failed[step] === "string" && guidance.failed[step].length > 20, step); + // No stale guidance for a reason or step the runner no longer has. + assert.deepEqual(Object.keys(guidance.blocked).sort(), [...blockedReasons].sort()); + assert.deepEqual(Object.keys(guidance.failed).sort(), [...failedSteps].sort()); + for (const code of ["unsupported-target", "unknown-tool", "incompatible-tool"]) assert.ok(guidance.blockers[code].length > 20, code); + assert.ok(guidance.fallback.length > 20); + assert.ok(Object.isFrozen(guidance) && Object.isFrozen(guidance.blocked) && Object.isFrozen(guidance.failed)); +}); + +test("entry helpers: fixed per-platform opener, exit codes and runner environment", async () => { + // The opener receives the private redirect file, never the session URL. + const file = "/tmp/gentle-shell-install-Ab12/open.html"; + assert.deepEqual(openerFor("darwin", file, {}), { command: "/usr/bin/open", args: [file] }); + assert.deepEqual(openerFor("linux", file, {}), { command: "xdg-open", args: [file] }); + const winFile = "C:\\Users\\u\\AppData\\Local\\Temp\\gentle-shell-install-Ab12\\open.html"; + assert.deepEqual(openerFor("win32", winFile, { SystemRoot: "C:\\Windows" }), + { command: "C:\\Windows\\System32\\rundll32.exe", args: ["url.dll,FileProtocolHandler", winFile] }); + assert.equal(openerFor("win32", winFile, { SystemRoot: "relative" }), null); + assert.equal(openerFor("freebsd", file, {}), null); + + assert.equal(exitCodeFor({ outcome: "ready" }), 0); + assert.equal(exitCodeFor({ outcome: "terminal-action-required" }), 0); + for (const outcome of [{ outcome: "blocked" }, { outcome: "failed" }, null]) assert.equal(exitCodeFor(outcome), 1); + + const TOOLS = "/home/u/.gentle-shell-bootstrap-tools.Ab12"; + const files = new Set([`${TOOLS}/pnpm/bin/pnpm`, "/usr/bin/pnpm"]); + const fs = { isFile: async (path: string) => files.has(path) }; + const wizardEnv = { HOME: "/home/u", PATH: `${TOOLS}/node/bin:${TOOLS}/pnpm/bin:/opt/bin:/bin` }; + // A bootstrap-only pnpm stays reachable only as the last PATH entry; other tool dirs are dropped. + assert.deepEqual(await runnerEnvironment({ platform: "linux", env: wizardEnv, fs }), + { HOME: "/home/u", PATH: `/opt/bin:/bin:${TOOLS}/pnpm/bin` }); + files.add("/opt/bin/pnpm"); + assert.deepEqual(await runnerEnvironment({ platform: "linux", env: wizardEnv, fs }), { HOME: "/home/u", PATH: "/opt/bin:/bin" }); + const windowsEnv = { USERPROFILE: "C:\\Users\\u", Path: `C:\\Users\\u\\AppData\\Local\\.gentle-shell-bootstrap-tools.1\\node;C:\\Windows`, + GENTLE_INSTALL_PNPM_NODE: "C:\\t\\node.exe", GENTLE_INSTALL_PNPM_ENTRY: "C:\\t\\pnpm.mjs" }; + assert.deepEqual(await runnerEnvironment({ platform: "win32", env: windowsEnv, fs }), + { ...windowsEnv, Path: "C:\\Windows" }); +}); + +test("every /api/* request needs X-Gentle-Install: 1 before any state change", async () => { + const { host, port, login, collects } = await start(); + try { + const cookie = await login(); + for (const value of [undefined, "0", "true"]) { + const headers: Record = { cookie }; + if (value !== undefined) headers["x-gentle-install"] = value; + assert.equal((await send(port, { path: "/api/plan", headers })).status, 403, String(value)); + assert.equal((await send(port, { path: "/api/progress", headers })).status, 403, String(value)); + } + assert.equal(collects(), 0); + const { planId } = await plan(port, cookie); + assert.equal(collects(), 1); + // A rejected plan request neither probes nor rotates the stored plan. + assert.equal((await send(port, { path: "/api/plan", headers: { cookie } })).status, 403); + assert.equal(collects(), 1); + assert.equal((await post(port, "/api/install", cookie, { planId, consent: true })).status, 202); + // Assets are page navigations and need no custom header. + assert.equal((await send(port, { path: "/", headers: { cookie } })).status, 200); + } finally { + await host.close("test"); + } +}); + +test("only authenticated traffic extends the idle timeout", async () => { + const clock = { value: 10_000 }; + const { host, port } = await start({ clock, limits: { idleMs: 60_000 } }); + try { + clock.value += 59_000; + // Wrong Host, missing cookie, bad codes and forged cookies do not count as activity. + await send(port, { path: "/api/plan", host: `localhost:${port}` }); + await send(port, { path: "/api/plan", headers: API }); + await send(port, { path: "/session?code=wrong" }); + await send(port, { path: "/", headers: { cookie: "gentle_install_session=forged" } }); + await send(port, { path: "/nope" }); + clock.value += 1_000; + assert.equal(host.checkIdle(), true); + assert.equal((await host.closed).reason, "idle"); + } finally { + await host.close("test"); + } + const active = await start({ clock, limits: { idleMs: 60_000 } }); + try { + clock.value += 59_000; + // Redeeming the code is activity, and so is an authenticated request. + const cookie = await active.login(); + clock.value += 59_000; + assert.equal(active.host.checkIdle(), false); + assert.equal((await send(active.port, { path: "/api/progress", headers: { cookie, ...API } })).status, 200); + clock.value += 59_000; + assert.equal(active.host.checkIdle(), false); + clock.value += 1_000; + assert.equal(active.host.checkIdle(), true); + } finally { + await active.host.close("test"); + } +}); + +test("a completed installation is final: later installs get 409 already-completed", async () => { + for (const result of [{ outcome: "ready", completed: [] }, { outcome: "failed", failedStep: "install-global", completed: [] }, + { outcome: "blocked", reason: "existing-stack", completed: [] }]) { + const { host, port, login, runs } = await start({ runInstall: async () => result }); + try { + const cookie = await login(); + const first = await plan(port, cookie); + assert.equal((await post(port, "/api/install", cookie, { planId: first.planId, consent: true })).status, 202); + await waitFor(() => host.outcome() !== null); + const second = await plan(port, cookie); + const again = await post(port, "/api/install", cookie, { planId: second.planId, consent: true }); + assert.equal(again.status, 409, result.outcome); + assert.equal(JSON.parse(again.body).error, "already-completed"); + assert.equal(runs.length, 1); + assert.equal(host.outcome()?.outcome, result.outcome); + const progress = JSON.parse((await send(port, { path: "/api/progress", headers: { cookie, ...API } })).body); + assert.equal(progress.outcome.outcome, result.outcome); + } finally { + await host.close("test"); + } + } +}); + +// The one-time code reaches the browser through a private redirect file, so it +// never appears in the opener's argv (visible to local process listings). +const redirectRoot = mkdtempSync(join(tmpdir(), "gentle-install-redirect-test-")); +test.after(() => rmSync(redirectRoot, { recursive: true, force: true })); +const SESSION_URL = "http://127.0.0.1:4000/session?code=Abc_123-xyz"; + +type Spawned = { command: string; args: string[]; options: Record }; +function fakeSpawn({ throws = false } = {}) { + const calls: Spawned[] = []; + const children: EventEmitter[] = []; + const spawn = (command: string, args: string[], options: Record) => { + calls.push({ command, args, options }); + if (throws) throw new Error("spawn failed"); + const child = Object.assign(new EventEmitter(), { unref() {} }); + children.push(child); + return child; + }; + return { spawn, calls, children }; +} + +test("redirect page escapes the URL and only refreshes to it", () => { + const page = redirectPage(SESSION_URL); + assert.ok(page.includes(``)); + assert.ok(!page.includes(" - -
-

Gentle Shell installation wizard

-

The local installation server is running and this browser session is connected.

-

The interactive installation steps are not available in this version yet, so nothing will be installed from this page.

-

What to do now

-
    -
  • Close this tab and return to the terminal.
  • -
  • Press Ctrl+C in the terminal to stop the wizard. The temporary installer tools are then removed.
  • -
  • To install Gentle Shell today, follow the installation instructions in the README.
  • -
+ + + +
+

Gentle Shell

+

Local installer

+
+
+ + +
+
+

Step 1 of 4 · Check

+

Checking this computer

+

Looking for Node.js, pnpm, Pi and Gentle Shell. Nothing changes until you confirm a plan.

+ + +
+
+
+
+

This page talks only to the installer running on this computer at 127.0.0.1. Closing the tab does not stop it; use Close installer or press Ctrl+C in the terminal.

+
diff --git a/assets/install-wizard/wizard.css b/assets/install-wizard/wizard.css new file mode 100644 index 000000000..ffaedc05e --- /dev/null +++ b/assets/install-wizard/wizard.css @@ -0,0 +1,1232 @@ +/* Gentle Shell installation wizard. + Dark only, local font stacks only (no web fonts, no external resources): + the host serves this file under `default-src 'self'`. Colors follow the + gentlemanprogramming.com tokens; every state also has a text label. */ + +:root { + color-scheme: dark; + --bg: #1a1218; + --bg-deep: #000000; + --surface: #20161e; + --surface-1: #241822; + --surface-2: #342230; + --border: #342230; + --border-soft: #ffffff17; + --text: #f6eff3; + --text-muted: #a78e9b; + --text-faint: #76616b; + --accent: #f095c8; + --accent-strong: #ff76c3; + --accent-soft: #ffb1dd; + --success: #b4e7c7; + --warning: #e0c27a; + --error: #ff718f; + --info: #a9c7ee; + --radius-sm: 8px; + --radius-md: 12px; + --radius-lg: 16px; + --radius-pill: 999px; + --ease: cubic-bezier(.4, 0, .2, 1); + --glow: 0 10px 30px #f095c84d; + --glow-hover: 0 14px 36px #f095c86b; + --shadow-panel: 0 24px 60px #00000073, 0 0 40px #f095c81a; + --font-sans: Inter, "SF Pro Text", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + --font-mono: "IosevkaTerm NF", "Iosevka Term", "JetBrains Mono", ui-monospace, "SFMono-Regular", Menlo, Consolas, "DejaVu Sans Mono", monospace; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +html { + background: var(--bg); + -webkit-text-size-adjust: 100%; + text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--bg); + color: var(--text); + font: 16px/1.6 var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + scrollbar-color: var(--surface-2) transparent; +} + +::selection { + background: #f095c84d; + color: var(--text); +} + +:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +a { + color: var(--accent); +} + +p { + margin: 0 0 12px; +} + +p:last-child { + margin-bottom: 0; +} + +code { + font-family: var(--font-mono); + font-size: .9em; + color: var(--accent-soft); + background: #ffffff0f; + border: 1px solid var(--border-soft); + border-radius: 6px; + padding: .08em .4em; + white-space: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + +.skip-link { + position: absolute; + top: -100px; + left: 16px; + z-index: 10; + padding: 10px 16px; + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--bg); + font-weight: 600; + text-decoration: none; +} + +.skip-link:focus { + top: 12px; +} + +/* Hero glow and a faint dot grid behind everything. */ +/* Faint dot grid behind everything; the hero glow lives behind the panel (.view::before). */ +.backdrop { + position: fixed; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.backdrop::after { + content: ""; + position: absolute; + inset: 0; + background-image: radial-gradient(#ffffff0d 1px, #0000 1px); + background-size: 26px 26px; + -webkit-mask-image: linear-gradient(180deg, #000 0%, #0000 70%); + mask-image: linear-gradient(180deg, #000 0%, #0000 70%); +} + +/* Top bar */ + +.topbar, +main, +.footer { + width: min(1040px, 100% - 32px); + margin-inline: auto; +} + +.topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 24px 0 8px; +} + +.wordmark { + display: inline-flex; + align-items: center; + gap: 10px; + margin: 0; + font-size: 18px; + font-weight: 800; + letter-spacing: -.02em; +} + +.wordmark-mark { + display: inline-grid; + place-items: center; + width: 34px; + height: 34px; + border: 1px solid #f095c866; + border-radius: var(--radius-sm); + background: linear-gradient(140deg, #f095c829, #f095c80a); + color: var(--accent); + font: 700 14px/1 var(--font-mono); + box-shadow: 0 6px 18px #f095c826; +} + +.wordmark-accent { + color: var(--accent); +} + +.topbar-note { + display: inline-flex; + align-items: center; + gap: 8px; + margin: 0; + padding: 5px 12px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-pill); + background: #ffffff08; + color: var(--text-muted); + font: 500 12px/1.4 var(--font-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 3px #b4e7c726; +} + +main { + flex: 1; + padding: 24px 0 56px; +} + +/* Stage indicator */ + +.stages-nav { + margin-bottom: 24px; +} + +.stages { + display: flex; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; + counter-reset: stage; +} + +.stage { + display: flex; + flex: 1; + align-items: center; + gap: 10px; + min-width: 0; + color: var(--text-muted); + font: 600 12px/1.2 var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; +} + +.stage:not(:last-child)::after { + content: ""; + flex: 1; + height: 1px; + min-width: 12px; + background: var(--border); +} + +.stage-done:not(:last-child)::after { + background: linear-gradient(90deg, #b4e7c799, #f095c866); +} + +.stage-dot { + display: inline-grid; + flex: none; + place-items: center; + width: 28px; + height: 28px; + border: 1px solid var(--border); + border-radius: 50%; + background: var(--surface); + font-size: 12px; + letter-spacing: 0; +} + +.stage-current { + color: var(--text); +} + +.stage-current .stage-dot { + border-color: var(--accent); + background: var(--accent); + color: var(--bg); + box-shadow: 0 0 0 4px #f095c826, var(--glow); +} + +.stage-done { + color: var(--success); +} + +.stage-done .stage-dot { + border-color: #b4e7c799; + background: #b4e7c726; + color: var(--success); +} + +.stage-issue { + color: var(--warning); +} + +.stage-issue .stage-dot { + border-color: #e0c27a99; + background: #e0c27a26; + color: var(--warning); +} + +/* Panel and typography */ + +/* Hero glow from the reference site, behind the panel. Decorative and static: + a pseudo-element is not in the accessibility tree and never takes input. */ +.view { + position: relative; + isolation: isolate; +} + +.view::before { + content: ""; + position: absolute; + /* Wider than the panel on large screens, never wider than the viewport: + the side inset stops 16px inside the page edge. */ + inset: -120px max(-12%, calc((100% - 100vw) / 2 + 16px)) -40px; + z-index: -1; + pointer-events: none; + background: + radial-gradient(38% 50% at 30% 30%, #f095c838, #0000 70%), + radial-gradient(30% 40% at 75% 40%, #b4e7c71f, #0000 70%); + filter: blur(32px); +} + +.panel { + position: relative; + overflow: hidden; + padding: clamp(24px, 4.2vw, 52px); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: linear-gradient(180deg, #241822f2, #20161ef2); + box-shadow: var(--shadow-panel); + animation: rise .35s var(--ease) both; +} + +.panel::before { + content: ""; + position: absolute; + inset: 0 0 auto; + height: 2px; + background: linear-gradient(90deg, #0000, var(--accent), #0000); + opacity: .7; +} + +.panel-success::before { + background: linear-gradient(90deg, #0000, var(--success), var(--accent), #0000); +} + +.panel-warning::before { + background: linear-gradient(90deg, #0000, var(--warning), #0000); +} + +.panel-error::before { + background: linear-gradient(90deg, #0000, var(--error), #0000); +} + +.eyebrow { + margin: 0 0 12px; + color: var(--accent); + font: 600 12px/1.4 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; +} + +h1 { + max-width: 22ch; + margin: 0 0 16px; + font-size: clamp(34px, 4.6vw, 58px); + font-weight: 800; + line-height: 1.08; + letter-spacing: -.022em; + color: var(--text); + background: linear-gradient(100deg, var(--text) 20%, var(--accent)); + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; +} + +/* Each new step moves focus to its heading so screen readers announce it. The + heading is not interactive, so it shows no ring; every control keeps its + :focus-visible outline. */ +h1[tabindex="-1"]:focus { + outline: none; +} + +h2 { + margin: 0; +} + +.lead { + max-width: 62ch; + margin-bottom: 0; + color: var(--text-muted); + font-size: clamp(17px, 1.6vw, 18px); +} + +.block { + margin-top: 36px; +} + +.section-title { + margin: 0 0 14px; + font-size: 18px; + font-weight: 700; + letter-spacing: -.01em; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); + gap: 16px; +} + +/* Cards and pills */ + +.card { + padding: 22px 24px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + transition: border-color .2s var(--ease), box-shadow .2s var(--ease); +} + +.card p { + color: var(--text-muted); + font-size: 15px; +} + +.disclosure-changes { + border-color: #f095c859; + background: linear-gradient(180deg, #f095c80d, #0000 60%), var(--surface); + box-shadow: inset 0 1px 0 #f095c81f; +} + +.card-head { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px 12px; + margin-bottom: 10px; +} + +.card-title { + font-size: 16px; + font-weight: 700; +} + +.detail { + margin-top: 12px; +} + +.detail code { + display: block; + white-space: normal; + overflow-wrap: anywhere; + padding: 8px 12px; + border-color: var(--border); + border-radius: var(--radius-sm); + background: var(--bg-deep); + color: var(--text); + font-size: 13px; +} + +.pill { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + border: 1px solid var(--border-soft); + border-radius: var(--radius-pill); + font: 600 12px/1.4 var(--font-mono); + letter-spacing: .02em; + white-space: nowrap; +} + +.pill-accent { + border-color: #f095c84d; + background: #f095c81f; + color: var(--accent-soft); +} + +.pill-muted { + background: #ffffff0a; + color: var(--text-muted); +} + +/* Plan steps */ + +.plan-steps, +.next-steps { + margin: 0; + padding: 0; + list-style: none; + counter-reset: item; +} + +.plan-steps li, +.next-steps li { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 12px 0; + border-bottom: 1px solid var(--border-soft); + counter-increment: item; +} + +.plan-steps li:last-child, +.next-steps li:last-child { + border-bottom: 0; +} + +.plan-steps li::before, +.next-steps li::before { + content: counter(item, decimal-leading-zero); + display: inline-grid; + flex: none; + place-items: center; + width: 32px; + height: 26px; + border: 1px solid #f095c84d; + border-radius: 6px; + background: #f095c812; + color: var(--accent); + font: 600 12px/1 var(--font-mono); +} + +.plan-step-text { + padding-top: 1px; +} + +/* Consent */ + +.consent { + display: flex; + align-items: flex-start; + gap: 14px; + margin-top: 32px; + padding: 18px 20px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface-1); + transition: border-color .2s var(--ease), box-shadow .2s var(--ease); +} + +.consent:has(.consent-input:checked) { + border-color: #f095c88c; + box-shadow: 0 0 0 3px #f095c81a; +} + +.consent:has(.consent-input[aria-invalid="true"]) { + border-color: var(--error); + box-shadow: 0 0 0 3px #ff718f1f; +} + +.consent-input { + flex: none; + width: 22px; + height: 22px; + margin: 1px 0 0; + accent-color: var(--accent); + cursor: pointer; +} + +.consent-label { + display: block; + font-weight: 600; + cursor: pointer; +} + +.hint { + margin: 4px 0 0; + color: var(--text-muted); + font-size: 14px; +} + +/* Buttons */ + +.actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 12px; + margin-top: 28px; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-height: 46px; + padding: 13px 24px; + border: 1px solid transparent; + border-radius: var(--radius-sm); + font: 600 15px/1.2 var(--font-sans); + cursor: pointer; + transition: transform .2s var(--ease), box-shadow .2s var(--ease), border-color .2s var(--ease), background-color .2s var(--ease), + color .2s var(--ease); +} + +.button-primary { + background: var(--accent); + color: var(--bg); + box-shadow: var(--glow); +} + +.button-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: var(--glow-hover); +} + +.button-primary:active:not(:disabled) { + transform: translateY(0); + background: var(--accent-strong); +} + +.button-secondary, +.button-ghost { + background: transparent; + color: var(--text); + border-color: var(--border); +} + +.button-ghost { + border-color: var(--border-soft); + color: var(--text-muted); +} + +.button-secondary:hover:not(:disabled), +.button-ghost:hover:not(:disabled) { + border-color: var(--accent); + color: var(--accent-soft); +} + +.button:disabled { + opacity: .55; + cursor: not-allowed; + box-shadow: none; +} + +/* Alerts */ + +.alert-region:not(:empty) { + margin-bottom: 16px; +} + +.alert { + padding: 16px 20px; + border: 1px solid var(--border); + border-left: 3px solid var(--info); + border-radius: var(--radius-md); + background: var(--surface); +} + +.alert .actions { + margin-top: 14px; +} + +.alert-title { + margin: 0 0 4px; + font-weight: 700; +} + +.alert-success { + border-left-color: var(--success); +} + +.alert-success .alert-title { + color: var(--success); +} + +.alert-warning { + border-left-color: var(--warning); +} + +.alert-warning .alert-title { + color: var(--warning); +} + +.alert-error { + border-left-color: var(--error); + box-shadow: 0 10px 30px #ff718f14; +} + +.alert-error .alert-title { + color: var(--error); +} + +.panel > .alert { + margin-top: 28px; +} + +.blockers { + display: grid; + gap: 12px; + margin: 28px 0 0; + padding: 0; + list-style: none; +} + +.blockers .alert-title { + font-size: 16px; +} + +.meta { + color: var(--text-muted); + font-size: 13px; +} + +/* Progress */ + +.progress { + margin-top: 32px; + padding: 18px 20px 20px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); +} + +.progress-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.progress-label { + color: var(--text-muted); + font: 600 12px/1.4 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; +} + +.progress-count { + font: 600 14px/1.4 var(--font-mono); +} + +progress { + display: block; + width: 100%; + height: 10px; + border: 0; + border-radius: var(--radius-pill); + overflow: hidden; + background: #ffffff0d; + -webkit-appearance: none; + appearance: none; +} + +progress::-webkit-progress-bar { + border-radius: var(--radius-pill); + background: #ffffff0d; +} + +progress::-webkit-progress-value { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--info), var(--accent-soft)); + box-shadow: 0 0 16px #ffb1dd66; + transition: inline-size .4s var(--ease); +} + +progress::-moz-progress-bar { + border-radius: var(--radius-pill); + background: linear-gradient(90deg, var(--info), var(--accent-soft)); +} + +.progress-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr)); + gap: 24px; + align-items: start; + margin-top: 8px; +} + +.progress-body .block { + margin-top: 24px; +} + +.steps { + display: grid; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.step { + display: grid; + grid-template-columns: 26px 1fr auto; + align-items: center; + gap: 12px; + padding: 9px 12px; + border: 1px solid transparent; + border-radius: 10px; +} + +.step-icon { + display: grid; + place-items: center; + width: 24px; + height: 24px; + border: 1.5px solid var(--border); + border-radius: 50%; + font: 700 12px/1 var(--font-mono); +} + +.step-label { + min-width: 0; + font-size: 15px; +} + +.step-status { + color: var(--text-muted); + font: 600 11px/1.4 var(--font-mono); + letter-spacing: .08em; + text-transform: uppercase; + white-space: nowrap; +} + +.step-done .step-icon { + border-color: #b4e7c799; + background: #b4e7c733; + color: var(--success); +} + +.step-done .step-status { + color: var(--success); +} + +.step-running { + border-color: #f095c833; + background: #f095c80f; +} + +.step-running .step-icon { + border-color: #ffffff1f; + border-top-color: var(--accent); + animation: spin .9s linear infinite; +} + +.step-running .step-status { + color: var(--accent-soft); +} + +.step-failed { + border-color: #ff718f40; + background: #ff718f0f; +} + +.step-failed .step-icon { + border-color: #ff718f80; + background: #ff718f1f; + color: var(--error); +} + +.step-failed .step-status { + color: var(--error); +} + +.step-blocked .step-icon { + border-color: #e0c27a80; + background: #e0c27a1f; + color: var(--warning); +} + +.step-blocked .step-status { + color: var(--warning); +} + +.step-pending .step-icon { + border-style: dashed; + border-color: var(--text-faint); +} + +.step-pending .step-label, +.step-skipped .step-label { + color: var(--text-muted); +} + +.step-skipped .step-icon { + color: var(--text-muted); +} + +/* Terminal-style log */ + +.log { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-deep); + box-shadow: inset 0 1px 0 #ffffff0a; +} + +.progress-body > .log { + margin-top: 24px; +} + +.log-head { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + border-bottom: 1px solid var(--border-soft); + background: #ffffff06; +} + +.log-dots { + display: inline-flex; + gap: 6px; +} + +.log-dots span { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff718f8c; +} + +.log-dots span:nth-child(2) { + background: #e0c27a8c; +} + +.log-dots span:nth-child(3) { + background: #b4e7c78c; +} + +.log-title { + color: var(--text-muted); + font: 600 12px/1.4 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; +} + +.log-body { + max-height: 360px; + overflow: auto; + padding: 12px 14px 14px; + font: 13px/1.7 var(--font-mono); +} + +.log-lines { + margin: 0; + padding: 0; + list-style: none; +} + +.log-line { + display: grid; + grid-template-columns: 4ch 8ch 1fr; + gap: 0 1ch; +} + +.log-seq { + color: var(--text-muted); +} + +.log-status { + color: var(--info); +} + +.log-done .log-status { + color: var(--success); +} + +.log-failed .log-status { + color: var(--error); +} + +.log-blocked .log-status { + color: var(--warning); +} + +.log-step { + color: var(--text); + overflow-wrap: anywhere; +} + +.log-reason { + grid-column: 3; + color: var(--text-muted); +} + +.log-empty { + margin: 0; + color: var(--text-muted); +} + +.log-empty::after { + content: "▍"; + margin-left: 4px; + color: var(--accent); + animation: blink 1.1s steps(1) infinite; +} + +[hidden] { + display: none !important; +} + +/* Outcome */ + +.command { + display: flex; + align-items: center; + gap: 12px; + margin-top: 16px; + padding: 12px 12px 12px 18px; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-deep); + font: 16px/1.4 var(--font-mono); +} + +.command-prompt { + color: var(--accent); +} + +.command .command-text { + padding: 0; + border: 0; + background: none; + color: var(--text); + font-size: 16px; +} + +.command .button { + min-height: 36px; + margin-left: auto; + padding: 6px 14px; + font-size: 14px; +} + +.notes, +.facts { + margin: 20px 0 0; + padding-left: 20px; + color: var(--text-muted); + font-size: 15px; +} + +.facts { + padding: 0; + list-style: none; +} + +.completed { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid var(--border-soft); +} + +.completed summary { + width: fit-content; + font-weight: 600; + cursor: pointer; +} + +.completed ul { + margin: 12px 0 0; + padding: 0; + list-style: none; + color: var(--text-muted); + font-size: 15px; +} + +.completed ul [aria-hidden="true"] { + color: var(--success); +} + +.completed .log { + margin-top: 12px; +} + +/* Loading */ + +.loader { + display: flex; + gap: 8px; + margin-top: 32px; +} + +.loader span { + width: 10px; + height: 10px; + border-radius: 50%; + background: var(--accent); + animation: pulse 1.2s var(--ease) infinite; +} + +.loader span:nth-child(2) { + animation-delay: .15s; +} + +.loader span:nth-child(3) { + animation-delay: .3s; +} + +noscript .alert { + margin-top: 24px; +} + +/* Footer */ + +.footer { + padding: 20px 0 32px; + border-top: 1px solid var(--border-soft); + color: var(--text-muted); + font-size: 13px; +} + +.footer strong { + color: var(--text); + font-weight: 600; +} + +/* Motion */ + +@keyframes rise { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: none; + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@keyframes pulse { + 0%, + 100% { + opacity: .25; + transform: scale(.8); + } + 50% { + opacity: 1; + transform: scale(1); + } +} + +@keyframes blink { + 50% { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: .001ms !important; + animation-iteration-count: 1 !important; + transition-duration: .001ms !important; + scroll-behavior: auto !important; + } + + .button-primary:hover:not(:disabled) { + transform: none; + } + + .loader span { + opacity: .7; + } +} + +/* Small screens */ + +@media (max-width: 600px) { + .topbar { + padding-top: 16px; + } + + .topbar-note { + padding: 4px 10px; + font-size: 11px; + } + + .stage { + gap: 6px; + } + + /* Visually hidden, still read: only the current stage shows its name. */ + .stage:not(.stage-current) .stage-label { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } + + .actions .button { + flex: 1 1 100%; + } + + .step { + gap: 10px; + padding: 8px 10px; + } + + .step-label { + font-size: 14px; + } + + .step-status { + font-size: 10px; + letter-spacing: .06em; + } + + .command .button { + flex: none; + } +} + +/* Windows High Contrast and other forced palettes */ + +@media (forced-colors: active) { + h1 { + background: none; + -webkit-text-fill-color: currentColor; + } + + .button, + .card, + .alert, + .panel { + border: 1px solid CanvasText; + } +} diff --git a/assets/install-wizard/wizard.js b/assets/install-wizard/wizard.js new file mode 100644 index 000000000..ba962a845 --- /dev/null +++ b/assets/install-wizard/wizard.js @@ -0,0 +1,767 @@ +// Gentle Shell installation wizard (browser side). +// +// Loaded as an ES module under the host's strict CSP (`default-src 'self'`): +// no inline code or styles, no HTML parsing of server data (DOM APIs and text +// nodes only) and no requests except same-origin `/api/*` calls. The pure +// models, renderers and the controller factory are exported so Node tests can +// load this file without a browser; it starts itself only inside a page. + +const API_HEADERS = Object.freeze({ "X-Gentle-Install": "1" }); +const LOG_LIMIT = 200; + +/** Human labels for every step the runner logs. Unknown ids are shown as-is. */ +export const stepLabels = Object.freeze({ + gate: "Safety checks", + "check-npm": "Check npm", + "check-global-bin": "Check the pnpm global bin directory", + "check-existing-stack": "Check for an existing installation", + "persist-node": "Install Node.js under PNPM_HOME", + "persist-package-managers": "Install npm and pnpm under PNPM_HOME", + "persist-npm": "Install npm under PNPM_HOME", + "persist-pnpm": "Install pnpm under PNPM_HOME", + "verify-persistent-runtime": "Verify the persistent Node.js and npm", + "verify-persistent-pnpm": "Verify the persistent pnpm", + "configure-npm-prefix": "Configure npm's global prefix", + "install-global": "Install Pi and Gentle Shell", + "verify-global-list": "Verify the installed packages", + "verify-shell-bin": "Verify the gentle-shell command", + "verify-gentle-ai": "Verify Gentle AI", + "shell-setup": "Run gentle-shell setup", + "persist-path": "Add the global bin directory to PATH", +}); +export function stepLabel(id) { + return typeof id === "string" && Object.hasOwn(stepLabels, id) ? stepLabels[id] : String(id); +} + +const toolLabels = Object.freeze({ + node: "Node.js", + pnpm: "pnpm", + pi: "Pi", + shell: "Gentle Shell", + gentleAi: "Gentle AI", + go: "Go", + globalBin: "pnpm global bin directory", + setup: "Gentle Shell setup", + target: "This computer", +}); +const statusText = Object.freeze({ + done: "Done", + failed: "Failed", + blocked: "Blocked", + running: "In progress", + pending: "Pending", + skipped: "Not run", + unknown: "Unknown", +}); +const stages = Object.freeze([ + ["check", "Check"], + ["review", "Review"], + ["install", "Install"], + ["done", "Done"], +]); + +function text(value) { + return typeof value === "string" ? value : ""; +} +function list(value) { + return Array.isArray(value) ? value : []; +} + +// --------------------------------------------------------------------------- +// Pure models +// --------------------------------------------------------------------------- + +/** + * The runner's fixed step sequence for a plan, used to show upcoming steps. + * It mirrors runStandardInstall; progressModel tolerates any divergence. + */ +export function expectedSteps(actionIds) { + const ids = new Set(list(actionIds)); + const addNpm = ids.has("persist-package-managers") || ids.has("persist-npm"); + const addPnpm = ids.has("persist-package-managers") || ids.has("persist-pnpm"); + // An npm that is about to be installed is checked after it is added. + const steps = [...(addNpm ? [] : ["check-npm"]), "check-global-bin", "check-existing-stack"]; + if (ids.has("persist-node")) { + steps.push("persist-node", "persist-package-managers", "verify-persistent-runtime", "check-npm", "configure-npm-prefix"); + } else if (addNpm || addPnpm) { + const add = ["persist-package-managers", "persist-npm", "persist-pnpm"].find((id) => ids.has(id)); + steps.push(add); + if (addNpm) steps.push("check-npm"); + if (addPnpm) steps.push("verify-persistent-pnpm"); + } + steps.push("install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"); + if (ids.has("setup-global-bin")) steps.push("persist-path"); + return steps; +} + +/** Review screen model for a GET /api/plan view. */ +export function planModel(view) { + const actions = list(view?.actions).map((action) => ({ id: text(action?.id), description: text(action?.description) })); + const blockers = list(view?.blockers).map((blocker) => ({ + code: text(blocker?.code), + tool: text(blocker?.tool), + toolLabel: Object.hasOwn(toolLabels, blocker?.tool) ? toolLabels[blocker.tool] : "A required tool", + guidance: text(blocker?.guidance), + })); + let kind = "install"; + if (blockers.length > 0) kind = "blocked"; + else if (actions.every((action) => action.id === "verify-readiness")) kind = "nothing"; + const profile = view?.profileChange ?? {}; + const persistence = view?.persistence ?? {}; + const tools = list(persistence.tools).filter((tool) => typeof tool === "string"); + return { + planId: text(view?.planId), + kind, + actions, + blockers, + disclosures: [ + { + id: "profile", + title: "Your PATH", + changes: profile.changesProfile === true, + state: profile.changesProfile === true ? "Will change" : "No change", + text: text(profile.description), + detail: typeof profile.binDir === "string" ? profile.binDir : null, + }, + { + id: "persistence", + title: "Runtimes under PNPM_HOME", + changes: tools.length > 0, + state: tools.length > 0 ? `Will add ${tools.join(", ")}` : "No change", + text: text(persistence.description), + detail: typeof persistence.pnpmHome === "string" ? persistence.pnpmHome : null, + }, + ], + steps: expectedSteps(actions.map((action) => action.id)), + }; +} + +function stepItem(id, status) { + return { id, label: stepLabel(id), status, statusText: statusText[status] }; +} + +/** + * Step list for the progress screen: logged entries in order, then the + * expected steps that have not run yet. The runner logs a step when it ends, + * so the first remaining step is the one in progress. + */ +export function progressModel(steps, entries, { running = false, outcome = null } = {}) { + const expected = list(steps); + const items = []; + let cursor = 0; + for (const entry of list(entries)) { + const at = expected.indexOf(entry?.step, cursor); + if (at !== -1) cursor = at + 1; + const status = ["done", "failed", "blocked"].includes(entry?.status) ? entry.status : "unknown"; + items.push(stepItem(text(entry?.step) || "unknown", status)); + } + expected.slice(cursor).forEach((id, index) => { + let status = "pending"; + if (outcome) status = "skipped"; + else if (running && index === 0) status = "running"; + items.push(stepItem(id, status)); + }); + return { + items, + done: items.filter((item) => item.status === "done").length, + total: items.length, + finished: Boolean(outcome), + }; +} + +/** Final screen model for every runner outcome. Guidance always comes from the host. */ +export function outcomeModel(outcome) { + const completed = list(outcome?.completed).map((id) => ({ id: text(id), label: stepLabel(text(id)) })); + const guidance = text(outcome?.guidance); + const notes = []; + if (outcome?.npmPrefix === "configured") notes.push("npm's user-level global prefix now points at PNPM_HOME."); + if (outcome?.npmPrefix === "unchanged") notes.push("npm's global prefix was already correct and was left unchanged."); + const base = { outcome: outcome?.outcome, guidance, completed, notes, reason: null, failedStep: null, command: null }; + if (outcome?.outcome === "ready") { + return { ...base, tone: "success", badge: "Installed", title: "Gentle Shell is ready", + lead: "Everything is installed and verified on this computer.", command: "gentle-shell", + next: ["Open any terminal.", "Run `gentle-shell` to start."] }; + } + if (outcome?.outcome === "terminal-action-required") { + return { ...base, tone: "success", badge: "One more step", title: "Open a new terminal", + lead: "Your PATH was updated, and only terminals opened from now on can see it.", command: "gentle-shell", + next: ["Open a new terminal window or tab.", "Run `gentle-shell` to start."] }; + } + if (outcome?.outcome === "blocked") { + return { ...base, tone: "warning", badge: "Blocked", title: "Installation blocked", + lead: "Nothing was installed. A safety check stopped the installation before any change.", reason: text(outcome.reason) || null, + next: ["Follow the guidance above.", "Run the installer again from your terminal."] }; + } + const id = text(outcome?.failedStep); + return { ...base, outcome: "failed", tone: "error", badge: "Failed", title: "Installation failed", + lead: id && id !== "unknown" + ? `The step “${stepLabel(id)}” failed. Steps that finished before it were kept; nothing was rolled back.` + : "The installation stopped unexpectedly. Steps that finished were kept; nothing was rolled back.", + failedStep: id && id !== "unknown" ? { id, label: stepLabel(id) } : null, + next: ["Follow the guidance above.", "Run the installer again from your terminal."] }; +} + +/** The only install request body: null until consent is explicit. */ +export function installBody(planId, consent) { + if (consent !== true || typeof planId !== "string" || planId.length === 0) return null; + return { planId, consent: true }; +} + +/** Maps a POST /api/install answer to the wizard's next step. */ +export function interpretInstall(status, body) { + const error = body?.error; + if (status === 202) return { next: "progress", message: "" }; + if (status === 409 && error === "plan-changed") { + return { next: "reload-plan", message: "This computer changed since you reviewed the plan. Review the updated plan and confirm again." }; + } + if (status === 409 && error === "install-running") return { next: "progress", message: "" }; + if (status === 409 && error === "already-completed") return { next: "outcome", message: "" }; + if (status === 401 || status === 403) return { next: "session", message: "" }; + if (status === 400) return { next: "error", message: "The installer rejected the request. Review the plan and try again." }; + return { next: "error", message: "The installer could not start. Check the terminal for details, then try again." }; +} + +/** Progress polling: quick while steps finish, slower while quiet, bounded on errors. */ +export const pollPolicy = Object.freeze({ initialMs: 500, maxMs: 4000, factor: 1.5, errorMaxMs: 8000, maxFailures: 5, maxIdle: 3 }); +export function initialPoll() { + return { delay: pollPolicy.initialMs, failures: 0, idle: 0, stop: false, reason: null }; +} +export function nextPoll(state, event) { + const policy = pollPolicy; + if (event.type === "error") { + const failures = state.failures + 1; + const stop = failures >= policy.maxFailures; + return { delay: Math.min(Math.max(state.delay, policy.initialMs) * 2, policy.errorMaxMs), failures, idle: state.idle, stop, + reason: stop ? "errors" : null }; + } + if (event.outcome) return { delay: policy.initialMs, failures: 0, idle: 0, stop: true, reason: "outcome" }; + // Neither running nor finished should not last: the host is in an unexpected state. + const idle = event.running ? 0 : state.idle + 1; + if (idle >= policy.maxIdle) return { delay: state.delay, failures: 0, idle, stop: true, reason: "stalled" }; + const delay = event.count > 0 ? policy.initialMs : Math.min(Math.round(state.delay * policy.factor), policy.maxMs); + return { delay, failures: 0, idle, stop: false, reason: null }; +} + +// --------------------------------------------------------------------------- +// Rendering: DOM APIs and text nodes only +// --------------------------------------------------------------------------- + +const headingOf = new WeakMap(); + +function el(doc, tag, attributes = {}, ...children) { + const node = doc.createElement(tag); + for (const [name, value] of Object.entries(attributes)) { + if (value === undefined || value === null || value === false) continue; + node.setAttribute(name, value === true ? "" : String(value)); + } + for (const child of children.flat()) { + if (child === undefined || child === null || child === false) continue; + node.append(typeof child === "string" ? doc.createTextNode(child) : child); + } + return node; +} + +/** Text with `code` spans: backtick segments become elements, never markup. */ +function rich(doc, value) { + return text(value).split("`").map((part, index) => (index % 2 === 1 ? el(doc, "code", {}, part) : doc.createTextNode(part))); +} + +function panel(doc, { stage, eyebrow, title, lead, tone }, ...children) { + const heading = el(doc, "h1", { id: "view-title", tabindex: "-1" }, title); + const node = el(doc, "section", { class: `panel${tone ? ` panel-${tone}` : ""}`, "aria-labelledby": "view-title", "data-stage": stage, + "data-tone": tone }, + el(doc, "p", { class: "eyebrow" }, eyebrow), + heading, + lead ? el(doc, "p", { class: "lead" }, rich(doc, lead)) : null, + ...children); + headingOf.set(node, heading); + return node; +} + +function actionButton(doc, label, variant, onClick) { + const node = el(doc, "button", { type: "button", class: `button button-${variant}` }, label); + node.addEventListener("click", (event) => { + event?.preventDefault?.(); + onClick(node); + }); + return node; +} + +/** Stage list; `stopped` marks the install stage when it ended blocked or failed. */ +export function renderStages(doc, current, { stopped = false } = {}) { + const index = stages.findIndex(([id]) => id === current); + return stages.map(([id, label], position) => { + let state = position < index ? "done" : position === index ? "current" : "upcoming"; + if (stopped && id === "install") state = "issue"; + const glyph = { done: "✓", issue: "!" }[state] ?? String(position + 1); + const note = { done: " (completed)", issue: " (stopped)" }[state]; + return el(doc, "li", { class: `stage stage-${state}`, "aria-current": state === "current" ? "step" : null }, + el(doc, "span", { class: "stage-dot", "aria-hidden": "true" }, glyph), + el(doc, "span", { class: "stage-label" }, label), + note ? el(doc, "span", { class: "sr-only" }, note) : null); + }); +} + +export function renderLoading(doc) { + return panel(doc, { stage: "check", eyebrow: "Step 1 of 4 · Check", title: "Checking this computer", + lead: "Looking for Node.js, pnpm, Pi and Gentle Shell. Nothing changes until you confirm a plan." }, + el(doc, "div", { class: "loader", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span"))); +} + +function disclosureCard(doc, item) { + return el(doc, "article", { class: `card disclosure${item.changes ? " disclosure-changes" : ""}` }, + el(doc, "div", { class: "card-head" }, + el(doc, "h2", { class: "card-title" }, item.title), + el(doc, "span", { class: `pill ${item.changes ? "pill-accent" : "pill-muted"}` }, + el(doc, "span", { "aria-hidden": "true" }, item.changes ? "● " : "○ "), item.state)), + el(doc, "p", {}, rich(doc, item.text)), + item.detail ? el(doc, "p", { class: "detail" }, el(doc, "code", {}, item.detail)) : null); +} + +/** Review screen. handlers: install({ consent, checkbox, button }), reload(), close(). */ +export function renderPlan(doc, model, handlers) { + const close = actionButton(doc, "Close installer", "ghost", () => handlers.close()); + if (model.kind === "blocked") { + return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Something needs attention first", + lead: "Nothing was changed. Resolve these items, then check again.", tone: "warning" }, + el(doc, "ul", { class: "blockers", role: "list" }, model.blockers.map((blocker) => el(doc, "li", { class: "alert alert-warning" }, + el(doc, "h2", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, "! "), blocker.toolLabel), + el(doc, "p", {}, rich(doc, blocker.guidance)), + el(doc, "p", { class: "meta" }, "Code: ", el(doc, "code", {}, blocker.code))))), + el(doc, "div", { class: "actions" }, actionButton(doc, "Check again", "primary", () => handlers.reload()), close)); + } + if (model.kind === "nothing") { + return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Gentle Shell is already set up", + lead: "This computer already has everything the wizard installs." }, + el(doc, "p", {}, rich(doc, "Run `gentle-shell` in a terminal. To upgrade, run `gentle-shell update`.")), + el(doc, "div", { class: "actions" }, close)); + } + const checkbox = el(doc, "input", { type: "checkbox", id: "consent", class: "consent-input", "aria-describedby": "consent-hint" }); + const install = actionButton(doc, "Install Gentle Shell", "primary", (button) => + handlers.install({ consent: checkbox.checked === true, checkbox, button })); + checkbox.addEventListener("change", () => { + if (checkbox.checked) checkbox.removeAttribute("aria-invalid"); + handlers.consentChanged?.(checkbox.checked === true); + }); + return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", title: "Review the installation plan", + lead: "Nothing changes until you confirm. This is exactly what the installer will do on this computer." }, + el(doc, "section", { class: "block", "aria-labelledby": "changes-title" }, + el(doc, "h2", { id: "changes-title", class: "section-title" }, "What changes on this computer"), + el(doc, "div", { class: "grid" }, model.disclosures.map((item) => disclosureCard(doc, item)))), + el(doc, "section", { class: "block", "aria-labelledby": "steps-title" }, + el(doc, "h2", { id: "steps-title", class: "section-title" }, "Installation steps"), + el(doc, "ol", { class: "plan-steps" }, model.actions.map((action) => el(doc, "li", {}, + el(doc, "span", { class: "plan-step-text" }, rich(doc, action.description)))))), + el(doc, "div", { class: "consent" }, + checkbox, + el(doc, "div", {}, + el(doc, "label", { for: "consent", class: "consent-label" }, "I reviewed this plan and agree to these changes on this computer."), + el(doc, "p", { id: "consent-hint", class: "hint" }, "The installer runs only the steps listed above. Existing installations are not replaced."))), + el(doc, "div", { class: "actions" }, install, close)); +} + +function logLine(doc, entry) { + const status = text(entry.status) || "unknown"; + return el(doc, "li", { class: `log-line log-${status}` }, + el(doc, "span", { class: "log-seq" }, String(entry.seq).padStart(3, "0")), + el(doc, "span", { class: "log-status" }, status), + el(doc, "span", { class: "log-step" }, text(entry.step)), + entry.reason ? el(doc, "span", { class: "log-reason" }, text(entry.reason)) : null); +} + +function logPanel(doc, entries) { + const lines = el(doc, "ol", { class: "log-lines" }); + const empty = el(doc, "p", { class: "log-empty" }, "Waiting for the first step…"); + const node = el(doc, "section", { class: "log", "aria-labelledby": "log-title" }, + el(doc, "div", { class: "log-head" }, + el(doc, "span", { class: "log-dots", "aria-hidden": "true" }, el(doc, "span"), el(doc, "span"), el(doc, "span")), + el(doc, "h2", { id: "log-title", class: "log-title" }, "Installer log")), + el(doc, "div", { class: "log-body", tabindex: "0", role: "region", "aria-label": "Installer log lines" }, empty, lines)); + const update = (all) => { + lines.replaceChildren(...all.map((entry) => logLine(doc, entry))); + if (all.length > 0) empty.setAttribute("hidden", ""); + else empty.removeAttribute("hidden"); + }; + update(entries); + return { node, update }; +} + +const stepIcons = Object.freeze({ done: "✓", failed: "✕", blocked: "!", running: "", pending: "", skipped: "–", unknown: "?" }); +function stepRow(doc, item) { + return el(doc, "li", { class: `step step-${item.status}`, "aria-current": item.status === "running" ? "step" : null }, + el(doc, "span", { class: "step-icon", "aria-hidden": "true" }, stepIcons[item.status]), + el(doc, "span", { class: "step-label" }, item.label), + el(doc, "span", { class: "step-status" }, item.statusText)); +} + +/** Progress screen with in-place updates, so focus stays on the heading. */ +export function renderProgress(doc) { + const bar = el(doc, "progress", { id: "install-progress", max: "1", value: "0", "aria-describedby": "progress-count" }); + const count = el(doc, "span", { id: "progress-count", class: "progress-count" }, "Starting…"); + const steps = el(doc, "ol", { class: "steps" }); + const log = logPanel(doc, []); + const node = panel(doc, { stage: "install", eyebrow: "Step 3 of 4 · Install", title: "Installing Gentle Shell", + lead: "This can take a few minutes. Keep this tab and the terminal open." }, + el(doc, "div", { class: "progress" }, + el(doc, "div", { class: "progress-head" }, el(doc, "label", { for: "install-progress", class: "progress-label" }, "Progress"), count), + bar), + el(doc, "div", { class: "progress-body" }, + el(doc, "section", { class: "block", "aria-labelledby": "progress-steps-title" }, + el(doc, "h2", { id: "progress-steps-title", class: "section-title" }, "Steps"), steps), + log.node)); + const update = (model, entries) => { + bar.setAttribute("max", String(Math.max(model.total, 1))); + bar.setAttribute("value", String(model.done)); + count.textContent = model.total > 0 ? `${model.done} of ${model.total} steps` : "Starting…"; + steps.replaceChildren(...model.items.map((item) => stepRow(doc, item))); + log.update(entries); + }; + return { node, update }; +} + +/** Final screen. handlers: close(), copy?(text) -> Promise. */ +export function renderOutcome(doc, model, handlers, entries = []) { + const children = [ + el(doc, "div", { class: `alert alert-${model.tone}` }, + el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, model.tone === "success" ? "✓ " : model.tone === "warning" ? "! " : "✕ "), + model.badge), + el(doc, "p", {}, rich(doc, model.guidance))), + ]; + if (model.command) { + const command = el(doc, "code", { class: "command-text" }, model.command); + const copy = handlers.copy + ? actionButton(doc, "Copy", "ghost", async (button) => { + const copied = await handlers.copy(model.command); + button.textContent = copied ? "Copied" : "Copy failed"; + }) + : null; + children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" }, + el(doc, "h2", { id: "next-title", class: "section-title" }, "Next"), + el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line))))), + el(doc, "div", { class: "command" }, el(doc, "span", { class: "command-prompt", "aria-hidden": "true" }, "$"), command, copy))); + } else { + children.push(el(doc, "section", { class: "block", "aria-labelledby": "next-title" }, + el(doc, "h2", { id: "next-title", class: "section-title" }, "What to do next"), + el(doc, "ol", { class: "next-steps" }, model.next.map((line) => el(doc, "li", {}, el(doc, "span", { class: "plan-step-text" }, rich(doc, line))))))); + } + if (model.notes.length > 0) children.push(el(doc, "ul", { class: "notes" }, model.notes.map((note) => el(doc, "li", {}, note)))); + const facts = []; + if (model.failedStep) facts.push(el(doc, "li", {}, "Failed step: ", el(doc, "code", {}, model.failedStep.id))); + if (model.reason) facts.push(el(doc, "li", {}, "Reason: ", el(doc, "code", {}, model.reason))); + if (facts.length > 0) children.push(el(doc, "ul", { class: "facts" }, facts)); + if (model.completed.length > 0) { + children.push(el(doc, "details", { class: "completed" }, + // A blocked run only passed read-only checks before it stopped. + el(doc, "summary", {}, `${model.outcome === "blocked" ? "Checks that passed" : "Completed steps"} (${model.completed.length})`), + el(doc, "ul", {}, model.completed.map((item) => el(doc, "li", {}, el(doc, "span", { "aria-hidden": "true" }, "✓ "), item.label))))); + } + if (entries.length > 0) { + const log = logPanel(doc, entries); + children.push(el(doc, "details", { class: "completed" }, el(doc, "summary", {}, `Installer log (${entries.length} entries)`), log.node)); + } + children.push(el(doc, "div", { class: "actions" }, actionButton(doc, "Close installer", model.tone === "success" ? "primary" : "secondary", + () => handlers.close()))); + return panel(doc, { stage: "done", eyebrow: "Step 4 of 4 · Done", title: model.title, lead: model.lead, tone: model.tone }, ...children); +} + +export function renderClosed(doc, outcome) { + const installed = outcome?.outcome === "ready" || outcome?.outcome === "terminal-action-required"; + return panel(doc, { stage: "done", eyebrow: "Finished", title: "Installer closed", + lead: installed + ? "You can close this tab. Run `gentle-shell` in a terminal to start." + : "You can close this tab. The terminal shows the same result, and nothing else will run." }); +} + +export function renderSession(doc) { + return panel(doc, { stage: "check", eyebrow: "Session ended", title: "This session has ended", + lead: "The installer link works once, in the browser that opened it, and stops when the installer closes. Run the installer again from your terminal to get a new link." }); +} + +// --------------------------------------------------------------------------- +// Controller +// --------------------------------------------------------------------------- + +function isProgress(body) { + return body !== null && typeof body === "object" && Array.isArray(body.entries); +} +const sessionEnded = (status) => status === 401 || status === 403; +const noAnswer = "The installer did not answer. Check that the terminal is still running it, then try again."; + +/** + * createWizard({ document, fetch, setTimeout, clearTimeout, clipboard? }) + * Expects #view, #stages, #alert (role=alert) and #status (role=status). + */ +export function createWizard({ document: doc, fetch: request, setTimeout: later, clearTimeout: cancel, clipboard = null }) { + const roots = { + view: doc.getElementById("view"), + stages: doc.getElementById("stages"), + alert: doc.getElementById("alert"), + status: doc.getElementById("status"), + }; + const state = { plan: null, steps: [], entries: [], lastSeq: 0, poll: initialPoll(), timer: null, outcome: null, busy: false, + progress: null, running: false }; + + async function api(path, { method = "GET", body } = {}) { + const init = { method, headers: { ...API_HEADERS }, credentials: "same-origin", cache: "no-store" }; + if (body !== undefined) { + init.headers["Content-Type"] = "application/json"; + init.body = JSON.stringify(body); + } + const response = await request(path, init); + let data = null; + try { + data = await response.json(); + } catch { + data = null; + } + return { status: response.status, body: data }; + } + + function announce(message) { + if (roots.status) roots.status.textContent = message; + } + function clearError() { + roots.alert?.replaceChildren(); + } + function showError(message, retry = null, { tone = "error", title = "Problem" } = {}) { + const retryButton = retry ? actionButton(doc, "Try again", "secondary", () => { + clearError(); + retry(); + }) : null; + roots.alert?.replaceChildren(el(doc, "div", { class: `alert alert-${tone}` }, + el(doc, "p", { class: "alert-title" }, el(doc, "span", { "aria-hidden": "true" }, tone === "error" ? "✕ " : "! "), title), + el(doc, "p", {}, message), + retryButton ? el(doc, "div", { class: "actions" }, retryButton) : null)); + } + function stopPolling() { + if (state.timer !== null) cancel(state.timer); + state.timer = null; + } + function show(node, { focus = true } = {}) { + const stage = node.getAttribute("data-stage"); + const stopped = stage === "done" && ["warning", "error"].includes(node.getAttribute("data-tone")); + roots.stages?.replaceChildren(...renderStages(doc, stage, { stopped })); + roots.view.replaceChildren(node); + if (focus) headingOf.get(node)?.focus(); + } + function ingest(entries) { + const fresh = list(entries).filter((entry) => Number.isInteger(entry?.seq) && entry.seq > state.lastSeq); + for (const entry of fresh) state.lastSeq = Math.max(state.lastSeq, entry.seq); + state.entries = [...state.entries, ...fresh].slice(-LOG_LIMIT); + return fresh; + } + + async function resume() { + clearError(); + let result; + try { + result = await api("/api/progress?after=0"); + } catch { + showError(noAnswer, resume); + return; + } + if (sessionEnded(result.status)) return showSession(); + if (result.status !== 200 || !isProgress(result.body)) { + showError(noAnswer, resume); + return; + } + ingest(result.body.entries); + if (result.body.outcome) return showOutcome(result.body.outcome); + if (result.body.running === true) return showProgress(); + return loadPlan(); + } + + async function loadPlan(notice = "") { + clearError(); + let result; + try { + result = await api("/api/plan"); + } catch { + showError(noAnswer, () => loadPlan(notice)); + return; + } + if (sessionEnded(result.status)) return showSession(); + if (result.status === 409 && result.body?.error === "install-running") return showProgress(); + if (result.status !== 200 || result.body === null || typeof result.body !== "object") { + showError("The installer could not check this computer. Check the terminal for details, then try again.", () => loadPlan(notice)); + return; + } + state.plan = planModel(result.body); + state.steps = state.plan.steps; + show(renderPlan(doc, state.plan, { install, reload: () => loadPlan(), close, consentChanged: (checked) => { if (checked) clearError(); } })); + if (notice) showError(notice, null, { tone: "warning", title: "Plan updated" }); + } + + async function install({ consent, checkbox, button }) { + if (state.busy) return; + const body = installBody(state.plan?.planId, consent); + if (body === null) { + showError("Check the box to confirm that you reviewed the plan. Nothing was installed."); + checkbox.setAttribute("aria-invalid", "true"); + checkbox.focus(); + return; + } + clearError(); + state.busy = true; + button.disabled = true; + let result; + try { + result = await api("/api/install", { method: "POST", body }); + } catch { + // The request may have reached the host before the connection broke: check + // once, and follow a running or finished installation instead of offering a retry. + const current = await currentProgress(); + state.busy = false; + if (current?.outcome) return showOutcome(current.outcome); + if (current?.running) return showProgress(); + button.disabled = false; + showError(noAnswer); + return; + } + state.busy = false; + const next = interpretInstall(result.status, result.body); + if (next.next === "progress") return showProgress(); + if (next.next === "reload-plan") return loadPlan(next.message); + if (next.next === "outcome") return resume(); + if (next.next === "session") return showSession(); + button.disabled = false; + showError(next.message, () => loadPlan()); + } + + /** One GET /api/progress?after=0, or null when the host does not answer usefully. */ + async function currentProgress() { + try { + const result = await api("/api/progress?after=0"); + if (result.status !== 200 || !isProgress(result.body)) return null; + ingest(result.body.entries); + return { running: result.body.running === true, outcome: result.body.outcome ?? null }; + } catch { + return null; + } + } + + function progressSnapshot() { + return progressModel(state.steps, state.entries, { running: state.running, outcome: state.outcome }); + } + + function showProgress() { + stopPolling(); + state.running = true; + state.poll = initialPoll(); + state.progress = renderProgress(doc); + state.progress.update(progressSnapshot(), state.entries); + show(state.progress.node); + announce("Installation started."); + schedule(state.poll.delay); + } + + function schedule(ms) { + stopPolling(); + state.timer = later(() => { + state.timer = null; + void pollOnce(); + }, ms); + } + + function pollFailed(event) { + state.poll = nextPoll(state.poll, event); + if (state.poll.stop) return pollStopped(); + schedule(state.poll.delay); + } + + function pollStopped() { + const message = state.poll.reason === "stalled" + ? "The installer stopped reporting progress. Check the terminal, then try again." + : "Lost contact with the installer. Check that the terminal is still running it, then try again."; + showError(message, () => { + state.poll = initialPoll(); + schedule(0); + }); + } + + async function pollOnce() { + let result; + try { + result = await api(`/api/progress?after=${state.lastSeq}`); + } catch { + return pollFailed({ type: "error" }); + } + if (sessionEnded(result.status)) return showSession(); + if (result.status !== 200 || !isProgress(result.body)) return pollFailed({ type: "error" }); + const fresh = ingest(result.body.entries); + const outcome = result.body.outcome ?? null; + state.running = result.body.running === true; + state.poll = nextPoll(state.poll, { type: "entries", count: fresh.length, running: state.running, outcome }); + if (outcome) return showOutcome(outcome); + clearError(); + state.progress?.update(progressSnapshot(), state.entries); + const last = fresh.at(-1); + if (last) announce(`${stepLabel(last.step)}: ${statusText[last.status] ?? last.status}.`); + if (state.poll.stop) return pollStopped(); + schedule(state.poll.delay); + } + + function showOutcome(outcome) { + stopPolling(); + state.outcome = outcome; + state.running = false; + const model = outcomeModel(outcome); + const copy = clipboard ? async (value) => { + try { + await clipboard.writeText(value); + announce("Command copied."); + return true; + } catch { + return false; + } + } : undefined; + show(renderOutcome(doc, model, { close, copy }, state.entries)); + announce(`${model.title}.`); + } + + function showSession() { + stopPolling(); + clearError(); + show(renderSession(doc)); + } + + async function close() { + if (state.busy) return; + state.busy = true; + let result; + try { + result = await api("/api/shutdown", { method: "POST", body: {} }); + } catch { + state.busy = false; + showError("The installer did not answer. It may already be closed; check the terminal."); + return; + } + state.busy = false; + if (result.status === 200) { + stopPolling(); + clearError(); + show(renderClosed(doc, state.outcome)); + announce("Installer closed."); + return; + } + if (sessionEnded(result.status)) return showSession(); + if (result.status === 409) { + showError("An installation is still running. Wait for it to finish, then close the installer."); + return; + } + showError("The installer could not close. Press Ctrl+C in the terminal to stop it."); + } + + return { start: resume, state }; +} + +if (typeof window !== "undefined" && typeof document !== "undefined" && typeof fetch === "function") { + const app = createWizard({ + document, + fetch: (...args) => fetch(...args), + setTimeout: (fn, ms) => window.setTimeout(fn, ms), + clearTimeout: (id) => window.clearTimeout(id), + clipboard: window.navigator?.clipboard ?? null, + }); + void app.start(); +} diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 0abfdeb36..d9ec7c1d1 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -2,12 +2,13 @@ The preflight planner, real host probes, POSIX bootstrap, Windows prerequisite foundation, the standard installation runner module (including runtime -persistence) and the secure local wizard host with its packaged entry are -implemented. Windows native validation remains unavailable locally. -**There is no interactive browser wizard or complete installation path yet:** -the served page is a static placeholder until the wizard UI ships (T6), so -nothing can be installed from the browser. -For ordinary installation and terminal use, follow the [README](../README.md). +persistence), the secure local wizard host with its packaged entry and the +interactive browser wizard UI are implemented. Windows native validation +remains unavailable locally. +**This is not a supported installation path yet:** published bootstrap +artifacts, real browsers and native clean-machine runs are still unverified +(T7). For ordinary installation and terminal use, follow the +[README](../README.md). ## What is available @@ -502,7 +503,7 @@ simply unknown paths. | --- | --- | | `GET /session?code=` | Consumes the one-time code, sets the cookie and returns 200 `text/html` that refreshes to `/`; 401 for a missing, wrong, used or expired code. | | `GET /`, `/wizard.js`, `/wizard.css` | `index.html`, `wizard.js`, `wizard.css` from `assetsDir`; 404 when absent. | -| `GET /api/plan` | Runs `collectPlan()` server-side, stores the plan under a new opaque `planId` and returns a view model: `actions` (`id` plus a fixed English description), `blockers` (`code`, `tool`, guidance), `profileChange` (whether `pnpm setup` edits the shell profile or Windows user PATH, and the bin directory), `persistence` (which of node, npm and pnpm go under `$PNPM_HOME`) and `ready`. 409 while installing. Accepts no input. | +| `GET /api/plan` | Runs `collectPlan()` server-side, stores the plan under a new opaque `planId` and returns a view model: `actions` (`id` plus a fixed English description), `blockers` (`code`, `tool` — a preflight tool key such as `node` or `gentleAi`, otherwise `unknown` — and guidance), `profileChange` (whether `pnpm setup` edits the shell profile or Windows user PATH, and the bin directory), `persistence` (which of node, npm and pnpm go under `$PNPM_HOME`) and `ready`. 409 while installing. Accepts no input. | | `POST /api/install` | Body exactly `{ "planId": string, "consent": true }` (400 otherwise, including extra keys or a plan). 409 `install-running` while an installation runs; 409 `already-completed` once an installation has a final outcome (one installation per wizard run; the outcome is never replaced); 409 `plan-changed` for a stale `planId` or when a fresh re-inventory differs from the stored plan. Otherwise 202, and the runner receives the server-stored plan with `consent: true`. | | `GET /api/progress?after=` | Entries `{ seq, step, status, reason }` after `seq` from a ring buffer of the last 200; values outside `[a-z][a-z0-9-]*` become `unknown`, and no other runner field is kept. Also `running` and the final `outcome` with fixed guidance. | | `POST /api/shutdown` | Body empty or `{}`. Closes the host (409 while installing). | @@ -515,21 +516,79 @@ arrays `blockedReasons` and `failedSteps`; a test requires guidance for exactly those entries, and the runner tests check that every reason and step their scenarios observe is listed. -### Placeholder page +### Wizard UI -`assets/install-wizard/index.html` is static HTML: it confirms the session, -says that installation steps are not available yet and points to the README. -The strict CSP blocks inline script and style, so it uses no script and only -legacy color attributes (background `#1a1218`, text `#f6eff3`, links `#f095c8`) -and `color-scheme: dark`; the system font stack and real styling arrive with -`wizard.css` in T6. With this placeholder the wizard always ends with exit 1 -(Ctrl+C or the idle close). +`assets/install-wizard/index.html`, `wizard.js` (an ES module) and +`wizard.css` are the whole browser side. They load only from the host itself +and follow its CSP: no inline script or style, no external fonts, images or +requests, and server strings are inserted as text nodes only (never parsed as +HTML). + +| Screen | What the user sees | +| --- | --- | +| Check | Shown while `GET /api/progress?after=0` and `GET /api/plan` run. A running installation resumes the Install screen; a finished one shows its outcome instead of a new plan. | +| Review | Each fixed action with its description, the `profileChange` and `persistence` disclosures (state as text: "Will change", "No change"), one consent checkbox and **Install Gentle Shell**. Without the checkbox no request is sent; the error is announced and focus moves to the checkbox. Preflight blockers replace the plan with their guidance, **Check again** and **Close installer**. | +| Install | Expected runner steps (`expectedSteps`) with a text status (Done, In progress, Pending, Failed, Blocked, Not run), a `` bar and a terminal-style log of `{ seq, step, status, reason }` entries. | +| Done | `ready`: run `gentle-shell`. `terminal-action-required`: open a new terminal, then run `gentle-shell`. `blocked`/`failed`: the host's guidance, the reason or failed step, the steps that finished and the log. Every outcome offers **Close installer** (`POST /api/shutdown`). | + +Behavior worth knowing: + +- `409 plan-changed` reloads the plan, clears consent and explains why; + `409 install-running` and `already-completed` resume the current state; 401 + or 403 ends the session with instructions to run the installer again. When + the install request itself fails on the network, the wizard checks + `/api/progress` once: a running or finished installation is followed + instead of an error, and the install request is never re-sent + automatically. +- Progress polling starts at 500 ms, backs off by 1.5× to 4 s while nothing + changes, resets when entries arrive and stops on the final outcome. Five + consecutive errors, or three answers that are neither running nor finished, + stop polling and offer **Try again**. The client keeps the last 200 log + entries, like the host. +- Accessibility: landmarks, one `h1` per screen that receives focus on each + step change (it is not interactive, so it shows no focus ring), a polite + `role="status"` region for progress and the closed state, an assertive + `role="alert"` region for errors, labelled controls, native buttons and + checkbox (keyboard operable), visible `:focus-visible` rings on every + control, text labels for every color state, `prefers-reduced-motion` and + `forced-colors` support. +- Visual language: the gentlemanprogramming.com dark palette (accent + `#f095c8` on `#1a1218`) and its static hero glow behind the panel, with + local font stacks only and a text wordmark; no logos, brand images or + favicon are copied. + +`wizard.js` exports its pure models (`planModel`, `progressModel`, +`outcomeModel`, `expectedSteps`, `installBody`, `interpretInstall`, +`nextPoll`), its renderers and `createWizard({ document, fetch, setTimeout, +clearTimeout, clipboard? })`; it starts itself only inside a page. +`tests/install-wizard.test.ts` drives the controller with a fake DOM (where +`innerHTML` throws), fake fetch and fake timers, and serves the real assets +through the real host. + +### Development preview + +`scripts/install-wizard-preview.mjs` starts the **real** host with the real +assets but a fake `collectPlan` and a fake runner, so it never probes the +machine, runs a package manager, downloads or installs anything: + +```sh +node scripts/install-wizard-preview.mjs --scenario=terminal --step-ms=600 +``` + +Scenarios: `ready`, `terminal` (`terminal-action-required` with runtime +persistence), `blocked` (`existing-stack`), `failed` (`install-global`), +`plan-changed` (the first plan goes stale, so the host answers 409) and +`preflight` (blockers, no plan). It prints the one-time session URL; Ctrl+C +or **Close installer** stops it. The preview is a development tool: it is not +listed in `installerPaths` (the entry never imports it), although it ships +with the rest of `scripts/` in the package. ### Remaining T6/T7 checks -- T6: the interactive `wizard.js`/`wizard.css` (plan, single consent, install, - progress polling, outcome, shutdown) must work under the CSP above; add both - to `installerPaths` when they ship; verify in a real browser. +- T6 (done): `wizard.js` and `wizard.css` are in `installerPaths`; the preview + scenarios ran end to end in headless Chromium 1148 at 1440 and 390 px wide, + including a keyboard-only consent and install, with no CSP violations. + Screen readers and other browsers were not tested. - T7: the opener on each desktop (including a missing `xdg-open`); that the redirect file opens in the browser rather than another `.html` handler, and that sandboxed browsers with a private `/tmp` (for example snap or flatpak diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md index 2bb3818ec..eeecf3421 100644 --- a/odd/tasks/browser-install-wizard.md +++ b/odd/tasks/browser-install-wizard.md @@ -40,7 +40,7 @@ Current setup requires a usable Node/Pi runtime and Windows native provisioning - [x] T3 — Windows clean-machine bootstrap foundation. Status: bounded implementation closed; commit `ffdc7f74e3cbd172a7c2233e40c6807a9bdd4b7a`. Writer/independent/parent each passed 98 tests with 13 unavailable native Windows cases; no failures. Real pinned pnpm archive integrity/format/metadata proof passed. Native review approved and exactly acknowledged, authority consumed; native Windows acceptance remains T7. Route: delegated direct (unfamiliar platform preparation and multiple non-trivial files). Surfaces: `scripts/bootstrap.cmd`, `scripts/installer-downloads.mjs`, optional narrow `scripts/installer-windows.mjs`/`scripts/installer-windows-artifacts.json`, `tests/installer-windows-bootstrap.test.ts`, `docs/install-wizard.md`. Initial forecast: 350–550 lines; ACL/reparse/quoting and integrity regression coverage may exceed this advisory estimate without compression. Checks: portable Windows contract tests plus POSIX/preflight regression, whitespace and explicitly gated native Windows entry tests. Native Windows/PowerShell unavailable locally; no support claim until T7 execution. Go helper remains lazy; T4 first establishes whether the normal planned operation needs Go despite reusable native Gentle AI. - [x] T4 — Standard installation driver. Status: bounded implementation closed; commit `0d9dac2cc90db922283f40cffa03958ba827fd42`. Writer, independent verifier and parent passed (final 134 tests/121 pass/0 fail/13 native Windows skips); native review approved and exactly acknowledged. Real-machine behavior remains T7. Route: delegated direct. Surfaces: `scripts/installer-runner.mjs`, `tests/installer-runner.test.ts`, `docs/install-wizard.md`. Forecast: 350–550 lines. Checks: focused runner tests; Pi/global package installation via pnpm, normal setup, partial failure and no alternate product roots. - [x] T5 — Secure local wizard host and packaged entry. Status: closed in two work units: T5a `e4cac184` (probes and runtime persistence) and T5b `00360106` (secure host, entry, placeholder, package verification); both independently verified and natively reviewed. Real-browser and native-platform behavior remain T7. Route: delegated direct. Surfaces: `bin/gentle-shell-install.mjs`, `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `package.json`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `docs/install-wizard.md`. Forecast: 350–500 lines. Checks: focused server/package tests; loopback binding, Host/Origin/session authorization, explicit install consent, bounded logs and no arbitrary command/path API. -- [ ] T6 — Accessible browser wizard and terminal handoff. Route: delegated direct. Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/verify-package-files.mjs`, `README.md`, `docs/install-wizard.md`. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. +- [ ] T6 — Accessible browser wizard and terminal handoff. Status: writer `murh4ohv-3-su3a` in progress (route: delegated direct, 2+ non-trivial files). Surfaces: `assets/install-wizard/index.html`, `assets/install-wizard/wizard.js`, `assets/install-wizard/wizard.css`, `tests/install-wizard.test.ts`, `scripts/install-wizard-preview.mjs` (dev preview with fake scenarios on the real server), `scripts/installer-server.mjs`, `tests/installer-server.test.ts`, `scripts/verify-package-files.mjs`, `tests/verify-package-files.test.ts`, `README.md`, `docs/install-wizard.md`. Visual reference: gentlemanprogramming.com tokens; visual checks use the local Playwright Chromium headless build with screenshots at 1440px and 390px. Forecast: 450–700 lines. Checks: focused UI tests plus Ego Browser with a fake driver; keyboard, consent, progress, failure/retry and handoff. - [ ] T7 — Cross-platform acceptance and parity evidence. Route: delegated direct. Surfaces: `.github/workflows/ci.yml`, `scripts/test-installer-runner.mjs`, `tests/installer-acceptance.test.ts`, `docs/install-wizard.md`. Forecast: 250–400 lines. Checks: deterministic matrix, explicit clean-machine native checks, preserved existing installs and parity with standard terminal launch. ## Acceptance criteria @@ -176,3 +176,12 @@ Split into two work units, each with its own verification, review and commit. - Commit `00360106be63dd61c0d9826a4947caef5693824a` (`feat(installer): add secure local wizard host and entry`): staged and committed trees equal frozen `633aeec8811b2c70771cc439a61ab9f9409d60c8`; 10 files, 1,765 insertions/14 deletions. - Nonblocking advisories for later: `R3-doc-exit-signal-mismatch` (`docs/install-wizard.md:440-445`), `R3-entry-main-untested` (`bin/gentle-shell-install.mjs:120-182`), `R3-expired-code-idle-30min` (`scripts/installer-server.mjs:451-455`), `R3-runner-coverage-test-order-dependent` (`tests/installer-runner.test.ts:862-872`), `R3-shutdown-test-no-finally` (`tests/installer-server.test.ts:486-498`). - T7 carries: real-browser session redemption from the `file:` redirect page, snap/flatpak private `/tmp`, default `.html` handler, opener per platform, Ctrl+C/SIGTERM process-group behavior. + +### T6 implementation evidence (uncommitted) +- Writer `murh4ohv-3-su3a`: `assets/install-wizard/wizard.js` (new, 749; pure models, DOM-API renderers, injectable controller), `wizard.css` (new, 1,214; one property per line), `index.html` (+37/-15), `tests/install-wizard.test.ts` (new, 711, 18 tests), `scripts/install-wizard-preview.mjs` (new, 140; six fake scenarios on the real host), `scripts/installer-server.mjs` (+6/-1, camelCase blocker tool names no longer collapse to `unknown`), server/package tests, docs (+70/-18), README row. About 2,950 lines. +- RED: `ERR_MODULE_NOT_FOUND` for wizard.js and package-path mismatch; then 4 expected failures (wizard.css missing, real-assets test, package paths, tool-name bug). GREEN 56/56 focused. Some visual-pass assertions (`(stopped)` marker) were added after implementation (no RED). +- Writer verification: 227 tests/214 pass/0 fail/13 native skips; package check passed (168 files); diff checks clean. CDP-driven Chromium headless (1148, `--no-sandbox` required on this host) reached all 6 scenarios with zero CSP violations (detector proven); keyboard-only consent and install flow worked. +- Parent visual review of screenshots (`/tmp/gentle-wizard-shots-vGp5Jf`): layout, tokens and states match the reference well. Findings: the programmatically focused h1 shows a large focus box on every screen; the hero radial glow from the reference is not visible. +- Independent verifier `murhvu02-4-0hoh`: PASS (227 tests/214 pass/0 fail/13 native skips; package check passed). No DOM injection, CSP violation, consent bypass or client control over actions; install body exactly `{planId, consent:true}`; plan-changed resets consent; bounded polling; keyboard-only flow; contrast minimum 4.93:1 for text. Browser checks on 5 scenarios had 0 CSP violations, exceptions or cross-origin requests (detector proven). +- Single scoped correction `muri8aqt-5-93wd`: suppress the outline on the programmatically focused h1 only; add the reference hero glow; after a network failure on the install POST, query progress once before offering retry; update the status region after shutdown; controller tests for `already-completed`, `install-running`, shutdown 409 and Copy; exact backoff assertion. Parent removed the leftover Chrome profile directories from both screenshot folders. +- Correction `muri8aqt-5-93wd` applied: RED observed for h1 outline, glow, closed-state announcement and POST network-failure resume; controller/backoff tests are characterization (no RED). Mobile overflow from the glow pseudo-element fixed with a bounded inset. Writer and parent spot check: 232 tests/219 pass/0 fail/13 native skips; package check passed (168 files); diff check clean. Parent reviewed the new screenshots (focus box gone, subtle glow visible) and removed all screenshot/verify temp directories; no preview or Chrome processes remain. Next: native review and commit. diff --git a/scripts/install-wizard-preview.mjs b/scripts/install-wizard-preview.mjs new file mode 100644 index 000000000..a59622aaa --- /dev/null +++ b/scripts/install-wizard-preview.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// Development preview for the browser installation wizard. +// +// Starts the REAL local host (createInstallerServer) with the real assets, but +// with fake inventories and a fake runner: it never runs host probes, package +// managers, downloads or installs. Use it to develop and visually check the UI: +// +// node scripts/install-wizard-preview.mjs --scenario=terminal --step-ms=600 +// +// Then open the printed one-time session URL. Not part of the installer entry. + +import { fileURLToPath, pathToFileURL } from "node:url"; +import { planPreflight } from "./installer-preflight.mjs"; +import { createInstallerServer } from "./installer-server.mjs"; + +const BIN = "/home/you/.local/share/pnpm/bin"; +const baseInventory = Object.freeze({ + platform: "linux", + arch: "x64", + node: { available: true, version: "24.21.0", usable: true, persistent: true, npm: true }, + pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: true }, + pi: { available: false }, + shell: { available: false }, + gentleAi: { available: false }, + go: { available: false }, + globalBin: { available: true, path: BIN, writable: true, onPath: true }, + setup: false, +}); +// A machine that only has the bootstrap's temporary Node and pnpm, with no pnpm bin on PATH. +const freshInventory = Object.freeze({ + ...baseInventory, + node: { available: true, version: "24.21.0", usable: true, persistent: false, npm: false }, + pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }, + globalBin: { ...baseInventory.globalBin, onPath: false }, +}); +const checks = ["check-npm", "check-global-bin", "check-existing-stack"]; +const install = ["install-global", "verify-global-list", "verify-shell-bin", "verify-gentle-ai", "shell-setup"]; +const persistence = ["persist-node", "persist-package-managers", "verify-persistent-runtime", "check-npm", "configure-npm-prefix"]; + +// Each scenario mirrors the runner's fixed step order for its plan. +const scenarios = Object.freeze({ + ready: { inventories: [baseInventory], steps: [...checks, ...install], result: { outcome: "ready" } }, + terminal: { + inventories: [freshInventory], + steps: ["check-global-bin", "check-existing-stack", ...persistence, ...install, "persist-path"], + result: { outcome: "terminal-action-required", action: "open-new-terminal", npmPrefix: "configured" }, + }, + blocked: { + inventories: [baseInventory], + steps: ["check-npm", "check-global-bin"], + result: { outcome: "blocked", reason: "existing-stack" }, + }, + failed: { + inventories: [freshInventory], + steps: ["check-global-bin", "check-existing-stack", ...persistence], + result: { outcome: "failed", failedStep: "install-global" }, + }, + // The first plan goes stale before install (the PATH was fixed meanwhile): the host + // answers 409 plan-changed and the wizard must show the new plan and ask again. + "plan-changed": { inventories: [freshInventory, baseInventory], steps: [...checks, ...install], result: { outcome: "ready" } }, + preflight: { + inventories: [{ ...baseInventory, node: { available: true, version: "18.20.0", usable: true, persistent: true, npm: true }, + gentleAi: { available: true, version: "1.0.0", usable: true, compatible: false } }], + steps: [], + result: { outcome: "blocked", reason: "preflight-blocked" }, + }, +}); +export const scenarioNames = Object.freeze(Object.keys(scenarios)); + +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Fake collectPlan/runInstall for a scenario; nothing touches the machine. */ +export function previewScenario(name, { stepMs = 600 } = {}) { + const scenario = scenarios[name]; + if (!scenario) throw new Error(`Unknown scenario "${name}". Use one of: ${scenarioNames.join(", ")}`); + let collected = 0; + return { + collectPlan: async () => { + const inventory = structuredClone(scenario.inventories[Math.min(collected, scenario.inventories.length - 1)]); + collected += 1; + await sleep(Math.min(stepMs, 400)); + return { inventory, plan: planPreflight(inventory) }; + }, + runInstall: async (_request, log) => { + const completed = []; + for (const step of scenario.steps) { + await sleep(stepMs); + completed.push(step); + log({ step, status: "done" }); + } + await sleep(stepMs); + const { result } = scenario; + if (result.outcome === "blocked") log({ step: "gate", status: "blocked", reason: result.reason }); + if (result.outcome === "failed") log({ step: result.failedStep, status: "failed" }); + return { ...result, completed }; + }, + }; +} + +/** Starts the real host on 127.0.0.1 with a fake scenario. Returns { host, url }. */ +export async function startPreview({ scenario = "ready", stepMs = 600, log = console.log } = {}) { + const fake = previewScenario(scenario, { stepMs }); + const host = createInstallerServer({ + assetsDir: fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)), + collectPlan: fake.collectPlan, + runInstall: fake.runInstall, + }); + const { url } = await host.listen(); + log(`Gentle Shell wizard preview (scenario: ${scenario}). Fake plan and fake installer: nothing is installed.`); + log(`Open this one-time link (expires in 2 minutes):\n ${url}`); + return { host, url }; +} + +function option(args, name, fallback) { + const found = args.find((arg) => arg.startsWith(`--${name}=`)); + return found === undefined ? fallback : found.slice(name.length + 3); +} + +async function main(args) { + if (args.includes("--help")) { + console.log(`Usage: node scripts/install-wizard-preview.mjs [--scenario=${scenarioNames.join("|")}] [--step-ms=600]`); + return; + } + const scenario = option(args, "scenario", "ready"); + const stepMs = Number(option(args, "step-ms", "600")); + if (!scenarioNames.includes(scenario) || !Number.isFinite(stepMs) || stepMs < 0) { + console.error(`Use --scenario=${scenarioNames.join("|")} and a non-negative --step-ms.`); + process.exitCode = 2; + return; + } + const { host } = await startPreview({ scenario, stepMs }); + process.once("SIGINT", () => host.close("signal")); + process.once("SIGTERM", () => host.close("signal")); + const { reason, outcome } = await host.closed; + console.log(`Preview closed (${reason}); last outcome: ${outcome?.outcome ?? "none"}.`); +} + +if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) { + await main(process.argv.slice(2)); +} diff --git a/scripts/installer-server.mjs b/scripts/installer-server.mjs index 8f019b82a..3534728f4 100644 --- a/scripts/installer-server.mjs +++ b/scripts/installer-server.mjs @@ -127,6 +127,11 @@ const ID = /^[a-z][a-z0-9-]{0,63}$/; function identifier(value, fallback = "unknown") { return typeof value === "string" && ID.test(value) ? value : fallback; } +// Preflight tool names include camelCase keys such as gentleAi and globalBin. +const TOOL = /^[a-z][A-Za-z0-9-]{0,63}$/; +function toolName(value) { + return typeof value === "string" && TOOL.test(value) ? value : "unknown"; +} function plainObject(value) { return typeof value === "object" && value !== null && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype; } @@ -162,7 +167,7 @@ function planView(planId, { inventory, plan }) { })), blockers: plan.blockers.map((blocker) => ({ code: identifier(blocker.code), - tool: identifier(blocker.tool), + tool: toolName(blocker.tool), guidance: guidance.blockers[blocker.code] ?? guidance.fallback, })), profileChange: { diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index 4f7e380d5..31093630b 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -20,6 +20,8 @@ export const installerPaths = Object.freeze([ "scripts/bootstrap.sh", "scripts/bootstrap.cmd", "assets/install-wizard/index.html", + "assets/install-wizard/wizard.js", + "assets/install-wizard/wizard.css", ]); export const requiredPaths = [ diff --git a/tests/install-wizard.test.ts b/tests/install-wizard.test.ts new file mode 100644 index 000000000..e7932fb5a --- /dev/null +++ b/tests/install-wizard.test.ts @@ -0,0 +1,863 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { request as httpRequest } from "node:http"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import * as wizard from "../assets/install-wizard/wizard.js"; +import { planPreflight } from "../scripts/installer-preflight.mjs"; +import { blockedReasons, failedSteps } from "../scripts/installer-runner.mjs"; +import { createInstallerServer, guidance } from "../scripts/installer-server.mjs"; +import { scenarioNames, startPreview } from "../scripts/install-wizard-preview.mjs"; + +const assetsDir = fileURLToPath(new URL("../assets/install-wizard/", import.meta.url)); +const read = (name: string) => readFileSync(join(assetsDir, name), "utf8"); +const API = { "x-gentle-install": "1" }; +const BIN = "/home/u/.local/share/pnpm/bin"; +// Backtick segments render as elements, so rendered text has no backticks. +const plain = (value: string) => value.replaceAll("`", ""); + +// --------------------------------------------------------------------------- +// Real HTTP helpers (the real host serving the real assets) +// --------------------------------------------------------------------------- + +type Response = { status: number; headers: Record; body: string }; +function send(port: number, { method = "GET", path = "/", headers = {} as Record, body = undefined as string | undefined } = {}): + Promise { + return new Promise((resolve, reject) => { + const all: Record = { host: `127.0.0.1:${port}`, ...headers }; + if (body !== undefined) all["content-length"] = Buffer.byteLength(body); + const req = httpRequest({ host: "127.0.0.1", port, method, path, headers: all, setHost: false, agent: false }, (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk) => chunks.push(chunk)); + res.on("end", () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: Buffer.concat(chunks).toString("utf8") })); + }); + req.on("error", reject); + if (body !== undefined) req.write(body); + req.end(); + }); +} +async function login(port: number, url: string) { + const code = new URL(url).searchParams.get("code") ?? ""; + const response = await send(port, { path: `/session?code=${code}` }); + assert.equal(response.status, 200); + return String(response.headers["set-cookie"]).split(";")[0]; +} +function post(port: number, path: string, cookie: string, body: unknown) { + return send(port, { method: "POST", path, body: JSON.stringify(body), headers: { cookie, origin: `http://127.0.0.1:${port}`, + "content-type": "application/json", ...API } }); +} +async function getJson(port: number, path: string, cookie: string) { + const response = await send(port, { path, headers: { cookie, ...API } }); + return { status: response.status, body: JSON.parse(response.body) }; +} + +const cleanInventory = { + platform: "linux", + arch: "x64", + node: { available: true, version: "24.18.0", usable: true, persistent: false, npm: false }, + pnpm: { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false }, + pi: { available: false }, + shell: { available: false }, + gentleAi: { available: false }, + go: { available: false }, + globalBin: { available: true, path: BIN, writable: true, onPath: false }, + setup: false, +}; + +/** The real host's /api/plan view for an inventory, so model tests never drift from the server. */ +async function serverPlanView(changes: Record = {}) { + const inventory = { ...cleanInventory, ...changes }; + const host = createInstallerServer({ assetsDir, collectPlan: async () => ({ inventory, plan: planPreflight(inventory) }), + runInstall: async () => ({ outcome: "ready", completed: [] }) }); + const { port, url } = await host.listen(); + try { + const cookie = await login(port, url); + const { status, body } = await getJson(port, "/api/plan", cookie); + assert.equal(status, 200); + return body; + } finally { + await host.close("test"); + } +} + +// --------------------------------------------------------------------------- +// Minimal fake DOM: only the APIs wizard.js may use. innerHTML throws. +// --------------------------------------------------------------------------- + +class FakeText { + data: string; + parent: FakeElement | null = null; + constructor(data: string) { + this.data = data; + } + get textContent() { + return this.data; + } +} +class FakeElement { + tagName: string; + ownerDocument: FakeDocument; + attributes = new Map(); + children: Array = []; + listeners = new Map unknown>>(); + parent: FakeElement | null = null; + checked = false; + disabled = false; + constructor(ownerDocument: FakeDocument, tag: string) { + this.ownerDocument = ownerDocument; + this.tagName = tag.toUpperCase(); + } + get textContent(): string { + return this.children.map((child) => child.textContent).join(""); + } + set textContent(value: string) { + this.replaceChildren(String(value)); + } + set innerHTML(_value: string) { + throw new Error("innerHTML is forbidden"); + } + set outerHTML(_value: string) { + throw new Error("outerHTML is forbidden"); + } + insertAdjacentHTML() { + throw new Error("insertAdjacentHTML is forbidden"); + } + get id() { + return this.attributes.get("id") ?? ""; + } + append(...nodes: Array) { + for (const node of nodes) { + const child = typeof node === "string" ? new FakeText(node) : node; + child.parent?.children.splice(child.parent.children.indexOf(child), 1); + child.parent = this; + this.children.push(child); + } + } + appendChild(node: FakeElement | FakeText) { + this.append(node); + return node; + } + replaceChildren(...nodes: Array) { + for (const child of this.children) child.parent = null; + this.children = []; + this.append(...nodes); + } + remove() { + this.parent?.children.splice(this.parent.children.indexOf(this), 1); + this.parent = null; + } + setAttribute(name: string, value: string) { + if (name === "style" || /^on/i.test(name)) throw new Error(`forbidden attribute ${name}`); + this.attributes.set(name, String(value)); + } + getAttribute(name: string) { + return this.attributes.get(name) ?? null; + } + hasAttribute(name: string) { + return this.attributes.has(name); + } + removeAttribute(name: string) { + this.attributes.delete(name); + } + addEventListener(type: string, listener: (event: unknown) => unknown) { + this.listeners.set(type, [...(this.listeners.get(type) ?? []), listener]); + } + focus() { + this.ownerDocument.activeElement = this; + } +} +class FakeDocument { + activeElement: FakeElement | null = null; + roots = new Map(); + constructor() { + for (const id of ["view", "stages", "alert", "status"]) { + const root = new FakeElement(this, "div"); + root.setAttribute("id", id); + this.roots.set(id, root); + } + } + createElement(tag: string) { + return new FakeElement(this, tag); + } + createTextNode(text: string) { + return new FakeText(text); + } + getElementById(id: string) { + return this.roots.get(id) ?? null; + } +} + +function walk(node: FakeElement | FakeText, out: FakeElement[] = []) { + if (node instanceof FakeElement) { + out.push(node); + for (const child of node.children) walk(child, out); + } + return out; +} +const all = (root: FakeElement, tag: string) => walk(root).filter((element) => element.tagName === tag.toUpperCase()); +const button = (root: FakeElement, text: string) => all(root, "button").find((element) => element.textContent.includes(text)); +const headings = (root: FakeElement) => all(root, "h1"); +function dispatch(element: FakeElement, type: string) { + for (const listener of element.listeners.get(type) ?? []) listener({ type, target: element, preventDefault() {} }); +} +async function flush() { + for (let i = 0; i < 30; i += 1) await new Promise((done) => setImmediate(done)); +} + +type Timer = { fn: () => void; ms: number; cleared: boolean; ran: boolean }; +function fakeTimers() { + const queue: Timer[] = []; + const pending = () => queue.filter((timer) => !timer.cleared && !timer.ran); + return { + setTimeout: (fn: () => void, ms: number) => { + const timer = { fn, ms, cleared: false, ran: false }; + queue.push(timer); + return timer; + }, + clearTimeout: (timer: Timer | undefined) => { + if (timer) timer.cleared = true; + }, + pending, + async runNext() { + const [timer] = pending(); + assert.ok(timer, "a timer is scheduled"); + timer.ran = true; + timer.fn(); + await flush(); + return timer; + }, + }; +} + +type Call = { url: string; method: string; headers: Record; body: unknown }; +type Reply = [number, unknown] | Error; +function fakeFetch(handlers: Record Reply | Promise>) { + const calls: Call[] = []; + const fetch = async (url: string, init: { method?: string; headers?: Record; body?: string } = {}) => { + const call = { url, method: init.method ?? "GET", headers: init.headers ?? {}, body: init.body === undefined ? undefined : JSON.parse(init.body) }; + calls.push(call); + const handler = handlers[`${call.method} ${url.split("?")[0]}`]; + assert.ok(handler, `unexpected request ${call.method} ${url}`); + const result = await handler(call); + if (result instanceof Error) throw result; + const [status, body] = result; + return { status, ok: status >= 200 && status < 300, json: async () => body }; + }; + return { fetch, calls }; +} + +type Clipboard = { writeText: (value: string) => Promise } | null; +function mount(handlers: Record Reply | Promise>, { clipboard = null as Clipboard } = {}) { + const document = new FakeDocument(); + const timers = fakeTimers(); + const server = fakeFetch(handlers); + const app = wizard.createWizard({ document, fetch: server.fetch, setTimeout: timers.setTimeout, clearTimeout: timers.clearTimeout, clipboard }); + const view = document.roots.get("view") as FakeElement; + return { document, timers, server, app, view, alert: document.roots.get("alert") as FakeElement, + status: document.roots.get("status") as FakeElement }; +} +const idleProgress = (): Reply => [200, { entries: [], running: false, outcome: null }]; +const outcomeProgress = (outcome: Record): Reply => [200, { entries: [], running: false, outcome }]; +const readyOutcome = { outcome: "ready", completed: ["install-global"], guidance: guidance.outcomes.ready }; +/** Ticks the consent checkbox on the review screen and presses Install. */ +async function consentAndInstall(ui: ReturnType) { + const consent = all(ui.view, "input").find((input) => input.getAttribute("type") === "checkbox") as FakeElement; + consent.checked = true; + dispatch(consent, "change"); + dispatch(button(ui.view, "Install") as FakeElement, "click"); + await flush(); +} +const posts = (ui: ReturnType, path: string) => ui.server.calls.filter((call) => call.method === "POST" && call.url === path).length; +const progressChecks = (ui: ReturnType) => ui.server.calls.filter((call) => call.url.startsWith("/api/progress")).length; + +// --------------------------------------------------------------------------- +// Static assets under the strict CSP +// --------------------------------------------------------------------------- + +test("index.html loads only same-origin wizard.js and wizard.css, with no inline script, style or handlers", () => { + const html = read("index.html"); + assert.match(html, /