diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1deed4170..050278d74 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,3 +115,39 @@ jobs: - name: Run Windows native consent boundary shell: pwsh run: pnpm exec node --experimental-strip-types --test tests/native-review-consent.test.ts + + # The installer suites import only Node built-ins and repository files, so no + # dependency installation is needed. + installer: + name: installer (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + + - name: Setup Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: "24" + + - name: Run installer suites + run: node --experimental-strip-types --test tests/install-wizard.test.ts 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 + + # The native tests skip off Windows; here every one must run and pass. + - name: Require native Windows installer tests + if: runner.os == 'Windows' + shell: pwsh + run: | + $output = & node --experimental-strip-types --test --test-reporter=tap --test-name-pattern "^native Windows" tests/installer-windows-bootstrap.test.ts 2>&1 + $status = $LASTEXITCODE + $output | Write-Output + if ($status -ne 0) { exit $status } + $text = $output -join "`n" + $passes = [regex]::Match($text, "(?m)^# pass (\d+)$") + $skips = [regex]::Match($text, "(?m)^# skipped (\d+)$") + if (!$passes.Success -or [int]$passes.Groups[1].Value -lt 14 -or !$skips.Success -or [int]$skips.Groups[1].Value -ne 0) { throw "Native Windows installer tests were skipped or not selected" } diff --git a/README.md b/README.md index 0903436a9..22c99fd7d 100644 --- a/README.md +++ b/README.md @@ -339,6 +339,7 @@ Start with the product-facing destination, then move into the operational refere | [Telemetry](docs/telemetry.md) | Approved fields and source limitations. | | [Delegated verification](docs/delegated-verification.md) | Practical verification guidance. | | [Skill style guide](docs/skill-style-guide.md) | The package skill contract. | +| [Installation wizard (in development)](docs/install-wizard.md) | The browser installation wizard's design, security model and preview. Not a supported installation path yet. |

Back to top ↑

diff --git a/assets/install-wizard/index.html b/assets/install-wizard/index.html new file mode 100644 index 000000000..4a4baa25c --- /dev/null +++ b/assets/install-wizard/index.html @@ -0,0 +1,47 @@ + + + + + + +Install Gentle Shell + + + + + + + +
+

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.

+ + +
+
+
+
+ + + 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..06cfaf58a --- /dev/null +++ b/assets/install-wizard/wizard.js @@ -0,0 +1,799 @@ +// 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", + "check-recoverable-stack": "Check the 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"); + if (recoveryPlan(actionIds)) { + // Setup recovery re-verifies the installed stack and never reinstalls it. + const steps = ["check-npm", "check-global-bin", "check-recoverable-stack", "verify-global-list", "verify-shell-bin", + "verify-gentle-ai", "shell-setup"]; + return ids.has("setup-global-bin") ? [...steps, "persist-path"] : steps; + } + // 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; +} + +/** The runner's fixed setup recovery: only setup, readiness and optionally PATH. */ +function recoveryPlan(actionIds) { + const ids = list(actionIds); + return ["setup-shell", "verify-readiness"].every((id) => ids.includes(id)) && + ids.every((id) => ["setup-global-bin", "setup-shell", "verify-readiness"].includes(id)); +} + +/** 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"; + else if (recoveryPlan(actions.map((action) => action.id))) kind = "recovery"; + 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), + }; +} + +const detailCommands = new Map([["shell-setup", "gentle-shell setup"], ["persist-path", "pnpm setup"]]); + +/** 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, detail: null, detailCommand: 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); + // The host sends a detail only for these fixed setup commands; bound it again here. + const detailCommand = detailCommands.get(id) ?? null; + const detail = detailCommand ? Array.from(text(outcome?.detail)).slice(0, 300).join("") : ""; + return { ...base, outcome: "failed", tone: "error", badge: "Failed", title: "Installation failed", + detail: detail || null, detailCommand: detail ? detailCommand : null, + 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 recovery = model.kind === "recovery"; + const checkbox = el(doc, "input", { type: "checkbox", id: "consent", class: "consent-input", "aria-describedby": "consent-hint" }); + const install = actionButton(doc, recovery ? "Complete setup" : "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); + }); + const heading = recovery + ? { title: "Finish setting up Gentle Shell", + lead: "Gentle Shell is already installed; this completes setup. Nothing is reinstalled, and nothing changes until you confirm." } + : { title: "Review the installation plan", + lead: "Nothing changes until you confirm. This is exactly what the installer will do on this computer." }; + return panel(doc, { stage: "review", eyebrow: "Step 2 of 4 · Review", ...heading }, + 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" }, recovery ? "Setup steps" : "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)); + // Untrusted process output: one plain text node, never `rich` code spans. + if (model.detail) { + children.push(el(doc, "section", { class: "block", "aria-labelledby": "setup-detail-title" }, + el(doc, "h2", { id: "setup-detail-title", class: "section-title" }, `Last error from ${model.detailCommand}`), + el(doc, "p", { class: "detail" }, el(doc, "code", {}, model.detail)))); + } + 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/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 new file mode 100644 index 000000000..0bc0b2ad0 --- /dev/null +++ b/docs/install-wizard.md @@ -0,0 +1,1287 @@ +# Installation wizard groundwork + +The preflight planner, real host probes, POSIX bootstrap, Windows prerequisite +foundation, the standard installation runner module (including runtime +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 macOS/Windows clean-machine runs are still +unverified (T7); one Linux clean-container run passed (see +[Clean-machine acceptance](#clean-machine-acceptance-t7)). For ordinary +installation and terminal use, follow the [README](../README.md). + +## What is available + +`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. | +| `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, +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`. `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 + 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. 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. + +## 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. + 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 + the existing installer instead. +4. Run normal Shell setup and verify stack readiness. Verification is always + included, even when all components can be reused. + +A recoverable setup (the pinned stack is installed, Gentle AI is verified and +only setup did not finish) plans exactly `setup-global-bin` (only when the +global bin is off PATH), `setup-shell` and `verify-readiness`: never an +acquisition, persistence or installation intent. See +[Setup recovery](#setup-recovery). + +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.** + +The consent UI is the local wizard host described below. A published +distribution bundle 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-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 +``` + +## 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, unless the caller passes + `stderrTail: ` (a positive integer, clamped to 4 KiB): then stderr is + piped and only its last bytes are kept and returned as `stderrTail`, also on a + deadline. Only the runner's `shell-setup` and `persist-path` steps ask for it. 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. `{ available: true, recoverable: true }` for the pinned stack this pnpm installed (see [Setup recovery](#setup-recovery)), using the runner's `recoverableStackRoot`. Unknown otherwise, because an existing Shell's setup readiness has no read-only evidence. | + +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 }, +adapters)`, the fixed installation step the local wizard host +(`bin/gentle-shell-install.mjs`, below) 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 and +the runtime persistence and npm prefix actions below, so the wizard must show +those changes 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` (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. | + +### Fixed sequence + +No-process gates, all returning `blocked`: + +1. Valid request and explicit consent. +2. Preflight blockers are absent. Two plans are supported. The clean-stack + plan must contain `install-pi`, `install-shell`, `setup-shell` and + `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. The + [setup recovery](#setup-recovery) plan is exactly `setup-shell` and + `verify-readiness`, plus optional `setup-global-bin`, and nothing else. + Prerequisite acquisition intents (Node, pnpm, Go), `provision-native`, any + other persistence combination, other partial existing stacks and fully + reused stacks are `unsupported-plan`. +3. On Windows, Go must be `reusable` in a clean-stack plan because gentle-pi's + postinstall may build Gentle AI from source; the runner never acquires Go + (`go-required`). A recovery runs no postinstall, so it needs no Go. +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. + 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. +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`. A recovery runs + `check-recoverable-stack` instead (see [Setup recovery](#setup-recovery)). + +Mutating and verification steps, returning `failed` with `failedStep` and the +`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`. + 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 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 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. It requests the + last 4 KiB of setup's stderr, where `gentle-shell setup` reports errors, and + a failure carries `detail`: the last error line (containing `Error:` or a + pnpm `ERR_` code), else the last non-empty line, without terminal escapes, + control or bidi characters, with the user's home replaced by `~` and at most + 300 characters (`setupErrorDetail`). The raw output is not kept; only this + step and `persist-path` have a detail. +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`. On POSIX, `pnpm setup` picks the profile + to edit from the inherited `SHELL` variable (bash, zsh, fish, ksh, dash, sh + or nushell); without it pnpm 11.1.1 fails with `ERR_PNPM_UNKNOWN_SHELL`, + with another shell `ERR_PNPM_UNSUPPORTED_SHELL`. A terminal session always + exports `SHELL`; a launcher that does not (for example `docker exec`, some + service managers or IDE tasks) makes this step fail. pnpm prints these + errors on stdout, so a failure takes `detail` from the same 4-KiB stderr + tail or, when that has no line, from stdout. Before checking the shell, the + observed pnpm 11.1.1 also installs the latest `@pnpm/exe` globally ("Installing + pnpm CLI globally"), which is upstream behavior the runner does not control. + +`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 npm config commands, and 20 minutes +each for the install, persistence and setup steps, including `pnpm setup`, +which installs `@pnpm/exe` from the registry. + +### Setup recovery + +`gentle-shell setup` runs after `add -g`, so a setup that stops (for example on +the [GitHub API limit](#github-api-limit-during-setup)) or a failed `pnpm setup` +leaves Pi and gentle-pi installed. Rerunning the installer then completes that +installation instead of blocking as an existing stack. + +Detection is the setup probe, with the same rule the runner applies +(`recoverableStackRoot`): the global listing names Pi and gentle-pi exactly +once each, both in the single project that owns gentle-pi, Pi at exactly +1.0.0 and gentle-pi at exactly this package's version, and gentle-pi's path +resolves (realpath) inside PNPM_HOME. Preflight also requires Pi, Shell and +Gentle AI to be `reusable`. Anything else (another version, a second listing, +a path outside PNPM_HOME, Pi missing) keeps the setup probe unknown, so a +foreign or other-version stack stays blocked exactly as before. + +The recovery plan is all or nothing: `setup-shell`, `verify-readiness` and +`setup-global-bin` only when the global bin is off PATH. The runner then runs +`check-npm`, `check-global-bin` and `check-recoverable-stack`, which lists the +global packages again and blocks as `existing-stack-unverified` when the stack +no longer matches (or as `global-list-unavailable` when pnpm cannot list). +It skips `install-global` (never `add -g`) and continues with the unchanged +`verify-global-list`, `verify-shell-bin`, `verify-gentle-ai`, `shell-setup` +and, when planned, `persist-path`, with the same outcomes. A recovery never +persists the runtime: the earlier run persisted it before installing the +stack, and `check-npm` still proves a genuine npm in the child environment. + +Rerunning `gentle-shell setup` is acceptable because it is the public, +rerunnable command the clean installation already runs; the runner adds no +setup logic of its own. The review screen says "Gentle Shell is already +installed; this completes setup" and offers **Complete setup**. + +### 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 + +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; +- `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. + +## 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`. Node, npm and pnpm now persist under `$PNPM_HOME` or were already the user's, so the bootstrap removes the tools directory it created. | +| 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. + +Known debt, not fixed yet: on SIGINT or SIGTERM, `close` does not wait for a +running installation to settle, and runner deadlines kill only the direct child +process, not the processes it started (for example, the ones `pnpm` or +`gentle-shell setup` spawn). + +### 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` — 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). | + +`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. A failed `shell-setup` or `persist-path` outcome also passes the +runner's `detail` through, bounded again (string only, control and bidi +characters removed, at most 300 characters); the host drops it for any other +step. When that detail names GitHub's rate limit (`rate limit`, or `GitHub API` +together with `403`), the guidance becomes `guidance.setupRateLimit`: GitHub's +anonymous API limit was reached on this network, wait up to an hour and run the +installer again. No token or credential is ever requested. For `persist-path`, +a detail with `ERR_PNPM_UNKNOWN_SHELL` or `ERR_PNPM_UNSUPPORTED_SHELL` selects +`guidance.persistPathShell`: open a regular terminal and run the installer +again, or add `$PNPM_HOME/bin` to PATH manually. `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. + +### Wizard UI + +`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** (for a setup recovery, "Finish setting up Gentle Shell" and **Complete setup**). 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. A failed `shell-setup` or `persist-path` with a detail also shows it under **Last error from gentle-shell setup** or **Last error from pnpm setup** as one plain text node (backticks stay literal). 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), +`recovery` (an installed stack whose setup did not finish; ends in +`terminal-action-required`) 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 (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 + 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 1 end to end, and exit 0 outside Linux; SIGTERM during a running + installation; Windows `rundll32` behavior; real probe and runner execution + through the host outside Linux. + +## 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 wizard +entry `bin/gentle-shell-install.mjs`. When that entry is missing, 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 +while the wizard runs. After the wizard exits 0, the script removes the tools +directory it claimed, and the Node helper removes the one it created when only +pnpm was missing. Both removals require the exact +`$HOME/.gentle-shell-bootstrap-tools.*` name, a real directory (not a symlink) +and the `.bootstrap-owned` marker with its exact content; `rm -rf` and +`rmSync` remove links inside without following them. When removal is refused or +fails, the bootstrap still exits 0 and prints +`Bootstrap: installation finished, but temporary tools could not be removed: +` on stderr. +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). | + +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 +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. The Windows adapter reuses descriptors/integrity verification without changing +POSIX process semantics, defaults or pins. + +### 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. + +## 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 the wizard entry +`bin/gentle-shell-install.mjs` or any Windows-dependent helper/metadata file is +missing. There is no published bundle URL or remote-pipe contract, and the +Windows bootstrap end to end still lacks native acceptance evidence. + +| 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 wizard entry `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. +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 + +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. Prerequisite tools remain +available to children while the wizard runs. After the helper exits 0, a final +stage removes the claimed root only when its full path is a direct child of +`%LOCALAPPDATA%` named `.gentle-shell-bootstrap-tools.*`, neither it nor its +`.bootstrap-owned` marker is a reparse point, and the marker holds the exact +text. It uses `[IO.Directory]::Delete(path, true)`, which does not recurse +through reparse points, instead of Windows PowerShell 5.1 `Remove-Item`. A +refusal or failure prints the same notice with the path and still exits 0. 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. + +### Claim reason codes and 8.3 short paths + +A rejected storage claim keeps its user-facing message and appends one fixed +code naming the failed check, for example `... policy denied it. Reason: home-owner`. +The code never contains a path, SID or exception text. Intentional rejections +report their own code: + +| Code | Rejected check | +|------|----------------| +| `policy` | PowerShell is not in FullLanguage mode. | +| `path-mismatch` | `%LOCALAPPDATA%` is not rooted, the target is UNC, or the target's parent is not exactly `%LOCALAPPDATA%`. | +| `home-owner` | `%LOCALAPPDATA%` is not owned by the invoking SID. | +| `ancestor-reparse` | An ancestor is not a directory or is a reparse point. | +| `ancestor-owner` | An ancestor owner is not the invoking SID, SYSTEM, Administrators or TrustedInstaller. | +| `acl-mask` | An untrusted effective allow ACE exceeds the depth's allowed rights mask. | +| `collision` | The random destination already exists; it is never reused or changed. | +| `protected-dacl` | The readback DACL is not protected from inheritance. | +| `private-owner` | The readback owner is not the invoking SID. | +| `private-ace` | The readback holds a deny ACE or a SID other than the invoking SID, SYSTEM or Administrators. | + +Any other exception, such as an unreadable ACL, a denied write or a failed +cmdlet, reports `unexpected-` for the step that was running, so it can +never pose as an intentional rejection: + +| Step | Work in progress | +|------|------------------| +| `unexpected-policy` | Language mode and identity lookup. | +| `unexpected-path-mismatch` | Path normalization. | +| `unexpected-home-owner` | Reading the `%LOCALAPPDATA%` owner. | +| `unexpected-ancestor-walk` | Reading an ancestor's attributes or ACL. | +| `unexpected-create` | Creating the new directory, including a destination that appeared after the collision check. | +| `unexpected-private-acl` | Writing or reading back the private DACL. | +| `unexpected-marker` | Writing the ownership marker. | + +The Node probe stage follows the same contract. Its message is unchanged and +ends with `Reason: `. Walk codes are prefixed with the role of the path +component that failed: `target` (`node.exe` itself, depth 0), `parent` (its +directory, depth 1) or `ancestor` (depth 2 and above). The role is fixed text, +never the path. + +| Code | Rejected check | +|------|----------------| +| `policy` | PowerShell is not in FullLanguage mode. | +| `missing-target` | The `.node-target` record is absent, not a file, or empty. | +| `unsafe-target` | The recorded Node target is a directory or a reparse point. | +| `unsafe-path` | The recorded Node target is not rooted, or is UNC. | +| `-reparse` | A walked component is a reparse point. | +| `-owner` | A walked component's owner is not the invoking SID, SYSTEM, Administrators or TrustedInstaller. | +| `-acl-mask` | An untrusted effective allow ACE on a walked component exceeds its depth's allowed rights mask. | +| `no-start` | The process did not start. | +| `deadline` | Node did not exit and close both pipes within 10 seconds. | +| `output-limit` | Combined stdout and stderr exceeded 1 MiB. | +| `exit-code` | Node exited with a nonzero code. | +| `version-format` | `node --version` did not print exactly `vMAJOR.MINOR.PATCH`. | +| `engine` | The version is below 24.3.0 or the repository `engines.node` floor. | +| `acquired-version` | A Node acquired by this bootstrap is not exactly v24.21.0. | + +Any other exception reports `unexpected-`: `unexpected-policy`, +`unexpected-target` (reading the recorded target), `unexpected-acl-walk` +(reading a component's attributes or ACL), `unexpected-start` (for example, an +application-control denial while starting the process), `unexpected-drain` +(reading the pipes) or `unexpected-version` (reading the repository metadata). +A child that cannot be confirmed terminated keeps its separate +`direct-child termination could not be confirmed` message. + +The `.node-target` record holds a full path, which may contain non-ASCII text, +for example a user profile such as `C:\Users\José` or the Unicode native +fixture root. Windows PowerShell 5.1 writes `New-Item -Value` content as +BOM-less UTF-8 but reads `Get-Content` without a BOM as the ANSI code page, so +such a path came back corrupted and the probe failed with `unexpected-target`. +The record is now created no-clobber with `[IO.File]::Open(..., CreateNew)` and +explicit BOM-less UTF-8 bytes, and the probe and launch stages read it with +`[IO.File]::ReadAllText(path, [Text.Encoding]::UTF8)`. The other bootstrap +records (`.bootstrap-owned`, `.node-stem`) hold fixed ASCII text and keep their +cmdlets. + +The Node helper (`installer-downloads.mjs --bootstrap-windows`) follows the same +contract. Its failure line is unchanged and ends with `Reason: ()` +for an intentional rejection, or `Reason: unexpected-` for any other +error, such as a file-system, JSON or spawn error. A value without that fixed +shape, including a failed module import, prints `Reason: unexpected-helper`. + +| Step | Phase | +|------|-------| +| `bundle` | Native platform, bundle files and repository Node/pnpm metadata. | +| `tools-storage` | ACL walk of the claimed tools directory. | +| `pnpm-discovery` | CMD-equivalent search of the current directory, `Path` and `PATHEXT` for `pnpm`. | +| `wrapper-storage`, `wrapper` | ACL walk and exact npm cmd-shim proof of the found `pnpm.cmd`. | +| `node-discovery` | The wrapper's sibling `node.exe`, or `node` on `Path`. | +| `node-storage`, `entry-storage`, `metadata-storage` | ACL walk of the selected Node, the pnpm entry and its `package.json`. | +| `package` | The pnpm `package.json` binary target. | +| `cli-proof` | `node --version`, `pnpm --version` and the `add`/`bin` global capability. | +| `tools-check`, `download`, `archive`, `publish` | Pinned pnpm acquisition when no `pnpm` was found. | +| `launch` | The bundle wizard entry and its exit code. | + +Codes: `native-unavailable`, `bundle-missing`, `prerequisite`, `unsafe-path`, +`acl-evidence`, the storage walk codes `policy` and +`-`, `cwd-search`, +`path-missing`, `pathext`, `path-entry`, `extensionless`, `wrapper-unknown`, +`wrapper-unproven`, `wrapper-node`, `wrapper-target`, `package-target`, +`pnpm-engine`, `pnpm-version`, `pnpm-capability`, `process-failed`, +`interpreter`, `unsafe-tools`, `pnpm-conflict`, `archive`, `pnpm-pin`, +`pnpm-entry`, `acquisition`, `wizard-missing`, `wizard-start` and +`wizard-exit`. For example, `parent-owner (wrapper-storage)` means the +directory holding `pnpm.cmd` has an untrusted owner. The helper's storage check +prints `unsafe:` for a walk rejection and still accepts only exact `safe`; +any other PowerShell exception exits nonzero and reports `unexpected-`. + +`.CPL` deserves a note. Windows PowerShell 5.1 appends `.CPL` to `PATHEXT` in +its own environment, so its children see +`.COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC;.CPL` even when CMD did +not. The bootstrap always starts the helper from Windows PowerShell, and native +CI showed the old discovery allowlist rejecting every Windows run with +`pathext (pnpm-discovery)`. `.cpl` is now a known extension, but not an +accepted candidate. Discovery keeps the CMD order (current directory, then each +`Path` entry, then `PATHEXT` order within a directory). A `pnpm.cpl` found +before the real `pnpm.cmd` stops with `wrapper-unknown (wrapper)`, and a +`node.cpl` found before `node.exe` stops with `wrapper-node (node-discovery)`; +neither is skipped or executed. Any other unknown, duplicate or empty +`PATHEXT` extension still fails with `pathext`. + +The wizard inherits the same environment. The runner's `lookPath` (Go +`exec.LookPath` order) and the host probes accept any `PATHEXT` extension and +already refuse a `.cpl` result: npm reports `npm-shadowed`, and Node or Go +resolving to a non-`.exe`/`.com` file reports unknown without running it. + +ACLs are read and written with .NET Framework APIs +(`[IO.Directory]::GetAccessControl`, `[IO.File]::GetAccessControl`, +`[IO.Directory]::SetAccessControl`) instead of `Get-Acl`/`Set-Acl`. Those +cmdlets live in Microsoft.PowerShell.Security, which Windows PowerShell 5.1 +autoloads from `PSModulePath`. Started from a PowerShell 7 terminal (or the +GitHub Actions `pwsh` step shell), the inherited `PSModulePath` lists +PowerShell 7's Core-only copy first, and 5.1 fails with +`CouldNotAutoloadMatchingModule`. The checks themselves are unchanged: the +same owner, mask, protected-DACL, readback and reparse predicates in the same +order. The bootstrap still uses cmdlets from Microsoft.PowerShell.Management +(`Get-Item`, `Get-Content`, `New-Item`, `Remove-Item`, `Test-Path`, +`Join-Path`) and Microsoft.PowerShell.Utility (`New-Object`, `ConvertFrom-Json`, +`Get-FileHash`, `Add-Type`, `Select-Object`, `Start-Sleep`). The native fixtures +observed both modules loading under the same `pwsh` parent, and a module loads +as a unit. `Get-Command` is built into the engine. + +The path comparison is insensitive to 8.3 short names by construction: both +sides derive from the same `%LOCALAPPDATA%` string and pass through +`[IO.Path]::GetFullPath`, so a short `%LOCALAPPDATA%` or `%TEMP%` is never +compared with a long canonical form. The host probe adapter's `realpath` is the +native call and returns the long form, for example `C:\Users\runneradmin\...` +for a `%TEMP%` exposed as `C:\Users\RUNNER~1\...`; tests compare it with +`realpathSync.native`, not the JavaScript `realpathSync`, which keeps short +names. + +An earlier CI run reported `home-owner` for every native claim. The actual +cause was this module-load failure, reported as the step that was running; +`unexpected-` now separates the two. The `home-owner` check requires +`%LOCALAPPDATA%` to be owned by the invoking SID itself, not by a trusted +group. A real `%LOCALAPPDATA%` created by the User Profile Service is owned by +the user. Elevated members of Administrators on Windows Server may create new +directories owned by BUILTIN\Administrators, depending on the default-owner +policy. The native fixtures therefore set the invoking SID as owner of the +fixture root and of every fixture directory used as `LOCALAPPDATA`, read the +owner back, and fail loudly before any production stage runs. Production is +unchanged: a real user whose `%LOCALAPPDATA%` is owned by Administrators fails +closed with `home-owner` and no storage is claimed. + +### 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. + +## Clean-machine acceptance (T7) + +### Linux clean container + +A disposable `debian:bookworm-slim` container without Node, npm, pnpm, Pi or +Gentle Shell, driven as a non-root user, passed end to end: +`sh scripts/bootstrap.sh` exited 0 and the wizard, driven over HTTP exactly as +the page does, reached `terminal-action-required` with all 13 runner steps +`done` in about 27 seconds. A fresh `bash -ic` resolved `node` v24.21.0, `npm` +11.19.0 and `pnpm` 11.1.1 from `$PNPM_HOME/bin`, and `gentle-shell --version` +reported 4.0.0 with Pi 1.0.0 and the isolated home `~/.gentle-shell/agent`. +That run predates the removal of bootstrap tools after success. + +The first run of `scripts/test-installer-acceptance.sh --worktree` (with the +tools removal, not yet committed) did **not** pass: the bootstrap acquired its +tools and printed the session URL after 113 seconds, the plan had no blockers, +and 11 runner steps finished `done` before `shell-setup` (`gentle-shell setup`) +failed; the outcome was `failed`, the bootstrap exited 1 and removed its tools +through the failure path, after 146 seconds in total. A separate diagnostic +container proved the cause: `gentle-shell setup` failed with `Error: execute +install pipeline: download engram binary: fetch latest engram version: GitHub +API returned HTTP 403` because the anonymous GitHub API quota of this network +was exhausted. After the quota reset, setup in the same container exited 0 +without installing any extra package. It was not caused by the tools removal, +which only runs after the wizard exits. See +[GitHub API limit during setup](#github-api-limit-during-setup). + +The second run (with the quota precheck: 43 anonymous requests left) got past +that: `shell-setup` finished `done`, then the last step, `persist-path` +(`pnpm setup`), failed. The installation part took about 34 seconds, so it was +not its 30-second deadline alone. The outcome was `failed`, the bootstrap exited +1 and removed its tools through the failure path; 147 seconds in total. Cause: +the script started the bootstrap with `docker exec ... sh -c`, which exports no +`SHELL`, while the passing lab run had `SHELL=/bin/bash`; `pnpm setup` then +cannot pick a profile (see `persist-path` above). A real terminal exports +`SHELL`, so the script now passes `SHELL=/bin/bash` to the bootstrap, and a +failed `persist-path` now reports pnpm's error line and SHELL guidance. + +The third run, with `SHELL=/bin/bash`, **passed**: 23 anonymous GitHub +requests left before it, session URL after 113 seconds, all 13 runner steps +`done` including `shell-setup` and `persist-path`, outcome +`terminal-action-required`, bootstrap exit 0; 142 seconds for bootstrap and +wizard, 147 in total. A fresh `bash -ic` resolved `node` v24.21.0, `npm` +11.19.0, `pnpm` 11.1.1 and `gentle-shell` from `~/.local/share/pnpm/bin`; +`gentle-shell --version` reported 4.0.0, Pi 1.0.0 and the isolated home +`~/.gentle-shell/agent`; no `~/.gentle-shell-bootstrap-tools.*` directory +remained, so the success-path tools removal is proven in a clean container. +`bash -lc` did not resolve `gentle-shell`, as documented below. + +To reproduce it, run `sh scripts/test-installer-acceptance.sh` from the +repository. It refuses to start without a working Docker daemon and never runs +the installer on the host or touches the host HOME. It starts a container +(`--pull missing`), installs only `ca-certificates` and `curl`, creates the +user `tester`, then queries `https://api.github.com/rate_limit` from inside the +container (this endpoint does not consume quota) and stops before the +bootstrap, printing the reset time, when fewer than 5 anonymous core requests +remain. It then copies the bundle to `/opt/bundle` (`git archive HEAD`, or the +tracked working-tree files including uncommitted changes with `--worktree`) and +runs the bootstrap. A headless driver inside the container redeems +`/session?code=` with a cookie jar, sends `X-Gentle-Install: 1` on every +`/api/*` request and the same Origin plus `Content-Type: application/json` on +every POST, then requests the plan, consents, polls progress and shuts the host +down. It then checks, in a fresh `bash -ic`, that `node`, `npm`, `pnpm` and +`gentle-shell --version` resolve and that no `~/.gentle-shell-bootstrap-tools.*` +directory remains, and reports whether `bash -lc` resolves `gentle-shell`. The +container is removed on every exit unless `GENTLE_ACCEPTANCE_KEEP=1` keeps a +failed one for inspection; the pulled image stays cached. Network +access happens only inside the container. The installation is bounded by +`GENTLE_ACCEPTANCE_INSTALL_SECONDS` (default 1500). CI does not run it. + +### GitHub API limit during setup + +`gentle-shell setup` runs upstream Gentle AI's install pipeline, which looks up +the latest Engram version through the GitHub REST API without authentication: +2 anonymous requests per setup. GitHub allows 60 anonymous requests per hour +per public IP address, so machines sharing one address (offices, NAT, VPNs, CI +runners, repeated test runs) can exhaust it; setup then fails with +`GitHub API returned HTTP 403` and the wizard shows the rate-limit guidance and +that error line. Waiting for the hourly reset is enough; nothing has to be +uninstalled first: rerunning the installer finds the installed stack and runs +only its [setup recovery](#setup-recovery). + +A missing `git` only produces a warning during setup; it was not the cause. + +Upstream advisory for Gentle AI (not changed here): pin the Engram version or +fall back to it when the lookup fails, accept an optional `GITHUB_TOKEN` for +the lookup, and replace the misleading "installed via go install" message that +accompanies this failure. + +### Shell profiles after `pnpm setup` + +`pnpm setup` writes `PNPM_HOME` and its PATH entry into the interactive shell's +profile only: `~/.bashrc` for bash on Debian. Debian's `~/.bashrc` returns +early for non-interactive shells, and login shells read `~/.profile`, so these +do not find `gentle-shell`, `node`, `npm` or `pnpm`: + +- non-interactive login shells such as `bash -lc '...'`, and tools or IDE tasks + that start one; +- `ssh host command`: bash reads `~/.bashrc` but stops at its non-interactive + guard, so the lines must sit above that guard (or the command must use an + absolute path); +- cron jobs, which read neither file: set PATH in the crontab or use absolute + paths. + +For login shells, add the same lines to `~/.profile` manually (the wizard does +not edit it), using the `PNPM_HOME` that `pnpm setup` wrote, for example: + +```sh +export PNPM_HOME="$HOME/.local/share/pnpm" +case ":$PATH:" in *":$PNPM_HOME/bin:"*) ;; *) export PATH="$PNPM_HOME/bin:$PATH" ;; esac +``` + +### Upstream `~/.pi/gentle-ai` + +Gentle AI's setup creates an empty `~/.pi/gentle-ai` directory outside the +isolated Gentle Shell home `~/.gentle-shell/agent`. This is upstream behavior; +the installer neither relies on nor removes it. + +### CI matrix + +The `installer` job in `.github/workflows/ci.yml` runs the installer suites with +Node 24 on `ubuntu-latest`, `macos-latest` and `windows-latest`. The native +Windows tests in `tests/installer-windows-bootstrap.test.ts` are gated on +`process.platform === "win32"`, so they run on the Windows runner and skip +elsewhere; the POSIX shell fixtures in `tests/installer-posix-bootstrap.test.ts` +skip on Windows. CI only runs after a push, which remains a user decision. + +### Remaining T7 checks + +- real browsers and screen readers (only headless Chromium was used); +- native macOS and Windows clean machines, including the Windows bootstrap + end to end and its tools removal; +- `pnpm setup` and fresh-terminal resolution in zsh and fish; +- SIGTERM during a running installation; +- reinstalling over an existing stack; +- download-time integrity of the Node runtime and the npm and pnpm packages + fetched during persistence; +- the earlier T4/T5/T6 items listed above that the Linux run does not cover. diff --git a/odd/tasks/browser-install-wizard.md b/odd/tasks/browser-install-wizard.md new file mode 100644 index 000000000..994c3d7e0 --- /dev/null +++ b/odd/tasks/browser-install-wizard.md @@ -0,0 +1,107 @@ +# 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. +- 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. +- `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`. +- 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 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. +- 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 +- [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. +- [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. +- [x] T6 — Accessible browser wizard and terminal handoff. Status: closed in `76b4d8a5`; writer, independent verifier, one scoped polish correction, parent visual review and native review. Real browsers, screen readers and native platforms remain T7. 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. Status: Linux clean-machine acceptance PASSED and T7a committed in `ae011a12` (independent verifier + high four-lens native review approved). Native Windows/macOS evidence comes from the new CI `installer` matrix after the user-authorized push; real browsers, screen readers, zsh/fish and other distros remain open. 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. 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–T6 and T7a are closed; PR #1703 is open (Closes #1700). T7b committed in `81b420c7` (native review `review-81da8731c385acb8` approved; advisories: native guard `exit 0` unproved, install chain lacks terminal catch). User chose to push T7b and T7c together. T7c committed in `29ae9946` (native review `review-e810447b245e09b9` approved; advisories R3-001..R3-004). T7b+T7c pushed together over HTTPS with the gh session (no workflow files changed) at `29ae9946`; CI rerunning on PR #1703. + +## Work-unit evidence (condensed) +Full per-unit evidence lives in Engram topics `installer/*` (t4-*, t5a-*, t5b-*, t6-*, t7-*, t7a-*, t7b-*) and in git history. + +| Unit | Commit | Verification | Native review | +|---|---|---|---| +| T1 preflight | `09860c05` | 21/21 writer, independent, parent | `review-eda68029d737a4f8` approved | +| T2 POSIX bootstrap | `03e770f7` | 76/76 writer, independent, parent | `review-5cbf4ce0e04bf1de` high, approved | +| T3 Windows bootstrap | `ffdc7f74` | 98 pass/13 native skips; real pnpm archive proof | `review-2aa665d2b4350300` approved | +| T4 runner | `0d9dac2c` | 121 pass; independent PASS + one correction | `review-7d456e98582a8344` approved | +| T5a probes + persistence | `e4cac184` | 156 pass; independent PASS + one correction; two labs proved pnpm-managed Node/npm/pnpm | `review-5ad487c03b1146fc` approved | +| T5b secure host + entry | `00360106` | 195 pass; offensive loopback verification PASS; hardening | `review-0f4c1883b3f3d950` approved | +| T6 wizard UI | `76b4d8a5` | 219 pass; CDP Chromium screenshots, 0 CSP violations | `review-d22416227f5c4e67` approved | +| T7a acceptance + CI | `ae011a12` | 232 pass; Linux clean-container acceptance PASS end to end | `review-2fead884599842f6` high, approved | + +Key decisions: existing stacks are blocked (upgrades stay with `gentle-shell update`); persist only what is missing (never shadow a user's Node); `--allow-build=gentle-pi` only; stderr captured only for `shell-setup` and `persist-path` (bounded, sanitized); bootstrap removes only its owned tools after success. + +Known upstream/environment facts: Gentle AI resolves the latest Engram through the anonymous GitHub API (60/h per IP); `pnpm setup` needs `SHELL`, prints errors on stdout and installs `@pnpm/exe` first; it writes only the interactive shell profile; Gentle AI creates an empty `~/.pi/gentle-ai`. + +Open debt: close/signals do not wait for the runner; deadlines kill only the direct child; real browsers/screen readers, macOS and Windows native acceptance, zsh/fish; nonblocking review advisories recorded per unit in Engram. + +### T7b PR feedback and CI fixes (committed `81b420c7`) +- Mapping `mus5u9pr-e-ly36` against CI run 37109363955 and reviews by egdev6 and CodeRabbit. +- Writer `mus61gr6-f-3emj`: macOS fixture canonicalization (RED reproduced with a symlinked TMPDIR), Windows probe chmod skip, native guard `exit 0` + stderr in the assertion, typecheck fixes (+2 hidden TS2345), empty Windows PATH entries skipped (P1), `pnpm setup` uses `deadlines.setup`, server resets `installing` in `.finally`, stale docs and recorded debt. 10 files, +108/-24. +- Parent spot check: 249 tests/235 pass/0 fail/14 native skips; `pnpm run typecheck` no regressions (187 recorded); diff check clean; temp files removed. Windows native items need CI after push. +- T7c next: partial-install recovery (exact-version stack in one pnpm global project under `$PNPM_HOME` → only `setup-shell` (+`setup-global-bin`) and `verify-readiness`, no `add -g`). + +### T7d Windows native CI (in progress) +- CI on `29ae9946`: all jobs green except `installer (windows-latest)`: 258 tests, 201 pass, 6 fail. The guard `exit 0` fix worked (8 native process tests + native entry pass). Remaining: probes fs test expects the 8.3 short temp path (`RUNNER~1`) while realpath is long; 5 native bootstrap tests fail with the production message "private storage ACL/reparse/ownership claim failed or policy denied it" (suspected short-path comparison in the claim, which would also affect real users, or runner ACL entries). User authorized T7d including a push. +- Writer `musfws5g-h-gaps`: fixed allowlisted claim reason codes in `bootstrap.cmd` (message unchanged + `Reason: `), stderr in native assertions, probes fixture `realpathSync.native`, docs. Static analysis rules out 8.3 paths in the claim; prime hypothesis `home-owner` (elevated runner creates directories owned by Administrators). Parent decision: push diagnostics first and read the CI reason before any owner decision; never relax production owner/ACL checks. Parent check: 259 tests/245 pass/0 fail/14 skips; typecheck no regressions. +- Diagnostics committed and pushed as `d091a25d` (native review `review-9db0f7f783c32122` approved; advisory R3-001 on the junction test). CI run 37127518418: Windows 259 tests/203 pass/5 fail, all five `Reason: home-owner`; probes short-path fix passed. Cause confirmed: the elevated Windows Server runner creates Administrators-owned fixture directories. Production stays strict; fixture owner fix delegated to `musgay6p-i-h1n1`. +- Fixture owner fix by `musgay6p-i-h1n1` (production unchanged; expected-rejection tests pinned to their codes). Parent check: 259 tests/245 pass/0 fail/14 skips; typecheck no regressions. +- Owner fixture committed/pushed as `61dab6b3` (review `review-c45f2ffbaf679d32` approved). CI run 37127968511 then showed the real cause: Windows PowerShell 5.1 cannot autoload `Microsoft.PowerShell.Security` (`Get-Acl`) under an inherited pwsh `PSModulePath`; the earlier `home-owner` code was this failure disguised because unexpected exceptions reported the current step. This is a real product bug (bootstrap launched from a PowerShell 7 terminal). Correction `musglgb1-j-764p`: .NET `GetAccessControl`/`SetAccessControl` in production and fixtures with identical semantics, and `unexpected-` for non-allowlisted errors. +- `musglgb1-j-764p`: all ACL reads/writes use .NET APIs (production + fixtures), `unexpected-` for non-allowlisted errors, `collision` precheck, static guard against Security-module cmdlets. Parent check: 260 tests/246 pass/0 fail/14 skips; typecheck no regressions. +- Committed/pushed `6c593f24` (review `review-2bfc4998183c53c4` approved). CI run 37128661267: Windows 260 tests/208 pass/1 fail — only the complete local sentinel fails in the Node probe stage (generic message). Diagnostics for that stage delegated to `mush1ifv-k-ii91`. +- `33234953` (probe reason codes, review `review-0c7366903313046b` approved). CI run 37129229874: Windows 261/209 pass/1 fail; the sentinel reports `Reason: unexpected-target` (exception reading `.node-target` or its item). Fix delegated to `mushf2hx-l-xpr1`. +- `615df6a7` (`.node-target` UTF-8 round-trip + `missing-target`, review `review-86076bd7c93f564e` approved). CI run 37129736644: Windows 262/210 pass/1 fail; sentinel now passes the probe and fails at the helper launch with a generic message. Helper diagnostics delegated to `mushq8bo-m-t4v4`. +- `f749a02c` (helper reason codes; native review `review-af7c8b65432f2cc9` required one correction: a platform-ungated test, fixed by the parent and validated). CI run 37130718018: Windows 265/213 pass/1 fail, `Reason: pathext (pnpm-discovery)` — Windows PowerShell appends `.CPL` to PATHEXT (affects every Windows user). Fix delegated to `musibg0f-n-lrkp`: recognize `.cpl` without accepting it as a candidate; audit all PATHEXT sites. diff --git a/scripts/bootstrap.cmd b/scripts/bootstrap.cmd new file mode 100644 index 000000000..e77ab611f --- /dev/null +++ b/scripts/bootstrap.cmd @@ -0,0 +1,225 @@ +@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. +rem Failures append one fixed reason code (the failed check), never path/SID/error text; +rem other exceptions report unexpected-. ACLs use .NET, not Security-module +rem cmdlets: PowerShell 7's inherited PSModulePath breaks their 5.1 autoload. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { $claimed = $false; $step = 'policy'; try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' };" ^ + "$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');" ^ + "$step = 'path-mismatch'; $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 'path-mismatch' };" ^ + "$depth = 1;" ^ + "$step = 'home-owner'; $homeAcl = [IO.Directory]::GetAccessControl($path); if ($homeAcl.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'home-owner' };" ^ + "$step = 'ancestor-walk'; while ($path) {" ^ + " $item = Get-Item -LiteralPath $path -Force; if (-not $item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'ancestor-reparse' };" ^ + " $acl = [IO.Directory]::GetAccessControl($path); if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw '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 'acl-mask' };" ^ + " }; $parent = [IO.Directory]::GetParent($path); if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++;" ^ + "};" ^ + "$step = 'create'; if (Test-Path -LiteralPath $tools) { throw 'collision' }; $null = New-Item -ItemType Directory -Path $tools; $claimed = $true;" ^ + "$step = 'private-acl'; $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);" ^ + "}; [IO.Directory]::SetAccessControl($tools,$acl);" ^ + "$verified = [IO.Directory]::GetAccessControl($tools); if (-not $verified.AreAccessRulesProtected) { throw 'protected-dacl' };" ^ + "if ($verified.GetOwner([Security.Principal.SecurityIdentifier]).Value -ne $me.Value) { throw 'private-owner' };" ^ + "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-ace' } };" ^ + "$step = 'marker'; $null = New-Item -ItemType File -Path (Join-Path $tools '.bootstrap-owned') -Value 'gentle-pi prerequisite tooling only';" ^ + "} catch { $reason = 'unexpected-' + $step; if ($_.Exception.Message -cmatch '^(policy|path-mismatch|home-owner|ancestor-reparse|ancestor-owner|acl-mask|collision|protected-dacl|private-owner|private-ace)$') { $reason = $_.Exception.Message };" ^ + "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. Reason: ' + $reason); 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) { $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($command.Source); $record = [IO.File]::Open((Join-Path $env:GENTLE_BOOTSTRAP_TOOLS '.node-target'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); try { $record.Write($bytes,0,$bytes.Length) } finally { $record.Dispose() } };" ^ + "} 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() };" ^ + " $bytes = (New-Object Text.UTF8Encoding($false)).GetBytes($target); $record = [IO.File]::Open((Join-Path $tools '.node-target'),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); try { $record.Write($bytes,0,$bytes.Length) } finally { $record.Dispose() };" ^ + "} 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. +rem Failures append one fixed reason code; walk codes name the component role only. +rem .node-target holds a possibly non-ASCII path: written and read as explicit UTF-8. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { $child = $null; $started = $false; $step = 'policy'; try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' };" ^ + "$step = 'target'; $tools = $env:GENTLE_BOOTSTRAP_TOOLS; $record = Join-Path $tools '.node-target'; if (-not [IO.File]::Exists($record)) { throw 'missing-target' };" ^ + "$node = [IO.File]::ReadAllText($record,[Text.Encoding]::UTF8).TrimEnd([char]13,[char]10); if (-not $node) { throw 'missing-target' };" ^ + "$item = Get-Item -LiteralPath $node -Force; if ($item.PSIsContainer -or ($item.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'unsafe-target' };" ^ + "if (-not [IO.Path]::IsPathRooted($node) -or $node.StartsWith('\\')) { throw 'unsafe-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;" ^ + "$step = 'acl-walk'; while ($path) {" ^ + " $role = 'ancestor'; if ($depth -eq 0) { $role = 'target' } elseif ($depth -eq 1) { $role = 'parent' };" ^ + " $item = Get-Item -LiteralPath $path -Force; if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw ($role + '-reparse') };" ^ + " if ($item.PSIsContainer) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) };" ^ + " if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw ($role + '-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 ($role + '-acl-mask') } };" ^ + " $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;" ^ + "$step = 'start'; $child = New-Object Diagnostics.Process; $child.StartInfo = $start; if (-not $child.Start()) { throw 'no-start' }; $started = $true;" ^ + "$step = 'drain'; $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 '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 '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 'exit-code' };" ^ + "$step = 'version'; $version = $text.Trim(); if ($version -notmatch '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$') { throw 'version-format' };" ^ + "$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 'engine' };" ^ + "if ((Test-Path -LiteralPath (Join-Path $tools '.node-stem')) -and $version -ne 'v24.21.0') { throw 'acquired-version' };" ^ + "} catch { $reason = 'unexpected-' + $step; if ($_.Exception.Message -cmatch '^(policy|missing-target|unsafe-target|unsafe-path|(target|parent|ancestor)-(reparse|owner|acl-mask)|no-start|deadline|output-limit|exit-code|version-format|engine|acquired-version)$') { $reason = $_.Exception.Message };" ^ + "[Console]::Error.WriteLine('Bootstrap: Node version, execution, deadline, output bound or policy check failed; refusing replacement. Reason: ' + $reason); 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; $record = Join-Path $tools '.node-target'; $node = [IO.File]::ReadAllText($record,[Text.Encoding]::UTF8).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 + +rem Success: node/npm/pnpm now persist under PNPM_HOME or were already the user's. +rem Remove only our exact claimed, marked root; Directory.Delete does not recurse +rem through reparse points. A removal problem never fails the installation. +"%GENTLE_BOOTSTRAP_PS%" -NoLogo -NoProfile -NonInteractive -Command ^ + "& { try { $ErrorActionPreference = 'Stop';" ^ + "if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'Managed policy constraint' };" ^ + "$tools = [IO.Path]::GetFullPath($env:GENTLE_BOOTSTRAP_TOOLS); $parent = [IO.Directory]::GetParent($tools).FullName;" ^ + "if ($tools.StartsWith('\\') -or $parent -ne [IO.Path]::GetFullPath($env:LOCALAPPDATA) -or -not [IO.Path]::GetFileName($tools).StartsWith('.gentle-shell-bootstrap-tools.',[StringComparison]::Ordinal)) { throw 'Cleanup target rejected' };" ^ + "$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' };" ^ + "[IO.Directory]::Delete($tools, $true);" ^ + "} catch { [Console]::Error.WriteLine('Bootstrap: installation finished, but temporary tools could not be removed: ' + $env:GENTLE_BOOTSTRAP_TOOLS); exit 1 } }" +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/bootstrap.sh b/scripts/bootstrap.sh new file mode 100644 index 000000000..f98d4a447 --- /dev/null +++ b/scripts/bootstrap.sh @@ -0,0 +1,161 @@ +#!/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 +# Only the exact directory this attempt claimed; rm -rf never follows links in it. +owned_tools() { + [ "$owned" = 1 ] && [ -n "$tools" ] || return 1 + case "$tools" in "$HOME"/.gentle-shell-bootstrap-tools.*/*) return 1;; "$HOME"/.gentle-shell-bootstrap-tools.*) ;; *) return 1;; esac + [ -d "$tools" ] && [ ! -L "$tools" ] && [ -d "$tools/.claim" ] && [ ! -L "$tools/.claim" ] +} +cleanup() { + if owned_tools; 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' +# Exit 0 means node/npm/pnpm now persist under $PNPM_HOME or were already the +# user's. A removal problem never turns the completed installation into failure. +if [ "$owned" = 1 ]; then + mark= + if owned_tools && [ -f "$tools/.bootstrap-owned" ] && [ ! -L "$tools/.bootstrap-owned" ] && + { IFS= read -r mark < "$tools/.bootstrap-owned"; } 2>/dev/null && [ "$mark" = 'gentle-pi prerequisite tooling only' ] && + rm -rf "$tools" 2>/dev/null; then : + else printf '%s\n' "Bootstrap: installation finished, but temporary tools could not be removed: $tools" >&2; fi +fi +owned=0 diff --git a/scripts/install-wizard-preview.mjs b/scripts/install-wizard-preview.mjs new file mode 100644 index 000000000..a0c5b58ab --- /dev/null +++ b/scripts/install-wizard-preview.mjs @@ -0,0 +1,154 @@ +#!/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, requirements } 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 }, +}); +// An earlier run installed the pinned stack, but its setup and `pnpm setup` did not finish. +const recoveryInventory = Object.freeze({ + ...baseInventory, + pi: { available: true, version: "1.0.0", usable: true }, + shell: { available: true, version: requirements.shell, usable: true, global: true }, + gentleAi: { available: true, version: requirements.gentleAi, usable: true, compatible: true }, + globalBin: { ...baseInventory.globalBin, onPath: false }, + setup: { available: true, recoverable: true }, +}); +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" } }, + recovery: { + inventories: [recoveryInventory], + steps: ["check-npm", "check-global-bin", "check-recoverable-stack", ...install.slice(1), "persist-path"], + result: { outcome: "terminal-action-required", action: "open-new-terminal" }, + }, + 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-downloads.mjs b/scripts/installer-downloads.mjs new file mode 100644 index 000000000..c9cb4e3ef --- /dev/null +++ b/scripts/installer-downloads.mjs @@ -0,0 +1,297 @@ +import { createHash, timingSafeEqual } from "node:crypto"; +import { lstatSync, readFileSync, realpathSync, mkdirSync, mkdtempSync, writeFileSync, renameSync, rmSync } from "node:fs"; +import { basename, 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. */ +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, + 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"); + } +} + +/** Remove only the exact private `$HOME/.gentle-shell-bootstrap-tools.*` directory + * carrying this module's marker. rmSync never follows links inside it. + * Returns false instead of throwing so success never turns into failure. + */ +export function removeOwnedTools(tools, home) { + try { + if (typeof tools !== "string" || typeof home !== "string" || !home.startsWith("/") || resolve(tools) !== tools) return false; + if (dirname(tools) !== resolve(home) || !basename(tools).startsWith(".gentle-shell-bootstrap-tools.")) return false; + const info = lstatSync(tools); + if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid()) return false; + const marker = join(tools, ".bootstrap-owned"); + if (!regular(marker) || readFileSync(marker, "utf8") !== "gentle-pi prerequisite tooling only\n") return false; + rmSync(tools, { recursive: true }); + return true; + } catch { + return false; + } +} + +/** 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"))); + }); +} + +/** `env` and transport/process adapters are injectable for local fixtures only. */ +export async function bootstrap(bundle, suppliedTools, { env = process.env, adapters = {} } = {}) { + 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", env) && !tools) { tools = privateTools(env.HOME); created = true; } + const result = await ensurePnpm({ tools, env, nodeVersion: process.versions.node, adapters }); + await launchWizard({ bundle, env: result.env }); + } catch (error) { + if (created) rmSync(tools, { recursive: true, force: true }); + throw error; + } + // Exit 0 means pnpm now persists under $PNPM_HOME or was already the user's. + if (created && !removeOwnedTools(tools, env.HOME)) { + console.error(`Bootstrap: installation finished, but temporary tools could not be removed: ${tools}`); + } +} +/** The Windows helper failure line appends only a fixed-shape reason code. Any + * other value, including a failed module import, reports `unexpected-helper`. + */ +export function windowsBootstrapMessage(error) { + const reason = typeof error?.reason === "string" && /^(?:[a-z][a-z-]* \([a-z][a-z-]*\)|unexpected-[a-z][a-z-]*)$/.test(error.reason) ? error.reason : "unexpected-helper"; + return `Windows bootstrap failed; policy, prerequisite or bundle evidence rejected. No installation completed. Reason: ${reason}`; +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + 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((error) => { + console.error(windowsBootstrapMessage(error)); + 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 { + 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/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs new file mode 100644 index 000000000..7e4e8d4b2 --- /dev/null +++ b/scripts/installer-preflight.mjs @@ -0,0 +1,193 @@ +import { readFileSync } from "node:fs"; +import { posix, win32 } from "node:path"; +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, +}); + +/** 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 + * 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). + * 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, or + * { available: true, recoverable: true } for the pinned stack this pnpm installed + * whose setup did not finish (only its setup is then planned). + * 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; +} +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"; + 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"; + // Setup recovery: the setup probe proved the pinned stack this pnpm installed + // (an earlier run stopped in setup), so only the public setup is rerun. + const recovering = inventory.setup?.available === true && inventory.setup.recoverable === true && + ["pi", "shell", "gentleAi"].every((name) => tools[name].status === "reusable"); + let setupStatus = "unknown"; + if (missingShell || needsNative || inventory.setup === false || recovering) 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"); + // 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. A recovery + // never persists: the earlier run did that before installing the stack. + const node = inventory.node; + const persistable = tools.node.status === "reusable" && !recovering; + if (persistable && 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 (persistable) { + 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); + // 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/scripts/installer-probes.mjs b/scripts/installer-probes.mjs new file mode 100644 index 000000000..2f9b92a68 --- /dev/null +++ b/scripts/installer-probes.mjs @@ -0,0 +1,323 @@ +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, + recoverableStackRoot, + 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. + * Only a caller that passes `stderrTail: ` (a positive integer, clamped + * to 4 KiB) gets stderr piped; the result then carries just its last bytes as + * `stderrTail`. Callers must sanitize that text before showing it anywhere. + * 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, stderrTail }) => new Promise((resolve) => { + let size = 0; + let truncated = false; + let timedOut = false; + const chunks = []; + const tailBytes = Number.isSafeInteger(stderrTail) && stderrTail > 0 ? Math.min(stderrTail, 4096) : 0; + let tail = Buffer.alloc(0); + const withTail = (result) => (tailBytes > 0 ? { ...result, stderrTail: tail.toString("utf8") } : result); + let child; + try { + child = spawn(command, argv, { env, shell: false, stdio: ["ignore", "pipe", tailBytes > 0 ? "pipe" : "ignore"], windowsHide: true }); + } catch { + resolve(withTail({ 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(withTail({ code, signal, timedOut, truncated, stdout: Buffer.concat(chunks).toString("utf8") })); + }; + // Keep only the last bytes: a long setup log never accumulates in memory. + if (tailBytes > 0) child.stderr?.on("data", (chunk) => { + const joined = Buffer.concat([tail, chunk]); + tail = joined.subarray(Math.max(0, joined.length - tailBytes)); + }); + // 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; + /** Output of the single `list -g` call, or null when it is unavailable. */ + const globalListing = () => (listing ??= (async () => { + const pnpm = globalBin ? await pnpmInvocation(env, platform, fs) : null; + if (!pnpm) return null; + return output(pnpm.command, [...pnpm.prefix, "list", "-g", "--depth", "0", "--json"], child, deadlines.list); + })()); + /** pnpm-global packages by name from that listing; null entries are ambiguous. */ + const globalPackages = async () => { + const stdout = await globalListing(); + 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. Only the + // pinned stack this pnpm installed (an earlier run whose setup did not + // finish) is recoverable: its public `gentle-shell setup` may be rerun. + const shell = await globalPackage(SHELL_PACKAGE, "gentle-shell"); + if (shell.state === "absent") return false; + if (shell.state !== "present") return unknown(); + const root = await recoverableStackRoot(await globalListing(), globalBin.pnpmHome, platform, fs); + return root === null ? unknown() : { available: true, recoverable: true }; + }, + }; + 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 new file mode 100644 index 000000000..9d60a09bc --- /dev/null +++ b/scripts/installer-runner.mjs @@ -0,0 +1,636 @@ +import { readFileSync } from "node:fs"; +import { posix, win32 } from "node:path"; +import { + gentleAiBinaryPath, + gentleAiDevBinaryOverrideConfigured, + resolveGentleAiBinary, +} from "../runtime/gentle-ai-binary.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. When an earlier run +// installed the pinned stack but stopped in setup, a fixed recovery re-verifies +// that stack and reruns only `gentle-shell setup` (and `pnpm setup`), never `add -g`. + +/** Pi version installed next to gentle-pi (optional peer, resolved in one add). */ +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", + "existing-stack-unverified", +]); +/** 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 }); + +// 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" }, + "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 }, + "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" }, +}); +// The clean-stack path: both global packages are missing. +const requiredActions = ["install-pi", "install-shell", "setup-shell", "verify-readiness"]; +const optionalActions = ["setup-global-bin"]; +// Setup recovery: the pinned stack this pnpm installed is present (planPreflight +// saw a recoverable setup), so only setup and the optional PATH step remain. +const recoveryActions = ["setup-shell", "verify-readiness"]; +// 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); + 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); + const only = (allowed) => ids.every((id) => allowed.includes(id)); + // All or nothing, like the persistence variants: never part of an installation. + const recovery = recoveryActions.every((id) => ids.includes(id)) && only([...recoveryActions, ...optionalActions]); + // gentle-pi's postinstall may build Gentle AI from source on Windows; the runner + // never acquires Go. A recovery runs no postinstall: its binary is verified. + if (platform === "win32" && (ids.includes("acquire-go") || (!recovery && 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 clean = requiredActions.every((id) => ids.includes(id)) && only([...requiredActions, ...optionalActions, ...persistenceActions]); + return clean || recovery ? 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. */ +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. */ +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; + 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 `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). + */ +export async function lookPath(name, env, platform, fs) { + const path = platform === "win32" ? win32 : posix; + const extensions = platform === "win32" + ? 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(env[pathKeyOf(env, platform)] ?? "").split(path.delimiter)) { + if (!path.isAbsolute(directory)) continue; + for (const extension of extensions) { + 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. + * 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. + */ +export async function genuineNpm(child, platform, nodePath, adapters, globalBin = null) { + const path = platform === "win32" ? win32 : posix; + 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; + 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 fs.realpath(first); + } + 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); +} + +export function succeeded(result) { + return result?.code === 0 && !result.signal && result.timedOut !== true; +} + +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); +} + +/** How many times Pi and gentle-pi are listed across every project and + * dependency field of a `list -g --json` output, or null when the shape is unknown. + */ +function stackListings(stdout) { + const projects = JSON.parse(stdout); + if (!Array.isArray(projects)) return null; + let listed = 0; + 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; + listed += [PI_PACKAGE, SHELL_PACKAGE].filter((name) => Object.hasOwn(dependencies, name)).length; + } + } + return listed; +} + +/** 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 listed = stackListings(stdout); + if (listed === null) return false; + return listed === 0 ? true : "existing-stack"; +} + +/** 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); + 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)]; + return contains(home, root, platform) ? root : null; +} + +/** A stack this pnpm installed whose setup may only be rerun (setup recovery): + * Pi and gentle-pi are each listed exactly once, in the single project that + * verifiedPackageRoot accepts (Pi at PI_INSTALL_VERSION, gentle-pi at this + * package version, realpath confined under PNPM_HOME). Returns the root or null. + */ +export async function recoverableStackRoot(stdout, pnpmHome, platform, fs) { + return stackListings(stdout) === 2 ? verifiedPackageRoot(stdout, pnpmHome, platform, fs) : 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. + * `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" }; +} + +/** One displayable line from the bounded output of a failed `gentle-shell + * setup` or `pnpm setup`: the last error line (`Error:` or a pnpm `ERR_` code), + * else the last non-empty line. + * Terminal escapes, control and bidi characters are removed, the user's home + * becomes `~`, and the result has at most 300 characters. Null when nothing + * remains. Untrusted output: the host still shows it only as text. + */ +export function setupErrorDetail(text, home, platform) { + if (typeof text !== "string") return null; + const lines = text.split(/\r\n|\r|\n/).map((line) => line + .replace(/\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g, "") + .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g, "") + .replace(/\t/g, " ") + .replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "") + .trim()).filter((line) => line.length > 0); + let line = lines.findLast((candidate) => /Error:|\bERR_[A-Z0-9_]+/.test(candidate)) ?? lines.at(-1); + if (line === undefined) return null; + const path = platform === "win32" ? win32 : posix; + const root = typeof home === "string" && path.isAbsolute(home) ? home.replace(/[\\/]+$/, "") : ""; + if (root.length > 1) { + // Only the whole home path, not a longer sibling such as /home/user for /home/u. + line = line.replace(new RegExp(`${escapeRegExp(root)}(?=$|[\\\\/\\s"'():,;\\]])`, platform === "win32" ? "gi" : "g"), "~"); + } + return Array.from(line).slice(0, 300).join(""); +} + +/** + * 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. A failed `shell-setup` or `persist-path` may add `detail`, + * one sanitized output line (setupErrorDetail); no other output is kept. 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, stderrTail? }) with shell:false semantics returning + * { code, signal, timedOut, stdout, stderrTail? }, fs { isFile, realpath, readText }, + * verifyGentleAi({ packageRoot, platform, env, home }) and log({ step, status }). + * Nothing is ever deleted; no provisioning marker is written. A setup-recovery + * plan replaces check-existing-stack with check-recoverable-stack and skips + * install-global; every later step and outcome rule is the same. + */ +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); + const ids = request.plan.actions.map((action) => action.id); + // planGate accepted either the clean-stack path or the fixed setup recovery. + const recovering = !ids.includes("install-shell"); + // 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 = [ + ...(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(); + 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, + // and a recovery still finds exactly the pinned stack this pnpm installed. + recovering + ? ["check-recoverable-stack", "global-list-unavailable", async () => { + const result = await list(); + if (!succeeded(result)) return false; + const root = await recoverableStackRoot(String(result.stdout ?? ""), globalBin.pnpmHome, platform, adapters.fs) + .catch(() => null); + return root !== null || "existing-stack-unverified"; + }] + : ["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; + let setupDetail = 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)]] : []), + ]; + // A recovery never runs `add -g`: the installed packages are verified as they are. + const install = ["install-global", async () => succeeded(await runPnpm(["add", "-g", `${PI_PACKAGE}@${PI_INSTALL_VERSION}`, + `${SHELL_PACKAGE}@${requirements.shell}`, `--allow-build=${SHELL_PACKAGE}`], deadlines.install))]; + const steps = [ + ...(persistRuntime ? persistence : addOnly ? packageManagers : []), + ...(recovering ? [] : [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], + // The setup commands are the only ones whose stderr tail is requested. + ["shell-setup", async () => { + const result = await adapters.run(adapters.nodePath, [path.join(packageRoot, "bin", "gentle-shell.mjs"), "setup"], + { env: setupEnvironment(child, platform), deadlineMs: deadlines.setup, stderrTail: 4096 }); + if (succeeded(result)) return true; + setupDetail = setupErrorDetail(result?.stderrTail, home, platform); + return false; + }], + ]; + // 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. + // pnpm setup installs @pnpm/exe over the network, so it gets the setup deadline. + const persistPath = !globalBin.onPath; + if (persistPath) { + steps.push(["persist-path", async () => { + const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup"], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 }); + if (succeeded(result)) return true; + // pnpm prints its own errors, such as ERR_PNPM_UNKNOWN_SHELL, on stdout. + setupDetail = setupErrorDetail(result?.stderrTail, home, platform) ?? setupErrorDetail(result?.stdout, home, platform); + return false; + }]); + } + for (const [step, run] of steps) { + if (!(await Promise.resolve().then(run).catch(() => false))) { + log({ step, status: "failed" }); + const detail = ["shell-setup", "persist-path"].includes(step) && setupDetail ? { detail: setupDetail } : {}; + return { outcome: "failed", failedStep: step, completed, ...detail }; + } + completed.push(step); + log({ step, status: "done" }); + } + 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/scripts/installer-server.mjs b/scripts/installer-server.mjs new file mode 100644 index 000000000..0ccad5a34 --- /dev/null +++ b/scripts/installer-server.mjs @@ -0,0 +1,533 @@ +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.", + "existing-stack-unverified": "The installed Pi and Gentle Shell changed after the plan was made, or are no longer the versions this installer set up. Nothing was changed; run the installer again to check this computer again.", + }), + failed: Object.freeze({ + "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`.", + }), + // A failed shell-setup whose detail shows GitHub's anonymous API limit. + setupRateLimit: "`gentle-shell setup` could not finish because GitHub's limit for anonymous API requests was reached on this network. Wait up to an hour, then run the installer again.", + // A failed persist-path whose detail shows pnpm could not tell the shell (POSIX SHELL missing or unsupported). + persistPathShell: "`pnpm setup` could not tell which shell profile to edit because the SHELL environment variable is missing or names an unsupported shell. Open a regular terminal and run the installer again, or add `$PNPM_HOME/bin` to your PATH yourself.", + fallback: "The installation stopped for an unexpected reason. Nothing else will run; check the terminal and run the installer again.", +}); + +/** The runner's setup detail, bounded again: text only, no control characters, + * at most 300 characters. Null when nothing remains. + */ +function setupDetail(value) { + if (typeof value !== "string") return null; + const text = Array.from(value.replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069]/g, "").trim()).slice(0, 300).join(""); + return text.length > 0 ? text : null; +} +const rateLimited = (detail) => /rate limit/i.test(detail) || (/GitHub API/i.test(detail) && /\b403\b/.test(detail)); +const unknownShell = (detail) => /ERR_PNPM_(?:UNKNOWN|UNSUPPORTED)_SHELL/.test(detail); +// Fixed setup commands whose sanitized last error line may reach the browser. +const detailSteps = Object.freeze({ "shell-setup": rateLimited, "persist-path": unknownShell }); +const detailGuidance = Object.freeze({ "shell-setup": "setupRateLimit", "persist-path": "persistPathShell" }); + +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; +} +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: toolName(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; + const step = Object.hasOwn(detailSteps, result.failedStep) ? result.failedStep : null; + const detail = step ? setupDetail(result.detail) : null; + if (detail) { + view.detail = detail; + if (detailSteps[step](detail)) view.guidance = guidance[detailGuidance[step]]; + } + } 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 }; + // Viewing an untrusted result can throw; the installation must still end + // with a failed outcome and release the single-flight and idle locks. + Promise.resolve() + .then(() => runInstall(request, record)) + .then((result) => { + try { + return outcomeView(result); + } catch { + return outcomeView(null); + } + }, () => outcomeView(null)) + .then((view) => { + lastOutcome = view; + }) + .finally(() => { + 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/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..2357219d0 --- /dev/null +++ b/scripts/installer-windows.mjs @@ -0,0 +1,362 @@ +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. +// ACLs are read through .NET, never Get-Acl: a PowerShell 7 parent's PSModulePath +// makes Windows PowerShell 5.1 fail to autoload Microsoft.PowerShell.Security. +// An intentional rejection prints one fixed `unsafe:-` code (the role +// of the component, never its path); any other exception still exits nonzero. +const aclCheck = String.raw` +$ErrorActionPreference = 'Stop'; +try { + if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') { throw 'policy' }; + $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) { + $role = 'ancestor'; if ($depth -eq 0) { $role = 'target' } elseif ($depth -eq 1) { $role = 'parent' }; + $item = Get-Item -LiteralPath $path -Force; + if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw ($role + '-reparse') }; + if ($item.PSIsContainer) { $acl = [IO.Directory]::GetAccessControl($path) } else { $acl = [IO.File]::GetAccessControl($path) }; + if ($trusted -notcontains $acl.GetOwner([Security.Principal.SecurityIdentifier]).Value) { throw ($role + '-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 ($role + '-acl-mask') }; + }; + $parent = [IO.Directory]::GetParent($path); + if ($null -eq $parent) { break }; $path = $parent.FullName; $depth++; + }; + 'safe' +} catch { if ($_.Exception.Message -cmatch '^(policy|(target|parent|ancestor)-(reparse|owner|acl-mask))$') { 'unsafe:' + $_.Exception.Message } else { throw } } +`; +const storageChecks = /^(?:policy|(?:target|parent|ancestor)-(?:reparse|owner|acl-mask))$/; +/** Only exact `safe` passes. A fixed rejection code is kept as `check`; any other + * output is rejected without carrying its text. + */ +export function windowsStorageEvidence(output) { + if (output === "safe") return; + const check = typeof output === "string" && output.startsWith("unsafe:") ? output.slice(7) : ""; + throw Object.assign(new Error("Windows ACL evidence rejected"), storageChecks.test(check) ? { check } : {}); +} +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"); + windowsStorageEvidence(processAdapter(join(env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", aclCheck], { ...env, GENTLE_WINDOWS_CHECK: path })); +} + +/** 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(";"); + // .cpl is known because Windows PowerShell 5.1 appends it for its children, and + // bootstrap.cmd starts this helper from there. Known is not accepted: a .cpl (or + // any non-.cmd wrapper / non-.exe Node) found first still fails closed below. + if (!extensions.length || new Set(extensions).size !== extensions.length || extensions.some((extension) => !/^\.(com|exe|bat|cmd|vbs|vbe|js|jse|wsf|wsh|msc|cpl)$/.test(extension))) throw new Error("Unknown Windows PATHEXT semantics"); + // CMD ignores empty entries (a trailing `;` is the Windows default); every + // other non-absolute, quoted or UNC entry still fails closed. + for (const directory of [process.cwd(), ...path.split(";").filter((entry) => entry !== "")]) { + 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. + * `onStep` receives fixed phase names for diagnostics only; it never alters checks. + */ +export async function ensureWindowsPnpm({ tools, env, node = process.execPath, adapters = {}, onStep = () => {} }) { + const processAdapter = adapters.process ?? windowsProcessCheck; + const storage = adapters.storage ?? verifyWindowsStorage; + onStep("pnpm-discovery"); + const existing = (adapters.findCommand ?? findWindowsCommand)(env); + if (existing) { + onStep("wrapper-storage"); + storage(existing, env); + onStep("wrapper"); + 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. + onStep("node-discovery"); + 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 [step, path] of [["node-storage", selectedNode], ["entry-storage", entry], ["metadata-storage", metadataPath]]) { + onStep(step); + storage(path, env); + if (!regular(path)) throw new Error("Unsafe pnpm wrapper target"); + } + onStep("package"); + 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"); + onStep("cli-proof"); + proveCli(selectedNode, entry, metadata, env, processAdapter); + return { acquired: false, env, command: selectedNode, prefix: [entry] }; + } + onStep("tools-check"); + 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"); + onStep("download"); + const bytes = await verifiedDownload("pnpm", adapters); + onStep("archive"); + const entries = readWindowsPnpmArchive(bytes); + 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; + onStep("publish"); + 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"); + } +} + +// Fixed, non-sensitive helper diagnostics. Every explicit check on this path has +// one code; the step names the phase. Anything else, including fs, JSON or spawn +// errors, reports `unexpected-`. No path, SID or exception text is copied. +const helperSteps = new Set(["bundle", "tools-storage", "pnpm-discovery", "wrapper-storage", "wrapper", "node-discovery", "node-storage", "entry-storage", + "metadata-storage", "package", "cli-proof", "tools-check", "download", "archive", "publish", "launch"]); +const helperRejections = new Map(Object.entries({ + "Windows bootstrap requires native Windows": "native-unavailable", + "Native Windows storage verification unavailable": "native-unavailable", + "Required wizard bundle file missing": "bundle-missing", + "Windows repository prerequisite rejected": "prerequisite", + "Unsafe Windows storage path": "unsafe-path", + "Windows ACL evidence rejected": "acl-evidence", + "Unknown Windows cwd-search semantics": "cwd-search", + "Unknown Windows PATH/PATHEXT": "path-missing", + "Unknown Windows PATHEXT semantics": "pathext", + "Unknown Windows PATH": "path-entry", + "Unknown extensionless Windows prerequisite": "extensionless", + "Unknown pnpm wrapper; refusing replacement": "wrapper-unknown", + "Unknown pnpm wrapper": "wrapper-unproven", + "Unknown pnpm wrapper; refusing execution or replacement": "wrapper-unproven", + "Unknown wrapper-selected Node": "wrapper-node", + "Unsafe pnpm wrapper target": "wrapper-target", + "Windows pnpm package target rejected": "package-target", + "Windows pnpm engine or identity rejected": "pnpm-engine", + "Windows pnpm version rejected": "pnpm-version", + "Windows pnpm global capability rejected": "pnpm-capability", + "Windows prerequisite process failed": "process-failed", + "Unproven command interpreter target": "interpreter", + "Unsafe Windows tools": "unsafe-tools", + "Conflicting Windows pnpm destination": "pnpm-conflict", + "Windows pnpm pin metadata rejected": "pnpm-pin", + "Windows pnpm entry missing": "pnpm-entry", + "Windows pnpm acquisition failed; no installation completed": "acquisition", + "Future wizard entry is missing; no live wizard is available": "wizard-missing", + "Wizard child could not start": "wizard-start", + "Wizard child failed": "wizard-exit", +})); +const archiveRejection = /^Unsafe (?:tar|archive|duplicate archive|truncated tar|pnpm package root)\b/; +export function windowsBootstrapReason(error, step) { + if (!helperSteps.has(step)) throw new Error("Unknown Windows bootstrap step"); + const message = error instanceof Error ? error.message : undefined; + const check = error instanceof Error && storageChecks.test(error.check ?? "") ? error.check : undefined; + const code = check ?? helperRejections.get(message) ?? (archiveRejection.test(message ?? "") ? "archive" : undefined); + return code ? `${code} (${step})` : `unexpected-${step}`; +} + +export async function bootstrapWindows({ bundle, tools, env }) { + let step = "bundle"; + try { + 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"); + step = "tools-storage"; + verifyWindowsStorage(tools, env); + const result = await ensureWindowsPnpm({ tools, env, onStep: (next) => { step = next; } }); + // Fixed data handoff for the future T5 entry. No caller-controlled command text. + step = "launch"; + await launchWizard({ bundle, env: { ...result.env, GENTLE_INSTALL_PNPM_NODE: result.command, GENTLE_INSTALL_PNPM_ENTRY: result.prefix[0] } }); + } catch (error) { + throw Object.assign(new Error("Windows bootstrap rejected"), { reason: windowsBootstrapReason(error, step) }); + } +} diff --git a/scripts/test-installer-acceptance.sh b/scripts/test-installer-acceptance.sh new file mode 100644 index 000000000..c06320db1 --- /dev/null +++ b/scripts/test-installer-acceptance.sh @@ -0,0 +1,161 @@ +#!/bin/sh +# Opt-in Linux clean-machine acceptance for the browser installation wizard. +# Runs the real bootstrap and wizard inside a disposable debian:bookworm-slim +# container as a non-root user. Network access happens only inside that +# container; nothing reads or writes the host HOME. Not part of CI. +# +# Usage: sh scripts/test-installer-acceptance.sh [--worktree] +# default bundle = `git archive HEAD` +# --worktree bundle = tracked files as they are in the working tree, +# including uncommitted modifications (untracked files excluded) +# GENTLE_ACCEPTANCE_KEEP=1 keeps a failed run's container for inspection. +set -eu +fail() { printf 'acceptance: %s\n' "$1" >&2; exit 1; } +note() { printf 'acceptance: %s\n' "$1"; } +source_mode='head' +case $# in + 0) ;; + 1) [ "$1" = --worktree ] || fail "unknown argument: $1"; source_mode=worktree;; + *) fail 'usage: sh scripts/test-installer-acceptance.sh [--worktree]';; +esac +image=debian:bookworm-slim +# Upper bound for the whole installation inside the container. +install_limit=${GENTLE_ACCEPTANCE_INSTALL_SECONDS:-1500} +case $install_limit in ''|*[!0-9]*) fail 'GENTLE_ACCEPTANCE_INSTALL_SECONDS must be a whole number';; esac +command -v docker >/dev/null 2>&1 || fail 'Docker is required; refusing to run anything on the host' +docker info >/dev/null 2>&1 || fail 'Docker daemon is unavailable; refusing to run anything on the host' +command -v git >/dev/null 2>&1 || fail 'git is required to package the bundle' +repo=$(git rev-parse --show-toplevel) || fail 'run from inside the repository' +started=$(date +%s) +elapsed() { printf '%ss' "$(( $(date +%s) - started ))"; } +name=gentle-install-acceptance-$$-$started +container= +passed=0 +cleanup() { + if [ -n "$container" ] && [ "$passed" = 0 ] && [ "${GENTLE_ACCEPTANCE_KEEP:-}" = 1 ]; then + printf 'acceptance: kept %s; inspect with: docker exec -it -u tester %s bash; remove with: docker rm -f %s\n' "$container" "$container" "$container" >&2 + elif [ -n "$container" ]; then + docker rm -f "$container" >/dev/null 2>&1 || printf 'acceptance: could not remove container %s\n' "$container" >&2 + fi +} +trap cleanup EXIT +trap 'exit 1' HUP INT TERM + +note "image $image, bundle from $source_mode" +docker run -d --name "$name" --pull missing "$image" sleep infinity >/dev/null || fail 'cannot start the container' +container=$name +# Clean machine: no Node, npm, pnpm, Pi or Gentle Shell. curl and CA roots are +# bootstrap prerequisites that a stock Debian desktop already has. +docker exec "$container" sh -c 'export DEBIAN_FRONTEND=noninteractive + apt-get update -qq >/dev/null && apt-get install -y -qq --no-install-recommends ca-certificates curl >/dev/null + useradd --create-home --shell /bin/bash tester + mkdir /opt/bundle + for tool in node npm pnpm pi gentle-shell; do if command -v "$tool" >/dev/null; then echo "unexpected $tool" >&2; exit 1; fi; done' \ + || fail 'container preparation failed' +# gentle-shell setup makes 2 anonymous GitHub API requests (upstream Gentle AI's +# latest-engram lookup), limited to 60 per hour per public IP. /rate_limit does +# not consume quota. Stop early instead of failing deep inside setup. +quota=$(docker exec "$container" sh -c 'curl -fsS --max-time 20 -H "Accept: application/vnd.github+json" https://api.github.com/rate_limit' | tr -d ' \n') \ + || fail 'cannot query the GitHub API rate limit from the container' +rate=$(printf '%s' "$quota" | sed -n 's/.*"rate":{\([^}]*\)}.*/\1/p') +remaining=$(printf '%s' "$rate" | sed -n 's/.*"remaining":\([0-9]*\).*/\1/p') +reset=$(printf '%s' "$rate" | sed -n 's/.*"reset":\([0-9]*\).*/\1/p') +[ -n "$remaining" ] && [ -n "$reset" ] || fail 'unexpected GitHub rate limit response' +reset_at=$(date -u -d "@$reset" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || date -u -r "$reset" '+%Y-%m-%d %H:%M:%S UTC' 2>/dev/null || printf 'epoch %s' "$reset") +if [ "$remaining" -lt 5 ]; then + fail "only $remaining anonymous GitHub API requests remain on this network (setup needs 2); the quota resets at $reset_at. Not starting." +fi +note "GitHub API quota: $remaining anonymous requests left (resets at $reset_at)" +if [ "$source_mode" = head ]; then + git -C "$repo" archive --format=tar HEAD +else + git -C "$repo" ls-files -z | tar -C "$repo" --null -T - -cf - +fi | docker exec -i "$container" tar --no-same-owner -xf - -C /opt/bundle || fail 'cannot copy the bundle' +note "prepared in $(elapsed)" + +bootstrap_started=$(date +%s) +# A terminal session exports SHELL (the user's login shell); docker exec does +# not. pnpm setup needs it to pick the profile to edit, and fails with +# ERR_PNPM_UNKNOWN_SHELL without it. +docker exec -d -u tester -w /home/tester -e SHELL=/bin/bash "$container" sh -c \ + 'sh /opt/bundle/scripts/bootstrap.sh > "$HOME/bootstrap.log" 2>&1; echo $? > "$HOME/bootstrap.status"' \ + || fail 'cannot start the bootstrap' + +# Headless browser stand-in: the same requests the wizard page makes. +driver=$(cat <<'DRIVER' +set -eu +log=$HOME/bootstrap.log +say() { printf 'driver: %s\n' "$1"; } +stop() { say "$1"; cat "$log" 2>/dev/null || :; exit 1; } +waited=0 +while :; do + url=$(grep -o 'http://127\.0\.0\.1:[0-9]*/session?code=[A-Za-z0-9_-]*' "$log" 2>/dev/null | head -n 1) || url= + [ -z "$url" ] || break + [ ! -f "$HOME/bootstrap.status" ] || stop 'bootstrap exited before printing the session URL' + waited=$((waited + 1)); [ "$waited" -lt 600 ] || stop 'no session URL after 600 s' + sleep 1 +done +say "session URL after ${waited}s" +origin=${url%%/session*} +jar=$HOME/acceptance-cookies +curl -fsS -c "$jar" -b "$jar" -o /dev/null "$url" || stop 'session redemption failed' +api() { curl -fsS -b "$jar" -H 'X-Gentle-Install: 1' "$@"; } +post() { api -X POST -H "Origin: $origin" -H 'Content-Type: application/json' --data "$1" "$origin$2"; } +plan=$(api "$origin/api/plan") || stop 'plan request failed' +case $plan in *'"blockers":[]'*) ;; *) stop "plan has blockers: $plan";; esac +plan_id=$(printf '%s' "$plan" | sed -n 's/.*"planId":"\([^"]*\)".*/\1/p') +[ -n "$plan_id" ] || stop "plan has no planId: $plan" +say "plan: $(printf '%s' "$plan" | grep -o '"actions":\[[^]]*\]' | grep -o '"id":"[^"]*"' | cut -d'"' -f4 | tr '\n' ' ')" +post "{\"planId\":\"$plan_id\",\"consent\":true}" /api/install >/dev/null || stop 'install request rejected' +seq=0 +polls=0 +while :; do + progress=$(api "$origin/api/progress?after=$seq") || stop 'progress request failed' + printf '%s' "$progress" | grep -o '"step":"[^"]*","status":"[^"]*"' | sed 's/"step":"\([^"]*\)","status":"\([^"]*\)"/ step \1: \2/' || : + last=$(printf '%s' "$progress" | grep -o '"seq":[0-9]*' | tail -n 1 | cut -d: -f2) || last= + [ -z "$last" ] || seq=$last + case $progress in *'"running":false'*'"outcome":{'*) break;; esac + polls=$((polls + 1)); [ "$polls" -lt "$INSTALL_LIMIT" ] || stop "installation still running after $INSTALL_LIMIT s" + sleep 1 +done +outcome=$(printf '%s' "$progress" | sed -n 's/.*"outcome":{"outcome":"\([a-z-]*\)".*/\1/p') +say "outcome: $outcome" +post '{}' /api/shutdown >/dev/null || stop 'shutdown request rejected' +waited=0 +until [ -f "$HOME/bootstrap.status" ]; do + waited=$((waited + 1)); [ "$waited" -lt 60 ] || stop 'bootstrap still running 60 s after shutdown' + sleep 1 +done +status=$(cat "$HOME/bootstrap.status") +say "bootstrap exit status: $status" +cat "$log" +case $outcome in ready|terminal-action-required) [ "$status" = 0 ] || exit 1;; *) exit 1;; esac +DRIVER +) +printf '%s\n' "$driver" | docker exec -i -u tester -w /home/tester -e INSTALL_LIMIT="$install_limit" "$container" sh -s \ + || fail "wizard flow failed after $(elapsed)" +note "bootstrap and wizard finished in $(( $(date +%s) - bootstrap_started ))s" + +# A fresh interactive shell must find the persisted stack; the bootstrap tools +# must be gone. bash -i without a terminal warns about job control; harmless. +checks=$(cat <<'CHECKS' +set -eu +for tool in node npm pnpm gentle-shell; do printf '%s -> %s\n' "$tool" "$(command -v "$tool")"; done +printf 'node %s, npm %s, pnpm %s\n' "$(node --version)" "$(npm --version)" "$(pnpm --version)" +gentle-shell --version +for dir in "$HOME"/.gentle-shell-bootstrap-tools.*; do + if [ -e "$dir" ] || [ -L "$dir" ]; then echo "bootstrap tools left behind: $dir" >&2; exit 1; fi +done +echo 'bootstrap tools removed' +CHECKS +) +docker exec -u tester -w /home/tester "$container" bash -ic "$checks" || fail "fresh-terminal checks failed after $(elapsed)" +# Informational: pnpm setup edits only ~/.bashrc, which non-interactive login +# shells skip on Debian (see docs/install-wizard.md). +if docker exec -u tester -w /home/tester "$container" bash -lc 'command -v gentle-shell' >/dev/null 2>&1; then + note 'bash -lc resolves gentle-shell' +else + note 'bash -lc does not resolve gentle-shell (expected: ~/.profile is not edited)' +fi +passed=1 +note "PASS in $(elapsed)" diff --git a/scripts/verify-package-files.mjs b/scripts/verify-package-files.mjs index a47e1fdbe..06160ac97 100644 --- a/scripts/verify-package-files.mjs +++ b/scripts/verify-package-files.mjs @@ -7,8 +7,26 @@ 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", + "assets/install-wizard/wizard.js", + "assets/install-wizard/wizard.css", +]); + +export const requiredPaths = [ "bin/gentle-shell.mjs", + ...installerPaths, "extensions/gentle-notifications.ts", "lib/notification-audio.ts", "lib/notification-audio-native.ts", @@ -200,6 +218,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); @@ -314,10 +340,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/install-wizard.test.ts b/tests/install-wizard.test.ts new file mode 100644 index 000000000..cc5331f8e --- /dev/null +++ b/tests/install-wizard.test.ts @@ -0,0 +1,931 @@ +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, requirements } 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, /