From e3effb3fc4eef75d83f501e31dde703d370e2207 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 11:51:39 -0700 Subject: [PATCH] feat: require explicit browser provider consent --- .github/workflows/release-artifacts.yml | 3 +- .github/workflows/windows-setup-e2e.yml | 4 +- browse/src/browser-manager.ts | 34 ++- browse/test/commands.test.ts | 21 +- bun.lock | 8 +- docs/gstack-2/JUDGMENT-PARITY.md | 2 +- docs/gstack-2/JUDGMENT-PROVENANCE.json | 6 +- .../parity/contracts/open-gstack-browser.json | 2 +- evals/parity/contracts/pair-agent.json | 2 +- .../contracts/setup-browser-cookies.json | 2 +- evals/parity/manifest.json | 6 +- package.json | 4 +- runtime/browser-choice.mjs | 154 ++++++++++++ runtime/cli.js | 101 +++++++- runtime/config.js | 40 +++ runtime/doctor.js | 55 +++- runtime/install.js | 231 +++++++++++++++-- runtime/runtime-bootstrap.mjs | 123 +++++++-- scripts/gstack2/generate-skill-tree.ts | 12 +- scripts/gstack2/render-legacy.ts | 2 +- scripts/gstack2/run-parity.ts | 6 +- scripts/gstack2/runtime-install-smoke.sh | 2 +- setup | 7 +- skills/debug/references/RUNTIME.md | 10 +- .../references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- skills/design/references/RUNTIME.md | 10 +- .../references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- skills/plan/references/RUNTIME.md | 10 +- .../references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- skills/qa/references/RUNTIME.md | 10 +- .../references/legacy/open-gstack-browser.md | 4 +- skills/qa/references/legacy/pair-agent.md | 4 +- .../legacy/setup-browser-cookies.md | 4 +- .../qa/references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- skills/review/references/RUNTIME.md | 10 +- .../references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- skills/ship/references/RUNTIME.md | 10 +- .../references/support/browser-choice.mjs | 154 ++++++++++++ .../references/support/runtime-bootstrap.mjs | 123 +++++++-- test/gstack2-runtime-install.test.ts | 199 +++++++++++++++ test/gstack2-runtime-setup-ux.test.ts | 234 +++++++++++++++++- test/gstack2-skill-ux.test.ts | 15 +- test/release-hardening.test.ts | 9 +- 48 files changed, 2798 insertions(+), 220 deletions(-) create mode 100644 runtime/browser-choice.mjs create mode 100644 skills/debug/references/support/browser-choice.mjs create mode 100644 skills/design/references/support/browser-choice.mjs create mode 100644 skills/plan/references/support/browser-choice.mjs create mode 100644 skills/qa/references/support/browser-choice.mjs create mode 100644 skills/review/references/support/browser-choice.mjs create mode 100644 skills/ship/references/support/browser-choice.mjs diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index e2c53d707..f45017559 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -70,6 +70,7 @@ jobs: --version 2.0.0 \ --install-now \ --yes \ + --browser managed \ --capabilities "$CAPABILITIES" active_slot=$(node -e 'const fs=require("fs"),p=process.argv[1];const v=JSON.parse(fs.readFileSync(p,"utf8")).current;if(typeof v!=="string"||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(v))process.exit(1);process.stdout.write(v)' "$GSTACK_HOME/versions/current.json") active="$GSTACK_HOME/versions/$active_slot" @@ -199,6 +200,6 @@ jobs: --verify-tag \ $PRERELEASE_FLAG \ --title "GStack runtime $GITHUB_REF_NAME" \ - --notes "Signed optional runtime artifacts for the six portable GStack skills." \ + --notes "Signed optional runtime artifacts for the six portable GStack skills. This release adds an explicit managed-versus-installed Chromium consent gate before browser preview or installation." \ release-output/* shell: bash diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 53fae3b00..310566ce3 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -65,14 +65,14 @@ jobs: - name: Preview without mutating state run: | set -e - bash ./setup --dry-run --capabilities browser + bash ./setup --dry-run --capabilities browser --browser managed test ! -e "$GSTACK_HOME" || (echo "dry-run mutated GSTACK_HOME" && exit 1) shell: bash - name: Explicitly install the browser capability run: | set -e - bash ./setup --install-now --yes --capabilities browser + bash ./setup --install-now --yes --capabilities browser --browser managed test -f "$GSTACK_HOME/versions/current.json" test -f "$GSTACK_HOME/bin/gstack.cmd" shell: bash diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 8774d9b17..b8c56f7ca 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -44,6 +44,30 @@ export function isCustomChromium(): boolean { return p.includes('GBrowser') || p.includes('gbrowser'); } +/** + * Return the explicitly selected Chromium executable for both headless and + * headed launches. Keeping this opt-in preserves the managed browser fallback + * while allowing the lightweight playwright-core adapter to reuse a system or + * host-managed Chrome without downloading Playwright's browser package. + */ +export function configuredChromiumExecutable( + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const value = env.GSTACK_CHROMIUM_PATH?.trim(); + return value || undefined; +} + +/** Installed-system Chromium is supported only for headless automation. */ +export function assertHeadedBrowserProvider( + env: NodeJS.ProcessEnv = process.env, +): void { + if (env.GSTACK_BROWSER_PROVIDER === 'installed') { + throw new Error( + 'Visible GStack Browser requires managed Chromium; installed Chrome-family browsers are headless-only', + ); + } +} + /** * Decide whether Playwright should request Chromium's sandbox. * @@ -361,6 +385,7 @@ export class BrowserManager { const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; let useHeadless = true; + const executablePath = configuredChromiumExecutable(); // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which // are typically disabled in containers and are never available for the root @@ -387,7 +412,11 @@ export class BrowserManager { this.browser = await chromium.launch({ headless: useHeadless, - ...(useHeadless && managedHeadlessChannel() ? { channel: 'chromium' as const } : {}), + ...(executablePath + ? { executablePath } + : useHeadless && managedHeadlessChannel() + ? { channel: 'chromium' as const } + : {}), // On Windows, Chromium's sandbox fails when the server is spawned through // the Bun→Node process chain (GitHub #276). Disable it — local daemon // browsing user-specified URLs has marginal sandbox benefit. Also disabled @@ -447,6 +476,7 @@ export class BrowserManager { * every action Claude takes in real time. */ async launchHeaded(authToken?: string): Promise { + assertHeadedBrowserProvider(); // Clear old state before repopulating this.pages.clear(); this.tabSessions.clear(); @@ -515,7 +545,7 @@ export class BrowserManager { // Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var. // Used by GStack Browser.app to point at the bundled Chromium. - const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined; + const executablePath = configuredChromiumExecutable(); // Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab. // Patch the Chromium .app's Info.plist so macOS shows our name. diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 9382cb27e..8bba8a9bd 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -7,7 +7,7 @@ import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; import { startTestServer } from './test-server'; -import { BrowserManager } from '../src/browser-manager'; +import { BrowserManager, assertHeadedBrowserProvider, configuredChromiumExecutable } from '../src/browser-manager'; import { resolveServerScript } from '../src/cli'; import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands'; import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands'; @@ -23,6 +23,25 @@ const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) => const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => _handleWriteCommand(cmd, args, b.getActiveSession(), b); +describe('configuredChromiumExecutable', () => { + test('returns and trims an explicitly selected system browser', () => { + expect(configuredChromiumExecutable({ + GSTACK_CHROMIUM_PATH: ' /Applications/Google Chrome.app/Contents/MacOS/Google Chrome ', + })).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'); + }); + + test('keeps the managed-browser path when no override is selected', () => { + expect(configuredChromiumExecutable({})).toBeUndefined(); + expect(configuredChromiumExecutable({ GSTACK_CHROMIUM_PATH: ' ' })).toBeUndefined(); + }); + + test('rejects headed launch when setup selected an installed system browser', () => { + expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'installed' })) + .toThrow('Visible GStack Browser requires managed Chromium'); + expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'managed' })).not.toThrow(); + }); +}); + // ─── Pure arg-parser + result-conversion unit tests (no browser) ─── describe('parseOutArgs / hasOutArg', () => { test('--out splits the flag from the positional', () => { diff --git a/bun.lock b/bun.lock index 1dd1ec869..720119df6 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ "diff": "^9.0.0", "html-to-docx": "1.8.0", "marked": "^18.0.2", - "playwright": "^1.58.2", + "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", "socks": "^2.8.8", "xterm": "5", @@ -275,8 +275,6 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], - "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], @@ -411,9 +409,7 @@ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], - "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], - - "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], + "playwright": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], diff --git a/docs/gstack-2/JUDGMENT-PARITY.md b/docs/gstack-2/JUDGMENT-PARITY.md index 85a92591e..42cddf688 100644 --- a/docs/gstack-2/JUDGMENT-PARITY.md +++ b/docs/gstack-2/JUDGMENT-PARITY.md @@ -2,7 +2,7 @@ Parity is executable, not a prose claim. Run `bun run scripts/gstack2/run-parity.ts` or the dedicated Bun tests. -The pinned release inventory passes **4,833 checks** across 55 specialist sources, 16 carved sections, 25 routing scenarios, 16 regression ports, and **78 assets**. +The pinned release inventory passes **4,836 checks** across 55 specialist sources, 16 carved sections, 25 routing scenarios, 16 regression ports, and **78 assets**. The suite verifies: diff --git a/docs/gstack-2/JUDGMENT-PROVENANCE.json b/docs/gstack-2/JUDGMENT-PROVENANCE.json index 39ba08820..75310112f 100644 --- a/docs/gstack-2/JUDGMENT-PROVENANCE.json +++ b/docs/gstack-2/JUDGMENT-PROVENANCE.json @@ -2798,7 +2798,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf", + "normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6", "target": "skills/qa/references/legacy/open-gstack-browser.md", "disposition": "BUG_FIX", "overlays": [ @@ -2873,7 +2873,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d", + "normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "disposition": "BUG_FIX", "overlays": [ @@ -2934,7 +2934,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457", + "normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a", "target": "skills/qa/references/legacy/pair-agent.md", "disposition": "BUG_FIX", "overlays": [ diff --git a/evals/parity/contracts/open-gstack-browser.json b/evals/parity/contracts/open-gstack-browser.json index 935315d89..91103ae3e 100644 --- a/evals/parity/contracts/open-gstack-browser.json +++ b/evals/parity/contracts/open-gstack-browser.json @@ -9,7 +9,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf", + "normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6", "target": "skills/qa/references/legacy/open-gstack-browser.md", "overlays": [ 679 diff --git a/evals/parity/contracts/pair-agent.json b/evals/parity/contracts/pair-agent.json index 41db63c13..7ae3a8729 100644 --- a/evals/parity/contracts/pair-agent.json +++ b/evals/parity/contracts/pair-agent.json @@ -9,7 +9,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457", + "normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a", "target": "skills/qa/references/legacy/pair-agent.md", "overlays": [ 679 diff --git a/evals/parity/contracts/setup-browser-cookies.json b/evals/parity/contracts/setup-browser-cookies.json index f86f14c0a..e5737d6ca 100644 --- a/evals/parity/contracts/setup-browser-cookies.json +++ b/evals/parity/contracts/setup-browser-cookies.json @@ -9,7 +9,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d", + "normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "overlays": [ 679 diff --git a/evals/parity/manifest.json b/evals/parity/manifest.json index 39ba08820..75310112f 100644 --- a/evals/parity/manifest.json +++ b/evals/parity/manifest.json @@ -2798,7 +2798,7 @@ "source_path": "open-gstack-browser/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b", - "normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf", + "normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6", "target": "skills/qa/references/legacy/open-gstack-browser.md", "disposition": "BUG_FIX", "overlays": [ @@ -2873,7 +2873,7 @@ "source_path": "setup-browser-cookies/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652", - "normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d", + "normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8", "target": "skills/qa/references/legacy/setup-browser-cookies.md", "disposition": "BUG_FIX", "overlays": [ @@ -2934,7 +2934,7 @@ "source_path": "pair-agent/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c", - "normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457", + "normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a", "target": "skills/qa/references/legacy/pair-agent.md", "disposition": "BUG_FIX", "overlays": [ diff --git a/package.json b/package.json index bd4ab1dcd..1fc3ff76b 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "server": "bun run browse/src/server.ts", "test": "bun run scripts/test-free-strict.ts", "check:gstack2-generated": "bun run scripts/gstack2/check-generated.ts", - "test:gstack2": "bun run gen:gstack2 && bun run check:gstack2-generated && bun test --timeout 30000 test/gstack2-*.test.ts", + "test:gstack2": "bun run gen:gstack2 && bun run check:gstack2-generated && bun test --timeout 60000 test/gstack2-*.test.ts", "test:gstack2:install": "bun run scripts/gstack2/test-install-matrix.ts --full", "test:gstack2:parity": "bun run ensure:gstack2-runtime && bun run scripts/gstack2/run-parity.ts", "test:free": "bun run scripts/test-free-shards.ts", @@ -75,7 +75,7 @@ "diff": "^9.0.0", "html-to-docx": "1.8.0", "marked": "^18.0.2", - "playwright": "^1.58.2", + "playwright": "npm:playwright-core@^1.58.2", "sharp": "^0.34.5", "socks": "^2.8.8", "xterm": "5", diff --git a/runtime/browser-choice.mjs b/runtime/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/runtime/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/runtime/cli.js b/runtime/cli.js index 620b8803a..56a97bcd1 100644 --- a/runtime/cli.js +++ b/runtime/cli.js @@ -9,10 +9,12 @@ import { setupRuntime } from "./setup.js"; import { configGet, configSet, + configSetBrowserChoice, configSetNetworkChoice, parseConfigValue, secretSet, } from "./config.js"; +import { resolveBrowserChoice } from "./browser-choice.mjs"; import { discoverProjectIdentity } from "./identity.js"; import { beginRun, @@ -172,12 +174,88 @@ async function configCommand({ args, home, cwd, stdout }) { if (action === "set") { const [key, value, ...rest] = tail; if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set ", "USAGE"); + if (key === "browser" || key.startsWith("browser.")) { + throw cliError( + "Browser selection is coherent state; use `gstack config browser managed`, `gstack config browser installed `, or `gstack config browser clear`.", + "CONFIG_BROWSER_COMMAND_REQUIRED", + ); + } await setupRuntime({ home, cwd }); const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value))); write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`); return 0; } - throw cliError("Usage: gstack config get [key] | gstack config set ", "USAGE"); + if (action === "browser") { + const [provider, executablePath, ...rest] = tail; + if (rest.length || !["managed", "installed", "clear"].includes(provider) || + (provider === "installed" ? !executablePath : executablePath != null)) { + throw cliError("Usage: gstack config browser managed | installed | clear", "USAGE"); + } + await setupRuntime({ home, cwd }); + const choice = provider === "clear" + ? null + : await resolveBrowserChoice({ provider, executablePath }); + await assertBrowserChoiceCompatibleWithActiveRuntime(home, choice); + const result = await withOwnedRuntimeMutation(home, () => configSetBrowserChoice(home, choice)); + write(stdout, provider === "clear" + ? "browser selection cleared\n" + : `browser = ${JSON.stringify(result)}\n`); + return 0; + } + throw cliError("Usage: gstack config get [key] | gstack config set | gstack config browser managed | installed | clear", "USAGE"); +} + +async function activeRuntimeBrowserChoice(home) { + const paths = resolveRuntimePaths({ home }); + const pointer = await readJson(paths.versionPointer, null); + if (typeof pointer?.current !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(pointer.current)) return null; + return runtimeBrowserChoiceAtPath(path.join(paths.versions, pointer.current)); +} + +async function runtimeBrowserChoiceAtPath(runtimePath) { + const manifest = await readJson(path.join(runtimePath, ".gstack-bundle.json"), null); + if (!manifest || typeof manifest !== "object") return null; + const selected = Array.isArray(manifest.selectedCapabilities) ? manifest.selectedCapabilities : []; + const components = Array.isArray(manifest.runtimeComponents) ? manifest.runtimeComponents : []; + if (!selected.includes("browser") && !selected.includes("browser-visible")) return null; + const explicit = manifest.browserChoice; + const provider = explicit?.provider ?? ( + components.includes("browser-headless") || components.includes("browser-visible") + ? "managed" + : components.includes("browser-code") + ? "installed" + : null + ); + return provider ? { + provider, + executablePath: provider === "installed" ? explicit?.executablePath ?? null : null, + visible: selected.includes("browser-visible"), + } : null; +} + +async function assertBrowserChoiceCompatibleWithActiveRuntime(home, choice) { + if (!choice) return; + const active = await activeRuntimeBrowserChoice(home); + if (!active) return; + if (choice.provider !== active.provider) { + throw cliError( + `The active runtime was installed for ${active.provider} Chromium. Use the signed capability bootstrap to install a ${choice.provider} browser slot before switching providers.`, + "BROWSER_PROVIDER_SLOT_MISMATCH", + ); + } + if (choice.provider === "installed" && active.visible) { + throw cliError("Visible GStack Browser is managed-only; install a managed browser slot before selecting it", "BROWSER_PROVIDER_UNSUPPORTED"); + } +} + +async function resolvedBrowserChoiceForRuntimePath(runtimePath) { + const choice = await runtimeBrowserChoiceAtPath(runtimePath); + if (!choice) return null; + if (choice.provider === "managed") return { provider: "managed", executablePath: null }; + if (typeof choice.executablePath !== "string") { + throw cliError("The rollback slot does not record its installed browser executable", "BROWSER_PATH_REQUIRED"); + } + return resolveBrowserChoice({ provider: "installed", executablePath: choice.executablePath }); } async function stateCommand({ args, home, cwd, env, stdout, stderr }) { @@ -549,7 +627,13 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) { if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE"); if (parsed.flags.has("--rollback")) { if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE"); - const pointer = await rollbackUpgrade(home); + let rollbackBrowserChoice = null; + const pointer = await rollbackUpgrade(home, { + healthCheck: async (fallbackPath) => { + rollbackBrowserChoice = await resolvedBrowserChoiceForRuntimePath(fallbackPath); + }, + }); + if (rollbackBrowserChoice) await configSetBrowserChoice(home, rollbackBrowserChoice); write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`); return 0; } @@ -558,10 +642,22 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) { if (!sourceDir || !version) { throw cliError("Usage: gstack upgrade --source --version | --rollback", "USAGE"); } + let browserChoice; + if (installOptions.entries == null) { + const configuredBrowser = await configGet(home, "browser"); + if (!configuredBrowser?.provider) { + throw cliError( + "Upgrade needs the browser choice that setup normally records. Run `gstack config browser managed` or `gstack config browser installed ` first.", + "BROWSER_CHOICE_REQUIRED", + ); + } + browserChoice = await resolveBrowserChoice(configuredBrowser); + } const result = await installManagedRuntime({ home, sourceDir, version, + ...(browserChoice ? { browserChoice } : {}), ...installOptions, buildMissing: false, rejectSourceRootLink: true, @@ -712,6 +808,7 @@ function usage() { " gstack runtime path \n" + " gstack config get [key]\n" + " gstack config set \n" + + " gstack config browser managed|installed |clear\n" + " gstack state inspect [run-id]\n" + " gstack state begin [--run-id ] [--goal ] [--plan ] [--stage ] [--depth quick|standard|deep] [--mutation ] [--modules ]\n" + " gstack state update [--plan |--clear-plan] [--stage ] [--depth quick|standard|deep] [--mutation ] [--modules ] [--push-detour |--pop-detour]\n" + diff --git a/runtime/config.js b/runtime/config.js index ddcc0cc50..a34fbf650 100644 --- a/runtime/config.js +++ b/runtime/config.js @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { atomicWriteJson, readJson, withLock } from "./storage.js"; import { resolveRuntimePaths } from "./paths.js"; +import { BROWSER_PROVIDERS } from "./browser-choice.mjs"; export const DEFAULT_CONFIG = Object.freeze({ schemaVersion: 2, @@ -10,6 +11,7 @@ export const DEFAULT_CONFIG = Object.freeze({ baseUrl: "https://api.context.dev/v1", validation: Object.freeze({ status: "unverified", checkedAt: null }), }), + browser: Object.freeze({ provider: null, executablePath: null }), cleanup: Object.freeze({ retentionDays: 30 }), }); @@ -125,6 +127,18 @@ export async function configSetNetworkChoice(home, choice) { }); } +/** Persist one coherent browser-engine choice or clear it atomically. */ +export async function configSetBrowserChoice(home, choice) { + const normalized = choice == null + ? { provider: null, executablePath: null } + : { provider: choice.provider, executablePath: choice.executablePath ?? null }; + validateBrowserChoice(normalized); + return updateConfig(home, (config) => { + config.browser = normalized; + return { ...config.browser }; + }); +} + async function updateConfig(home, mutate) { const paths = resolveRuntimePaths({ home }); return withLock(path.join(paths.locks, "config.lock"), async () => { @@ -211,6 +225,31 @@ function validateConfig(config) { throw new TypeError("context.validation.checkedAt must be an ISO timestamp or null"); } } + validateBrowserChoice(config.browser ?? { provider: null, executablePath: null }); +} + +function validateBrowserChoice(browser) { + if (browser == null || typeof browser !== "object" || Array.isArray(browser)) { + throw new TypeError("browser must be an object"); + } + const keys = Object.keys(browser).sort(); + if (keys.join(",") !== "executablePath,provider") { + throw new TypeError("browser requires exactly provider and executablePath"); + } + if (browser.provider == null) { + if (browser.executablePath != null) throw new TypeError("An unselected browser cannot have an executable path"); + return; + } + if (!BROWSER_PROVIDERS.includes(browser.provider)) { + throw new TypeError("browser.provider must be `managed`, `installed`, or null"); + } + if (browser.provider === "managed" && browser.executablePath != null) { + throw new TypeError("Managed Chromium cannot have an installed executable path"); + } + if (browser.provider === "installed" && + (typeof browser.executablePath !== "string" || !path.isAbsolute(browser.executablePath))) { + throw new TypeError("An installed browser requires an absolute executable path"); + } } function cloneDefaultConfig() { @@ -223,6 +262,7 @@ function mergeDefaults(stored) { ...stored, network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) }, context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) }, + browser: { ...DEFAULT_CONFIG.browser, ...(stored.browser ?? {}) }, cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) }, }; } diff --git a/runtime/doctor.js b/runtime/doctor.js index 1f9601bad..3ecf5c061 100644 --- a/runtime/doctor.js +++ b/runtime/doctor.js @@ -10,6 +10,7 @@ import { RUNTIME_SCHEMA_VERSION, RUNTIME_MIGRATION_ID } from "./migrations.js"; import { assertManagedHome } from "./managed-home.js"; import { recoverPendingUpgrade } from "./upgrade.js"; import { bashCandidates } from "./tooling.js"; +import { resolveBrowserChoice } from "./browser-choice.mjs"; import { OPTIONAL_RUNTIME_CAPABILITIES, RUNTIME_CAPABILITY_DEPENDENCIES, @@ -23,6 +24,7 @@ export async function runDoctor(options = {}) { const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) }); const now = options.now ? options.now() : new Date(); const expectedSkillApi = options.expectedSkillApi ?? RUNTIME_COMPATIBILITY.skillApi; + let runtimeConfig = null; if (typeof expectedSkillApi !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/.test(expectedSkillApi)) { throw new TypeError("Expected skill API must be a short version identifier"); } @@ -51,12 +53,16 @@ export async function runDoctor(options = {}) { } try { - const config = await readJson(paths.config); - add("config", config?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail", - `Config schema ${config?.schemaVersion ?? "unknown"}`); - const enabled = config?.network?.mode === "context" && config?.network?.consent === true; + runtimeConfig = await readJson(paths.config); + add("config", runtimeConfig?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail", + `Config schema ${runtimeConfig?.schemaVersion ?? "unknown"}`); + const enabled = runtimeConfig?.network?.mode === "context" && runtimeConfig?.network?.consent === true; add("network", enabled ? "pass" : "warn", enabled ? "Context.dev network mode has explicit consent" : "Network access is off (safe default)"); + const browserProvider = runtimeConfig?.browser?.provider; + add("browser-selection", browserProvider ? "pass" : "warn", browserProvider + ? `Browser provider explicitly selected: ${browserProvider}` + : "No browser provider selected; browser-backed skills will ask at first use"); } catch (error) { add("config", "fail", `Config cannot be read: ${error.message}`); } @@ -153,7 +159,16 @@ export async function runDoctor(options = {}) { continue; } if (capability === "browser") { - const browser = await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node"); + const browser = runtimeConfig?.browser?.provider === "installed" + ? await inspectInstalledChromium( + activeRoot, + options.nodeCommand ?? process.env.GSTACK_NODE ?? "node", + runtimeConfig.browser, + options, + ) + : runtimeConfig?.browser?.provider === "managed" + ? await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node") + : { ok: false, message: "browser capability is installed, but no browser provider was explicitly selected" }; add(`capability:${capability}`, browser.ok ? "pass" : "fail", browser.message, browser.details); continue; } @@ -255,6 +270,36 @@ async function inspectManagedChromium(activeRoot, nodeCommand) { } } +async function inspectInstalledChromium(activeRoot, nodeCommand, configured, options = {}) { + const modulePath = path.join(activeRoot, "node_modules", "playwright", "index.mjs"); + const moduleStat = await fs.lstat(modulePath).catch(() => null); + if (!moduleStat?.isFile() || moduleStat.isSymbolicLink()) { + return { ok: false, message: "Playwright module for the installed-browser adapter is missing/unsafe" }; + } + try { + const choice = await resolveBrowserChoice(configured, { + platform: options.platform, + env: options.env, + homeDir: options.homeDir, + }); + const moduleUrl = pathToFileURL(modulePath).href; + const result = await captureCommand(nodeCommand, [ + "--input-type=module", + "--eval", + `const { chromium } = await import(${JSON.stringify(moduleUrl)}); const browser = await chromium.launch({ headless: true, executablePath: ${JSON.stringify(choice.executablePath)} }); try { process.stdout.write(browser.version()); } finally { await browser.close(); }`, + ]); + const version = result.stdout.trim(); + if (!version) return { ok: false, message: "installed Chromium launched without reporting a browser version" }; + return { + ok: true, + message: `installed Chromium ${version} launches through the Playwright adapter and exits cleanly`, + details: { provider: "installed", executablePath: choice.executablePath, version }, + }; + } catch (error) { + return { ok: false, message: `installed Chromium is not runnable through the Playwright adapter: ${error.message}` }; + } +} + async function inspectXcrun() { if (process.platform !== "darwin") return { ok: false, message: "physical-iOS capability requires macOS" }; try { diff --git a/runtime/install.js b/runtime/install.js index ea62fb5c2..2d5a67f67 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -20,6 +20,14 @@ import { } from "./managed-home.js"; import { errorWithCode as installError } from "./errors.js"; import { currentIsoTimestamp as isoNow } from "./time.js"; +import { configSetBrowserChoice, loadConfig } from "./config.js"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; const INSTALL_SCHEMA_VERSION = 2; export const MAX_RUNTIME_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024; @@ -266,7 +274,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([ entry("browse/src"), entry("extension"), entry("node_modules/playwright"), - entry("node_modules/playwright-core"), entry(managedBunRelativePath(), "managed-bun", true), entry(".gstack-runtime-browsers", "browser"), entry("node_modules/diff"), @@ -321,10 +328,11 @@ const CAPABILITY_PATH_PREFIXES = Object.freeze({ }); /** Resolve the audited core plus only explicitly selected optional capabilities. */ -export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES) { +export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES, options = {}) { const selected = normalizeCapabilitySelection(input); const includesBrowserCode = selected.includes("browser") || selected.includes("browser-visible"); const entries = DEFAULT_RUNTIME_BUNDLE.filter((item) => { + if (options.browserChoice?.provider === "installed" && item.path === ".gstack-runtime-browsers") return false; const owner = capabilityForPath(item.path); return owner == null || selected.includes(owner) || (owner === "browser" && includesBrowserCode); }); @@ -336,7 +344,7 @@ export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILIT } /** Expand logical runtime capabilities into the signed internal components. */ -export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES) { +export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES, options = {}) { const capabilities = normalizeCapabilitySelection(input); const selected = new Set(["core"]); for (const capability of capabilities) { @@ -351,13 +359,16 @@ export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABI } } } - return Object.freeze([...selected].sort()); + return applyBrowserProviderToComponents([...selected], options.browserChoice); } -export function runtimeSlotVersion(releaseVersion, capabilityIds) { +export function runtimeSlotVersion(releaseVersion, capabilityIds, options = {}) { validateVersion(releaseVersion); const selected = normalizeCapabilitySelection(capabilityIds); - const digest = createHash("sha256").update(selected.join(",") || "core").digest("hex").slice(0, 12); + const browserProvider = browserChoiceRequired(selected) + ? options.browserChoice?.provider ?? "legacy-managed" + : "no-browser"; + const digest = createHash("sha256").update(`${selected.join(",") || "core"}|${browserProvider}`).digest("hex").slice(0, 12); const prefix = String(releaseVersion).slice(0, 60); return `${prefix}-caps-${digest}`; } @@ -365,7 +376,7 @@ export function runtimeSlotVersion(releaseVersion, capabilityIds) { export async function previewManagedRuntime(options = {}) { if (!options.sourceDir) throw installError("sourceDir is required", "INSTALL_SOURCE_REQUIRED"); const sourceDir = await resolvePhysicalSource(options.sourceDir); - const surface = runtimeSurfaceForCapabilities(options.capabilityIds); + const surface = runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice }); let bytes = 0; let files = 0; const missing = []; @@ -419,6 +430,7 @@ export async function previewManagedRuntime(options = {}) { return Object.freeze({ sourceDir, capabilities: surface.selected, + browser: browserChoiceRequired(surface.selected) ? options.browserChoice ?? null : null, components: surface.entries.length, files, bytes, @@ -460,7 +472,7 @@ export async function installManagedRuntime(options = {}) { if (options.requirePackageIdentity) validatePackageIdentity(packageMetadata, version); const selectedSurface = options.entries == null - ? runtimeSurfaceForCapabilities(options.capabilityIds) + ? runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice }) : null; const entries = normalizeEntries(options.entries ?? selectedSurface.entries); const capabilities = normalizeCapabilities(options.capabilities ?? selectedSurface.capabilities, entries); @@ -581,7 +593,15 @@ export async function installManagedRuntime(options = {}) { version, compatibility: RUNTIME_COMPATIBILITY, selectedCapabilities: selectedSurface?.selected ?? null, - runtimeComponents: selectedSurface ? runtimeComponentsForCapabilities(selectedSurface.selected) : null, + browserChoice: selectedSurface && browserChoiceRequired(selectedSurface.selected) + ? { + provider: options.browserChoice?.provider ?? null, + executablePath: options.browserChoice?.executablePath ?? null, + } + : null, + runtimeComponents: selectedSurface + ? runtimeComponentsForCapabilities(selectedSurface.selected, { browserChoice: options.browserChoice }) + : null, components: entries.map(({ path: component }) => component), capabilities, stableSourceFiles, @@ -617,6 +637,7 @@ export async function installManagedRuntime(options = {}) { nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node", run: options.runCommand ?? runCommand, commandTimeoutMs: options.commandTimeoutMs, + browserChoice: selectedSurface ? options.browserChoice : null, }); }, beforeActivate: async ({ active, previous, previousExists, destination }) => { @@ -629,6 +650,7 @@ export async function installManagedRuntime(options = {}) { await removeObsoleteLaunchers(paths, snapshot, launcherSurface); const manifestWriter = options.manifestWriter ?? writeInstallManifest; installManifest = await manifestWriter(paths, active, launcherSurface, options.now); + if (options.browserChoice) await configSetBrowserChoice(home, options.browserChoice); }, afterActivate: async () => fs.rm(path.join(home, RUNTIME_TRANSACTION_FILE), { force: true }), onRollback: async ({ pointerRollbackError }) => { @@ -895,6 +917,26 @@ export async function smokeRuntimeBundle(directory, options = {}) { cause, ); } + } else if (options.browserChoice?.provider === "installed") { + const playwrightFile = path.join(directory, "node_modules", "playwright", "index.mjs"); + const moduleStat = await fs.lstat(playwrightFile).catch(() => null); + if (!moduleStat?.isFile() || moduleStat.isSymbolicLink()) { + throw installError("Playwright adapter for the installed browser is missing or unsafe", "INSTALL_SMOKE_FAILED"); + } + const browserChoice = await resolveBrowserChoice(options.browserChoice); + try { + await run(command, [ + "--input-type=module", + "--eval", + `const { chromium } = await import(${JSON.stringify(pathToFileURL(playwrightFile).href)}); const browser = await chromium.launch({ headless: true, executablePath: ${JSON.stringify(browserChoice.executablePath)} }); try { if (!browser.version()) throw new Error("browser version unavailable"); } finally { await browser.close(); }`, + ], { cwd: directory, capture: true, timeoutMs: Math.max(timeoutMs, 30_000) }); + } catch (cause) { + throw installError( + "The selected installed Chromium failed its Playwright launch smoke test; the active runtime and browser selection were not changed", + "INSTALL_SMOKE_FAILED", + cause, + ); + } } } @@ -913,7 +955,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {} const stdout = options.stdout ?? process.stdout; const bunCommand = parsed.bunCommand ?? env.BUN_CMD ?? "bun"; let capabilityIds = parsed.capabilityIds; - if (parsed.installMode == null && !parsed.dryRun && stdin.isTTY && !parsed.json) { + if (!parsed.capabilitiesProvided && parsed.installMode == null && !parsed.dryRun && stdin.isTTY && !parsed.json) { const answer = await askInstallerQuestion( stdin, options.stderr ?? process.stderr, @@ -921,10 +963,52 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {} ); capabilityIds = parseCapabilityList(answer || "all"); } + if (parsed.installMode === "later" && !parsed.browserProvider && browserChoiceRequired(capabilityIds)) { + if (parsed.json) { + stdout.write(`${JSON.stringify({ ok: true, action: "install-later", mutated: false, preview: null }, null, 2)}\n`); + } else if (!parsed.quiet) { + stdout.write("No browser provider was selected and no runtime was installed. Judgment-only skills remain usable.\n"); + } + return 0; + } capabilityIds = await mergeActiveCapabilities(home, capabilityIds, parsed.replaceCapabilities); + let browserChoice = null; + if (browserChoiceRequired(capabilityIds)) { + const configured = parsed.browserProvider + ? { provider: parsed.browserProvider, executablePath: parsed.browserPath } + : (await loadConfig(home)).browser; + if (configured?.provider) { + browserChoice = await resolveBrowserChoice(configured, { + platform: options.platform, + env, + homeDir: options.homeDir, + }); + } else if (stdin.isTTY && !parsed.json && !parsed.dryRun) { + browserChoice = await askBrowserChoice({ + input: stdin, + output: options.stderr ?? process.stderr, + platform: options.platform, + env, + homeDir: options.homeDir, + }); + if (!browserChoice) { + stdout.write("No browser provider was selected. No runtime was installed; judgment-only skills remain usable.\n"); + return 0; + } + } else { + throw installError( + "Browser-backed capabilities require an explicit choice. Use `--browser managed` or `--browser installed --browser-path `; no browser was downloaded or selected.", + "INSTALL_BROWSER_CHOICE_REQUIRED", + ); + } + assertBrowserChoiceSupportsCapabilities(browserChoice, capabilityIds); + } else if (parsed.browserProvider || parsed.browserPath) { + throw installError("Browser options require a browser-backed capability", "INSTALL_BROWSER_CHOICE_UNUSED"); + } const preview = await previewManagedRuntime({ sourceDir, capabilityIds, + browserChoice, bunCommand, preparedSource: parsed.prepared, runCommand: options.installOptions?.runCommand, @@ -966,9 +1050,10 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {} const result = await installManagedRuntime({ sourceDir, home, - version: runtimeSlotVersion(releaseVersion, capabilityIds), + version: runtimeSlotVersion(releaseVersion, capabilityIds, { browserChoice }), bunCommand, capabilityIds, + browserChoice, buildMissing: parsed.prepared ? false : undefined, nodeCommand: env.GSTACK_NODE ?? "node", launcherNodeCommand: env.GSTACK_NODE ?? "node", @@ -981,7 +1066,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {} stdout.write(`Installed gstack runtime ${releaseVersion}\n`); stdout.write(`Runtime home: ${result.home}\n`); stdout.write(`Launcher directory: ${path.join(result.home, "bin")}\n`); - stdout.write("Skills are installed separately with: npx skills add time-attack/gstack\n"); + stdout.write("Skills are installed separately with: npx skills add time-attack/gstack/skills\n"); } return 0; } catch (error) { @@ -1549,6 +1634,49 @@ if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error("Active capability const managedBrowsers = path.join(root, ".gstack-runtime-browsers"); const browserStat = await fs.lstat(managedBrowsers).catch(() => null); if (browserStat?.isSymbolicLink()) throw new Error("Managed browser directory is unsafe"); +const config = await fs.readFile(path.join(home, "config.json"), "utf8") + .then(value => JSON.parse(value), () => null); +const bundle = await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8") + .then(value => JSON.parse(value), () => null); +const browserBacked = relative.startsWith("browse/") || relative.startsWith("make-pdf/"); +const selectedCapabilities = Array.isArray(bundle?.selectedCapabilities) ? bundle.selectedCapabilities : []; +const runtimeComponents = Array.isArray(bundle?.runtimeComponents) ? bundle.runtimeComponents : []; +const slotProvider = bundle?.browserChoice?.provider ?? ( + runtimeComponents.includes("browser-headless") || runtimeComponents.includes("browser-visible") + ? "managed" + : runtimeComponents.includes("browser-code") + ? "installed" + : null +); +let browserChoice = config?.browser ?? { provider: null, executablePath: null }; +if (browserBacked) { + if (!browserChoice?.provider) { + throw new Error("No browser provider is selected; run the signed browser capability bootstrap before launching browser-backed tools"); + } + if (slotProvider && browserChoice.provider !== slotProvider) { + throw new Error("The selected browser provider does not match the active runtime slot; run the signed browser capability bootstrap for the selected provider"); + } + if (browserChoice.provider === "installed") { + if (selectedCapabilities.includes("browser-visible")) { + throw new Error("Visible GStack Browser requires a managed Chromium runtime slot"); + } + const visibleRequested = relative.startsWith("browse/") && ( + args.includes("connect") || + args.includes("handoff") || + args.includes("--headed") || + (args[0] === "pair-agent" && !args.includes("--headless")) + ); + if (visibleRequested) { + throw new Error("Visible GStack Browser requires managed Chromium; preview and approve the browser-visible capability first"); + } + const browserModule = await import(pathToFileURL(path.join(root, "runtime", "browser-choice.mjs")).href); + browserChoice = await browserModule.resolveBrowserChoice(browserChoice); + } else if (browserChoice.provider === "managed") { + if (!browserStat?.isDirectory()) throw new Error("Managed Chromium is missing from the active runtime slot"); + } else { + throw new Error("Configured browser provider is invalid"); + } +} const managedBun = path.join(root, ${JSON.stringify(managedBunRelativePath())}); const bunStat = await fs.lstat(managedBun).catch(() => null); const hasManagedBun = bunStat?.isFile() && !bunStat.isSymbolicLink(); @@ -1575,20 +1703,29 @@ if (/^#!.*\\bbun(?:\\s|$)/.test(header)) { command = process.env.GSTACK_NODE || process.execPath; commandArgs = [target, ...args]; } +const childEnv = { + ...process.env, + GSTACK_HOME: process.env.GSTACK_HOME || home, + GSTACK_NODE: process.env.GSTACK_NODE || process.execPath, + GSTACK_BASH: bashCommand, + ...(hasManagedBun ? { + BUN_CMD: managedBun, + PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""), + } : {}), +}; +if (browserBacked) { + delete childEnv.PLAYWRIGHT_BROWSERS_PATH; + delete childEnv.GSTACK_CHROMIUM_PATH; + delete childEnv.GSTACK_BROWSER_PROVIDER; + childEnv.GSTACK_BROWSER_PROVIDER = browserChoice.provider; + if (browserChoice.provider === "installed") delete childEnv.BROWSE_EXTENSIONS_DIR; + if (browserChoice.provider === "managed") childEnv.PLAYWRIGHT_BROWSERS_PATH = managedBrowsers; + else childEnv.GSTACK_CHROMIUM_PATH = browserChoice.executablePath; +} const child = spawn(command, commandArgs, { stdio: "inherit", windowsHide: true, - env: { - ...process.env, - GSTACK_HOME: process.env.GSTACK_HOME || home, - GSTACK_NODE: process.env.GSTACK_NODE || process.execPath, - GSTACK_BASH: bashCommand, - ...(hasManagedBun ? { - BUN_CMD: managedBun, - PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""), - } : {}), - ...(browserStat?.isDirectory() ? { PLAYWRIGHT_BROWSERS_PATH: managedBrowsers } : {}), - }, + env: childEnv, }); child.once("error", error => { console.error(error.message); process.exitCode = 1; }); child.once("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; }); @@ -1877,6 +2014,7 @@ async function captureInstallSurface(paths, launcherSurface) { const oldManifest = await readJson(manifestPath, null); const oldLaunchers = validateInstallManifestForUninstall(oldManifest); const relativePaths = new Set([ + "config.json", "runtime-install.json", ...oldLaunchers, ...launcherRelativePaths(launcherSurface), @@ -2156,7 +2294,10 @@ function parseInstallerArgs(argv) { home: null, version: undefined, bunCommand: undefined, + browserProvider: null, + browserPath: null, capabilityIds: OPTIONAL_RUNTIME_CAPABILITIES, + capabilitiesProvided: false, installMode: null, yes: false, dryRun: false, @@ -2177,20 +2318,34 @@ function parseInstallerArgs(argv) { else if (arg === "--replace-capabilities") result.replaceCapabilities = true; else if (arg === "--install-now") result.installMode = "now"; else if (arg === "--install-later") result.installMode = "later"; - else if (["--source", "--home", "--version", "--bun", "--capabilities"].includes(arg)) { + else if (["--source", "--home", "--version", "--bun", "--capabilities", "--browser", "--browser-path"].includes(arg)) { const value = argv[index + 1]; if (!value || value.startsWith("--")) throw new TypeError(`Missing value for ${arg}`); index += 1; - if (arg === "--capabilities") result.capabilityIds = parseCapabilityList(value); + if (arg === "--capabilities") { + result.capabilityIds = parseCapabilityList(value); + result.capabilitiesProvided = true; + } + else if (arg === "--browser") result.browserProvider = value; + else if (arg === "--browser-path") result.browserPath = value; else { const key = { "--source": "sourceDir", "--home": "home", "--version": "version", "--bun": "bunCommand" }[arg]; result[key] = value; } } else { - throw new TypeError(`Unknown setup option: ${arg}. Skill placement is delegated to: npx skills add time-attack/gstack`); + throw new TypeError(`Unknown setup option: ${arg}. Skill placement is delegated to: npx skills add time-attack/gstack/skills`); } } if (result.installMode === "later" && result.yes) throw new TypeError("--install-later cannot be combined with --yes"); + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw new TypeError("--browser must be `managed` or `installed`"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw new TypeError("--browser-path is valid only with `--browser installed`"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw new TypeError("--browser-path requires `--browser installed`"); + } if (result.prepared && result.installMode !== "now") throw new TypeError("--prepared is reserved for an explicit prepared artifact install"); if (result.dryRun && (result.installMode != null || result.yes)) throw new TypeError("--dry-run cannot be combined with install/consent flags"); return result; @@ -2198,12 +2353,13 @@ function parseInstallerArgs(argv) { function installerUsage() { return `Usage: ./setup [--capabilities ] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]\n` + + ` [--browser managed|installed [--browser-path ]]\n` + ` [--home ] [--version ] [--json] [--quiet]\n\n` + `Optional capabilities: ${OPTIONAL_RUNTIME_CAPABILITIES.join(", ")}\n` + "Without --install-now, non-interactive use previews and installs nothing.\n" + "--dry-run and --install-later never modify the runtime, state, or host setup.\n" + "Installs only the optional host-neutral runtime and selected local capabilities.\n" + - "Install the six skills separately with: npx skills add time-attack/gstack\n"; + "Install the six skills separately with: npx skills add time-attack/gstack/skills\n"; } function parseCapabilityList(value) { @@ -2222,9 +2378,30 @@ async function askInstallerQuestion(input, output, prompt) { } } +async function askBrowserChoice({ input, output, platform, env, homeDir }) { + const installed = await detectInstalledBrowsers({ platform, env, homeDir }); + output.write("\nBrowser-backed skills need one explicit browser choice:\n"); + output.write(" m) Managed Chromium — isolated and reproducible; its exact download is shown before install.\n"); + installed.forEach((browser, index) => { + output.write(` ${index + 1}) ${browser.name} — ${browser.executablePath} (isolated automation profile; no browser download).\n`); + }); + output.write(" l) Later — install nothing.\n"); + const answer = (await askInstallerQuestion(input, output, "Select m, a browser number, or l [l]: ")).trim().toLowerCase(); + if (!answer || answer === "l" || answer === "later") return null; + if (answer === "m" || answer === "managed") return resolveBrowserChoice({ provider: "managed" }); + const selected = installed[Number(answer) - 1]; + if (!selected) throw installError("Invalid browser selection", "INSTALL_BROWSER_CHOICE_INVALID"); + return resolveBrowserChoice({ provider: "installed", executablePath: selected.executablePath }, { platform, env, homeDir }); +} + function printInstallPreview(stdout, preview) { stdout.write("GStack optional runtime preview\n"); stdout.write(`Capabilities: ${preview.capabilities.length ? preview.capabilities.join(", ") : "core only"}\n`); + if (preview.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium (downloaded only after approval).\n"); + } else if (preview.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${preview.browser.executablePath} (launched with an isolated automation profile; no browser download).\n`); + } stdout.write(`Projected local payload before unknown downloads: ${preview.humanSize} (${preview.files} files, ${preview.components} components)\n`); for (const item of preview.materializations) { if (item.kind === "managed-bun-capture") { diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/scripts/gstack2/generate-skill-tree.ts b/scripts/gstack2/generate-skill-tree.ts index 4dfe43335..5bfebf6ec 100644 --- a/scripts/gstack2/generate-skill-tree.ts +++ b/scripts/gstack2/generate-skill-tree.ts @@ -494,15 +494,17 @@ function runtimeContract(): string { The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read \`references/BROWSER-PROVIDERS.md\` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read \`references/BROWSER-PROVIDERS.md\` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: \`node references/support/runtime-bootstrap.mjs preview --capability \` (repeat \`--capability\` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run \`node references/support/runtime-bootstrap.mjs options --capability \`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal \`browser-visible\` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: \`node references/support/runtime-bootstrap.mjs preview --capability --browser managed\` or \`node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path \` (repeat \`--capability\` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly \`browser\`, \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. \`all\` means those five and intentionally excludes visible Chromium. The internal \`browser-visible\` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run \`node references/support/runtime-bootstrap.mjs install --capability --yes\`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are \`core\`, \`browser-code\` (browse code and dependencies), \`browser-headless\` (Playwright headless shell and FFmpeg), \`browser-visible\` (full Chromium), \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. Logical \`browser\` expands to \`browser-code + browser-headless\`; internal \`browser-visible\` expands to \`browser-code + browser-visible\` and does not require headless. Component dependencies are \`browser-code → core\`, \`browser-headless → browser-code\`, and \`browser-visible → browser-code\`. \`diagram\` depends on logical \`browser\`; \`pdf\` depends on \`diagram\`; \`ios\` is Darwin-only. Therefore a first-time headed flow previews \`core + browser-code + browser-visible\`, while an existing verified headless runtime downloads only missing \`browser-visible\`. The manifest schema is v2 with global \`capabilityComponents\` and \`componentDependencies\`, plus \`targets[target].components[id]\` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching \`install\` command with the same capabilities and browser flags plus \`--yes\`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in \`$GSTACK_HOME/config.json\`. \`gstack config browser clear\` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are \`core\`, \`browser-code\` (adapter code and dependencies), \`browser-headless\` (managed Playwright headless shell and FFmpeg), \`browser-visible\` (managed full Chromium), \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. With managed Chromium, logical \`browser\` expands to \`browser-code + browser-headless\`; with an installed browser, the same logical capability downloads \`browser-code\` only and the stable launcher injects the validated executable path. Internal \`browser-visible\` expands to \`browser-code + browser-visible\` and is managed-only. \`diagram\` depends on logical \`browser\`; \`pdf\` depends on \`diagram\`; \`ios\` is Darwin-only. The manifest schema is v2 with global \`capabilityComponents\` and \`componentDependencies\`, plus \`targets[target].components[id]\` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run \`./setup\` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -512,7 +514,7 @@ Some retained helpers are shell scripts. \`gstack doctor\` verifies Bash and, on The package/runtime compatibility tuple is \`schemaVersion=1\`, \`runtimeVersion=2.0.0\`, and \`skillApi=2.0\`; the machine-readable copy is \`references/support/runtime-contract.json\`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source --capability --yes\`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes\`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. `; } @@ -562,6 +564,7 @@ Do not put secrets in run IDs, effect keys, or command arguments. Existing appro function writeSharedContracts(): void { const bootstrap = fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs')); + const browserChoice = fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs')); const browserSmoke = fs.readFileSync(path.join(ROOT, 'runtime', 'browser-provider-smoke.mjs')); for (const tree of TREE_NAMES) { write(path.join(ROOT, 'skills', tree, 'references', 'SHARED-JUDGMENT.md'), sharedJudgmentContract()); @@ -570,6 +573,7 @@ function writeSharedContracts(): void { write(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), runtimeContract()); write(path.join(ROOT, 'skills', tree, 'references', 'BROWSER-PROVIDERS.md'), `${GENERATED}\n${renderBrowserProviderContract()}`); write(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'), bootstrap); + write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs'), browserChoice); write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-provider-smoke.mjs'), browserSmoke); writeJson(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), RUNTIME_SKILL_CONTRACT); } diff --git a/scripts/gstack2/render-legacy.ts b/scripts/gstack2/render-legacy.ts index f2fb4f882..1603043fe 100644 --- a/scripts/gstack2/render-legacy.ts +++ b/scripts/gstack2/render-legacy.ts @@ -430,7 +430,7 @@ export function renderPortedLegacyBody(source: string): string { '', 'This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.', '', - 'At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.', + 'At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.', '', body, ].join('\n'); diff --git a/scripts/gstack2/run-parity.ts b/scripts/gstack2/run-parity.ts index 4d3f85cc2..2de7c6a83 100644 --- a/scripts/gstack2/run-parity.ts +++ b/scripts/gstack2/run-parity.ts @@ -16,7 +16,7 @@ const ALLOWED_DISPOSITIONS = new Set(['VERBATIM_PORT', 'MECHANICAL_PORT', 'JUDGM // checks to the previously verified 4,681-check corpus. The first update only // accounted for the 16 lazy-section checks; the remaining 136 cover runtime // contracts, retired-invocation guards, and generated package closure. -export const EXPECTED_PARITY_CHECKS = 4833; +export const EXPECTED_PARITY_CHECKS = 4836; function sha256(value: string | Uint8Array): string { return createHash('sha256').update(value).digest('hex'); @@ -373,8 +373,8 @@ export function runParity(): ParityResult { const packagedBootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs')); check(packagedBootstrap.equals(fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'))), `${tree} packaged runtime bootstrap drifted from its source`); check(runtimeContract.includes('preview --capability ') && runtimeContract.includes('It never downloads components or mutates runtime state.'), `${tree} runtime contract lacks non-mutating exact-byte preview`); - check(runtimeContract.includes('install --capability --yes'), `${tree} runtime contract lacks explicit approved install invocation`); - check(runtimeContract.includes('Logical `browser` expands to `browser-code + browser-headless`') && runtimeContract.includes('`browser-visible` expands to `browser-code + browser-visible` and does not require headless') && runtimeContract.includes('`pdf` depends on `diagram`'), `${tree} runtime contract omits component dependency closure`); + check(runtimeContract.includes('matching `install` command with the same capabilities and browser flags plus `--yes`'), `${tree} runtime contract lacks explicit approved install invocation`); + check(runtimeContract.includes('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`') && runtimeContract.includes('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only') && runtimeContract.includes('`pdf` depends on `diagram`'), `${tree} runtime contract omits provider-aware component dependency closure`); check(runtimeContract.includes('`all` means those five and intentionally excludes visible Chromium'), `${tree} runtime contract lets eager setup install visible Chromium`); check(runtimeContract.includes('B=$GSTACK_BIN/browse') && runtimeContract.includes('P=$GSTACK_BIN/make-pdf'), `${tree} runtime contract omits stable launcher bindings`); check(runtimeContract.includes('BUN_CMD=$GSTACK_BIN/bun'), `${tree} runtime contract omits the managed Bun binding`); diff --git a/scripts/gstack2/runtime-install-smoke.sh b/scripts/gstack2/runtime-install-smoke.sh index 8c48c5aa0..e987da746 100755 --- a/scripts/gstack2/runtime-install-smoke.sh +++ b/scripts/gstack2/runtime-install-smoke.sh @@ -34,7 +34,7 @@ rm -f \ ( cd "$REPO" - ./setup --home "$HOME_DIR" --json + ./setup --home "$HOME_DIR" --browser managed --install-now --yes --json ) # The optional runtime setup installs only its production/build closure. The diff --git a/setup b/setup index a4d5dd171..4560568e6 100755 --- a/setup +++ b/setup @@ -25,17 +25,18 @@ for arg in "$@"; do -h|--help) printf '%s\n' \ 'Usage: ./setup [--capabilities ] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]' \ + ' [--browser managed|installed [--browser-path ]]' \ ' [--home ] [--version ] [--json] [--quiet]' \ '' \ 'Optional capabilities: browser, design, pdf, diagram, ios (iOS is macOS-only).' \ 'Without --install-now, non-interactive use previews and installs nothing.' \ '--dry-run and --install-later never modify runtime state or host setup.' \ 'Installs only the optional host-neutral runtime and selected local capabilities.' \ - 'Skills are installed separately with: npx skills add time-attack/gstack' + 'Skills are installed separately with: npx skills add time-attack/gstack/skills' exit 0 ;; --local|--team|--no-team) - echo "gstack setup: $arg is deprecated; skill placement is delegated to: npx skills add time-attack/gstack" >&2 + echo "gstack setup: $arg is deprecated; skill placement is delegated to: npx skills add time-attack/gstack/skills" >&2 ;; *) ARGS+=("$arg") ;; esac @@ -45,7 +46,7 @@ NODE_COMMAND="${GSTACK_NODE:-node}" if ! command -v "$NODE_COMMAND" >/dev/null 2>&1; then echo "gstack setup: Node 18+ is required by the managed runtime launchers." >&2 echo "Install Node from https://nodejs.org, or install judgment-only skills with:" >&2 - echo " npx skills add time-attack/gstack" >&2 + echo " npx skills add time-attack/gstack/skills" >&2 exit 1 fi diff --git a/skills/debug/references/RUNTIME.md b/skills/debug/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/debug/references/RUNTIME.md +++ b/skills/debug/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/debug/references/support/browser-choice.mjs b/skills/debug/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/debug/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/skills/design/references/RUNTIME.md b/skills/design/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/design/references/RUNTIME.md +++ b/skills/design/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/design/references/support/browser-choice.mjs b/skills/design/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/design/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/skills/plan/references/RUNTIME.md b/skills/plan/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/plan/references/RUNTIME.md +++ b/skills/plan/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/plan/references/support/browser-choice.mjs b/skills/plan/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/plan/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/skills/qa/references/RUNTIME.md b/skills/qa/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/qa/references/RUNTIME.md +++ b/skills/qa/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/qa/references/legacy/open-gstack-browser.md b/skills/qa/references/legacy/open-gstack-browser.md index 1b27b2734..9c63ec3b0 100644 --- a/skills/qa/references/legacy/open-gstack-browser.md +++ b/skills/qa/references/legacy/open-gstack-browser.md @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf" This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA. -At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step. +At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step. # $qa --mode Report --module open-gstack-browser — Launch GStack Browser diff --git a/skills/qa/references/legacy/pair-agent.md b/skills/qa/references/legacy/pair-agent.md index f6ee806a8..7299e34ee 100644 --- a/skills/qa/references/legacy/pair-agent.md +++ b/skills/qa/references/legacy/pair-agent.md @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf" This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA. -At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step. +At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step. # $qa --mode Report --module pair-agent — Share Your Browser With Another AI Agent diff --git a/skills/qa/references/legacy/setup-browser-cookies.md b/skills/qa/references/legacy/setup-browser-cookies.md index 0407f317f..d148e81ba 100644 --- a/skills/qa/references/legacy/setup-browser-cookies.md +++ b/skills/qa/references/legacy/setup-browser-cookies.md @@ -1,5 +1,5 @@ - + @@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf" This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA. -At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step. +At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step. # Setup Browser Cookies diff --git a/skills/qa/references/support/browser-choice.mjs b/skills/qa/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/qa/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/skills/review/references/RUNTIME.md b/skills/review/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/review/references/RUNTIME.md +++ b/skills/review/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/review/references/support/browser-choice.mjs b/skills/review/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/review/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/skills/ship/references/RUNTIME.md b/skills/ship/references/RUNTIME.md index 6ce746ba0..10a231dad 100644 --- a/skills/ship/references/RUNTIME.md +++ b/skills/ship/references/RUNTIME.md @@ -3,15 +3,17 @@ The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked. -Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness. +Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness. When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch. -Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability ` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. +For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability `. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading. + +Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability --browser installed --browser-path ` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent. User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA. -After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. +After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts. The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent. @@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it. -The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. +The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source --capability [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment. diff --git a/skills/ship/references/support/browser-choice.mjs b/skills/ship/references/support/browser-choice.mjs new file mode 100644 index 000000000..5558eb724 --- /dev/null +++ b/skills/ship/references/support/browser-choice.mjs @@ -0,0 +1,154 @@ +import { constants as fsConstants } from "node:fs"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]); + +const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]); + +const NAMED_CANDIDATES = Object.freeze({ + darwin: Object.freeze([ + ["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"], + ["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"], + ["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"], + ["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"], + ["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"], + ]), + win32: Object.freeze([ + ["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]], + ["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]], + ["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]], + ["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]], + ["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]], + ]), +}); + +const PATH_CANDIDATES = Object.freeze([ + ["Google Chrome", "google-chrome"], + ["Google Chrome", "google-chrome-stable"], + ["Chromium", "chromium"], + ["Chromium", "chromium-browser"], + ["Microsoft Edge", "microsoft-edge"], + ["Microsoft Edge", "microsoft-edge-stable"], + ["Brave", "brave-browser"], +]); + +export function browserChoiceRequired(capabilities) { + return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability)); +} + +export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) { + if (choice?.provider === "installed" && capabilities.includes("browser-visible")) { + throw browserChoiceError( + "Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability", + "BROWSER_PROVIDER_UNSUPPORTED", + ); + } + return choice; +} + +export function applyBrowserProviderToComponents(components, choice) { + if (choice?.provider !== "installed") return Object.freeze([...components].sort()); + return Object.freeze(components + .filter((component) => component !== "browser-headless" && component !== "browser-visible") + .sort()); +} + +export async function detectInstalledBrowsers(options = {}) { + if (Array.isArray(options.candidates)) { + const resolved = []; + for (const candidate of options.candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); + } + + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const homeDir = options.homeDir ?? os.homedir(); + const candidates = []; + if (platform === "darwin") { + for (const [name, executablePath] of NAMED_CANDIDATES.darwin) { + candidates.push({ name, executablePath }); + candidates.push({ + name, + executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")), + }); + } + } else if (platform === "win32") { + for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) { + const base = env[variable]; + if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) }); + } + } else if (platform === "linux") { + for (const [name, command] of PATH_CANDIDATES) { + for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) { + candidates.push({ name, executablePath: path.join(directory, command) }); + } + } + } + + const resolved = []; + for (const candidate of candidates) { + const browser = await inspectCandidate(candidate.name, candidate.executablePath, options); + if (browser) resolved.push(browser); + } + return deduplicate(resolved); +} + +export async function resolveBrowserChoice(choice, options = {}) { + if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) { + throw browserChoiceError( + "Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable", + "BROWSER_CHOICE_REQUIRED", + ); + } + if (choice.provider === "managed") { + if (choice.executablePath != null) { + throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID"); + } + return Object.freeze({ provider: "managed", executablePath: null }); + } + if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) { + throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED"); + } + const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options); + if (!inspected) { + throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID"); + } + return Object.freeze({ provider: "installed", executablePath: inspected.executablePath }); +} + +async function inspectCandidate(name, executablePath, options) { + if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null; + const fs_ = options.fs ?? fs; + try { + const invocationPath = path.resolve(executablePath); + const physical = await fs_.realpath(invocationPath); + const stat = await fs_.lstat(physical); + if (!stat.isFile() || stat.isSymbolicLink()) return null; + if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK); + return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical }); + } catch { + return null; + } +} + +function deduplicate(candidates) { + const seen = new Set(); + return Object.freeze(candidates.flatMap((candidate) => { + const identity = candidate.physicalPath ?? candidate.executablePath; + if (seen.has(identity)) return []; + seen.add(identity); + return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })]; + })); +} + +function browserChoiceError(message, code) { + const error = new Error(message); + error.code = code; + return error; +} diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index 75c1a91f1..d4e7a3cab 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -10,13 +10,20 @@ import { createHash } from "node:crypto"; import { constants as fsConstants, createReadStream } from "node:fs"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; +import { + applyBrowserProviderToComponents, + assertBrowserChoiceSupportsCapabilities, + browserChoiceRequired, + detectInstalledBrowsers, + resolveBrowserChoice, +} from "./browser-choice.mjs"; export const BOOTSTRAP_SCHEMA_VERSION = 2; export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0"; // Keep the runtime compatibility version separate from the immutable release // channel. Release candidates carry the 2.0.0 runtime contract while letting // fresh-machine production journeys run before the stable v2.0.0 tag exists. -export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6"; export const OFFICIAL_MANIFEST_URL = `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`; const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]); @@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) { io.stdout.write(usage()); return 0; } - if (!["preview", "install"].includes(parsed.action)) { - throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE"); + if (!["options", "preview", "install"].includes(parsed.action)) { + throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE"); } const platform = options.platform ?? process.platform; if (parsed.capabilities.includes("ios") && platform !== "darwin") { throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED"); } + const requiresBrowser = browserChoiceRequired(parsed.capabilities); + if (parsed.action === "options") { + if (!requiresBrowser) { + throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE"); + } + const detected = await detectInstalledBrowsers({ + platform, + env: options.env, + homeDir: options.homeDir, + candidates: options.browserCandidates, + }); + const installedSupported = !parsed.capabilities.includes("browser-visible"); + const installed = detected.map((browser) => ({ + ...browser, + supported: installedSupported, + ...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }), + })); + const result = { + managed: { + provider: "managed", + description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent", + }, + installed, + mutated: false, + network: false, + }; + if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`); + else printBrowserOptions(io.stdout, result); + return 0; + } + let browserChoice = null; + if (requiresBrowser) { + browserChoice = await resolveBrowserChoice({ + provider: parsed.browserProvider, + executablePath: parsed.browserPath, + }, { platform, env: options.env, homeDir: options.homeDir }); + assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities); + } else if (parsed.browserProvider || parsed.browserPath) { + throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE"); + } if (parsed.source) { if (parsed.action === "preview") { io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n"); @@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED"); io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n"); - return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false }); + return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice }); } const fetch_ = options.fetch ?? globalThis.fetch; @@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { validateManifest(manifest, target); const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack")); const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null); - const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable); + const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice); if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`); else printComponentPlan(io.stdout, plan); if (parsed.action === "preview") return 0; @@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { await assertNoLinks(componentRoot); await mergeComponentRoot(componentRoot, root, claimedFiles, item.component); } - return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version }); + return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice }); } finally { await fs.rm(temporary, { recursive: true, force: true }); } @@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) { } function parseArgs(argv) { - const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false }; + const result = { + action: null, + capabilities: [], + source: null, + home: null, + browserProvider: null, + browserPath: null, + yes: false, + json: false, + help: false, + }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (["-h", "--help"].includes(arg)) result.help = true; else if (arg === "--yes") result.yes = true; else if (arg === "--json") result.json = true; else if (!result.action && !arg.startsWith("-")) result.action = arg; - else if (["--capability", "--source", "--home"].includes(arg)) { + else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) { const value = argv[++index]; if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE"); if (arg === "--capability") result.capabilities.push(value); else if (arg === "--source") result.source = value; - else result.home = value; + else if (arg === "--home") result.home = value; + else if (arg === "--browser") result.browserProvider = value; + else result.browserPath = value; } else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE"); } if (result.help) return result; if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE"); + if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) { + throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) { + throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserProvider === "managed" && result.browserPath != null) { + throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE"); + } + if (result.browserPath != null && result.browserProvider !== "installed") { + throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE"); + } if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE"); result.capabilities = [...new Set(result.capabilities)].sort(); for (const capability of result.capabilities) { @@ -212,7 +283,7 @@ function sameGraph(actual, expected) { return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected)); } -function selectedComponents(capabilities) { +function selectedComponents(capabilities, browserChoice) { const selected = new Set(["core"]); for (const capability of capabilities) { for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component); @@ -226,11 +297,11 @@ function selectedComponents(capabilities) { } } } - return [...selected].sort(); + return applyBrowserProviderToComponents([...selected], browserChoice); } -function buildComponentPlan(manifest, target, capabilities, reusable) { - const components = selectedComponents(capabilities); +function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) { + const components = selectedComponents(capabilities, browserChoice); const retained = new Set(reusable?.components ?? []); const downloads = components .filter((component) => !retained.has(component)) @@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { target, version: manifest.version, capabilities, + browser: browserChoice, components, reusedComponents: components.filter((component) => retained.has(component)), downloads, @@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) { function printComponentPlan(stdout, plan) { stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`); stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`); + if (plan.browser?.provider === "installed") { + stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`); + } else if (plan.browser?.provider === "managed") { + stdout.write("Browser: managed isolated Chromium\n"); + } stdout.write(`Components: ${plan.components.join(", ")}\n`); if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`); stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`); @@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) { const stat = await fs.lstat(installer).catch(() => null); if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID"); const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")]; + if (options.browserChoice) { + args.push("--browser", options.browserChoice.provider); + if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath); + } if (parsed.home) args.push("--home", path.resolve(parsed.home)); if (options.version) args.push("--version", options.version); if (options.prepared) args.push("--prepared"); @@ -535,12 +616,24 @@ function formatBytes(bytes) { } function usage() { - return "Usage: node runtime-bootstrap.mjs install --capability [--capability ...]\n" + - " node runtime-bootstrap.mjs install --source --capability \n\n" + + return "Usage: node runtime-bootstrap.mjs options --capability \n" + + " node runtime-bootstrap.mjs preview|install --capability [--capability ...]\n" + + " --browser managed|installed [--browser-path ] [--yes]\n" + + " node runtime-bootstrap.mjs install --source --capability --browser \n\n" + "Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" + "--source is a developer-only fallback for a checkout you have reviewed and trust.\n"; } +function printBrowserOptions(stdout, result) { + stdout.write("GStack browser setup options (no network access and no changes made)\n"); + stdout.write(`managed: ${result.managed.description}\n`); + if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n"); + for (const browser of result.installed) stdout.write(browser.supported + ? `installed: ${browser.name} — ${browser.executablePath}\n` + : `installed (unavailable for this capability): ${browser.name} — ${browser.executablePath}; ${browser.reason}\n`); + stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n"); +} + async function isDirectExecution() { if (!process.argv[1]) return false; const [modulePath, invokedPath] = await Promise.all([ diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index 77b84b578..fea03f762 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { pathToFileURL } from "node:url"; import { main as runtimeMain } from "../runtime/cli.js"; +import { configSetBrowserChoice } from "../runtime/config.js"; import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle"; import { DEFAULT_CAPABILITY_LAUNCHERS, @@ -14,6 +15,7 @@ import { defaultBunBuilder, installManagedRuntime, normalizeManagedBrowserTree, + runInstallerCli, runtimeReleaseComponentForPath, runtimeNativePackagePaths, uninstallManagedRuntime, @@ -28,6 +30,12 @@ const ENTRIES = [ { path: "cap/tool", build: "fixture", executable: true }, ]; const CAPABILITIES = { "fixture-tool": "cap/tool" }; +const BROWSER_ENTRIES = [ + { path: "runtime" }, + { path: "bin/gstack", executable: true }, + { path: "browse/dist/browse", build: "fixture", executable: true }, +]; +const BROWSER_CAPABILITIES = { browse: "browse/dist/browse" }; const REPO_ROOT = path.resolve(import.meta.dir, ".."); const FULL_RUNTIME_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 30_000; @@ -113,6 +121,185 @@ describe("GStack 2 managed runtime installer", () => { }, { createDefaultSource: false }); }); + test("stable launchers inject the persisted installed-browser choice and honor clearing it", async () => { + await withFixture(async ({ source, home }) => { + await installFixture(source, home, "browser-config-launcher", { + entries: BROWSER_ENTRIES, + capabilities: BROWSER_CAPABILITIES, + }); + const executable = await fs.realpath(process.execPath); + await configSetBrowserChoice(home, { provider: "installed", executablePath: executable }); + const ambient = { ...process.env, GSTACK_CHROMIUM_PATH: path.join(home, "ambient-browser-must-not-win") }; + const selected = await runInstalledLauncher(home, "browse", [], { capture: true, env: ambient }); + expect(selected.stdout).toBe(executable); + + await configSetBrowserChoice(home, { provider: "managed", executablePath: null }); + await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient })) + .rejects.toThrow("Command failed"); + + await configSetBrowserChoice(home, null); + await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient })) + .rejects.toThrow("Command failed"); + }); + }); + + test("installed-browser launchers refuse visible commands before starting the browser binary", async () => { + await withFixture(async ({ source, home }) => { + const executable = await fs.realpath(process.execPath); + await installFixture(source, home, "installed-visible-refusal", { + entries: BROWSER_ENTRIES, + capabilities: BROWSER_CAPABILITIES, + browserChoice: { provider: "installed", executablePath: executable }, + }); + await configSetBrowserChoice(home, { provider: "installed", executablePath: executable }); + await expect(runInstalledLauncher(home, "browse", ["connect"], { capture: true })) + .rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") }); + await expect(runInstalledLauncher(home, "browse", ["pair-agent"], { capture: true })) + .rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") }); + await expect(runInstalledLauncher(home, "browse", ["handoff"], { capture: true })) + .rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") }); + }); + }); + + test("design-only launchers do not require an unrelated browser selection", async () => { + await withFixture(async ({ source, home }) => { + const design = path.join(source, "design", "dist", "design"); + await fs.mkdir(path.dirname(design), { recursive: true }); + await fs.writeFile(design, "#!/bin/sh\nprintf 'design ready\\n'\n", { mode: 0o755 }); + await installManagedRuntime({ + sourceDir: source, + home, + version: "design-without-browser", + entries: [...ENTRIES, { path: "design/dist/design", build: "fixture", executable: true }], + capabilities: { ...CAPABILITIES, "gstack-design": "design/dist/design" }, + }); + await configSetBrowserChoice(home, null); + expect((await runInstalledLauncher(home, "gstack-design", [], { capture: true })).stdout) + .toContain("design ready"); + }); + }); + + test("browser config refuses a provider that does not match the active runtime slot", async () => { + await withFixture(async ({ source, home }) => { + const result = await installFixture(source, home, "installed-slot"); + const manifestPath = path.join(result.path, ".gstack-bundle.json"); + const manifest = await readJson(manifestPath); + await fs.writeFile(manifestPath, JSON.stringify({ + ...manifest, + selectedCapabilities: ["browser"], + runtimeComponents: ["browser-code", "core"], + browserChoice: { provider: "installed", executablePath: process.execPath }, + })); + const output = captureStream(); + expect(await runtimeMain(["config", "browser", "managed"], { + cwd: source, + env: { ...process.env, GSTACK_HOME: home }, + stdout: output.stream, + stderr: output.stream, + })).toBe(1); + expect(output.value()).toContain("active runtime was installed for installed Chromium"); + + const selected = captureStream(); + expect(await runtimeMain(["config", "browser", "installed", process.execPath], { + cwd: source, + env: { ...process.env, GSTACK_HOME: home }, + stdout: selected.stream, + stderr: selected.stream, + })).toBe(0); + expect((await readJson(path.join(home, "config.json"))).browser.provider).toBe("installed"); + }); + }); + + test("rollback validates a recorded installed browser before switching runtime slots", async () => { + await withFixture(async ({ root, source, home }) => { + const fallback = await installFixture(source, home, "installed-fallback"); + const staleBrowser = path.join(root, "removed-chromium"); + const fallbackManifestPath = path.join(fallback.path, ".gstack-bundle.json"); + const fallbackManifest = await readJson(fallbackManifestPath); + await fs.writeFile(fallbackManifestPath, JSON.stringify({ + ...fallbackManifest, + selectedCapabilities: ["browser"], + runtimeComponents: ["browser-code", "core"], + browserChoice: { provider: "installed", executablePath: staleBrowser }, + })); + + const current = await installFixture(source, home, "managed-current"); + const currentManifestPath = path.join(current.path, ".gstack-bundle.json"); + const currentManifest = await readJson(currentManifestPath); + await fs.writeFile(currentManifestPath, JSON.stringify({ + ...currentManifest, + selectedCapabilities: ["browser"], + runtimeComponents: ["browser-headless", "core"], + browserChoice: { provider: "managed", executablePath: null }, + })); + await configSetBrowserChoice(home, { provider: "managed", executablePath: null }); + + const output = captureStream(); + expect(await runtimeMain(["upgrade", "--rollback"], { + cwd: source, + env: { ...process.env, GSTACK_HOME: home }, + stdout: output.stream, + stderr: output.stream, + })).toBe(1); + expect(output.value()).toContain("unavailable or not executable"); + expect((await readJson(path.join(home, "versions", "current.json"))).current).toBe("managed-current"); + expect((await readJson(path.join(home, "config.json"))).browser) + .toEqual({ provider: "managed", executablePath: null }); + }); + }); + + test("an installed-browser setup persists the choice only after activation and launches through it", async () => { + await withFixture(async ({ root, source, home }) => { + await fs.writeFile(path.join(source, "cap", "tool"), `#!/usr/bin/env node +process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset"); +`, { mode: 0o755 }); + const executable = await fs.realpath(process.execPath); + const output = captureStream(); + expect(await runInstallerCli([ + "--source", source, + "--home", home, + "--capabilities", "browser", + "--browser", "installed", + "--browser-path", executable, + "--install-now", + "--yes", + "--json", + ], { + stdout: output.stream, + stderr: output.stream, + prepareDependencies: async () => {}, + installOptions: { entries: BROWSER_ENTRIES, capabilities: BROWSER_CAPABILITIES }, + })).toBe(0); + expect((await readJson(path.join(home, "config.json"))).browser) + .toEqual({ provider: "installed", executablePath: executable }); + expect((await runInstalledLauncher(home, "browse", [], { capture: true })).stdout) + .toBe(executable); + + const failedHome = path.join(root, "failed-home", ".gstack"); + const failed = captureStream(); + expect(await runInstallerCli([ + "--source", source, + "--home", failedHome, + "--capabilities", "browser", + "--browser", "installed", + "--browser-path", executable, + "--install-now", + "--yes", + "--json", + ], { + stdout: failed.stream, + stderr: failed.stream, + prepareDependencies: async () => {}, + installOptions: { + entries: BROWSER_ENTRIES, + capabilities: BROWSER_CAPABILITIES, + smokeTest: async () => { throw new Error("fixture smoke failure"); }, + }, + })).toBe(1); + expect(await exists(path.join(failedHome, "config.json"))).toBe(false); + }); + }); + test("accepts a symlink to the source root but rejects links inside the allowlist", async () => { if (process.platform === "win32") return; await withFixture(async ({ root, source, home }) => { @@ -417,6 +604,13 @@ describe("GStack 2 managed runtime installer", () => { await fs.writeFile(path.join(browserRoot, "chromium-fixture", "chrome"), "fixture\n", { mode: 0o755 }); return { code: 0, stdout: "", stderr: "" }; } + const outfileIndex = args.indexOf("--outfile"); + if (outfileIndex >= 0 && typeof args[outfileIndex + 1] === "string") { + const outfile = path.join(REPO_ROOT, args[outfileIndex + 1]); + await fs.mkdir(path.dirname(outfile), { recursive: true }); + await fs.writeFile(outfile, "fixture runtime helper\n", { mode: 0o755 }); + return { code: 0, stdout: "", stderr: "" }; + } if (args[0] === "--version" && (command === process.execPath || command.includes(".gstack-runtime-tools"))) { return { code: 0, stdout: "1.3.14\n", stderr: "" }; } @@ -1095,15 +1289,20 @@ async function createSource(source: string) { await fs.mkdir(path.join(source, "runtime"), { recursive: true }); await fs.mkdir(path.join(source, "bin"), { recursive: true }); await fs.mkdir(path.join(source, "cap"), { recursive: true }); + await fs.mkdir(path.join(source, "browse", "dist"), { recursive: true }); await fs.writeFile(path.join(source, "package.json"), '{"name":"gstack","version":"2.0.0","type":"module"}\n'); await fs.writeFile(path.join(source, "runtime", "cli.js"), fixtureCli("")); await fs.writeFile(path.join(source, "runtime", "tooling.js"), 'export async function resolveBashCommand(env = process.env) { return env.GSTACK_BASH || "bash"; }\n'); + await fs.copyFile(path.join(REPO_ROOT, "runtime", "browser-choice.mjs"), path.join(source, "runtime", "browser-choice.mjs")); await fs.writeFile(path.join(source, "bin", "gstack"), `#!/usr/bin/env node import { main } from "../runtime/cli.js"; process.exitCode = await main(process.argv.slice(2)); `, { mode: 0o755 }); await fs.writeFile(path.join(source, "cap", "tool"), "#!/bin/sh\nprintf 'fixture capability %s\\n' \"$*\"\n", { mode: 0o755 }); + await fs.writeFile(path.join(source, "browse", "dist", "browse"), `#!/usr/bin/env node +process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset"); +`, { mode: 0o755 }); } function fixtureCli(label: string) { diff --git a/test/gstack2-runtime-setup-ux.test.ts b/test/gstack2-runtime-setup-ux.test.ts index 4fca7bf4b..2daba155d 100644 --- a/test/gstack2-runtime-setup-ux.test.ts +++ b/test/gstack2-runtime-setup-ux.test.ts @@ -3,10 +3,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +import { PassThrough, Readable } from "node:stream"; import { runDoctor } from "../runtime/doctor.js"; import { runInstallerCli, runtimeSlotVersion, runtimeSurfaceForCapabilities } from "../runtime/install.js"; import { resolveRuntimePaths } from "../runtime/paths.js"; import { setupRuntime } from "../runtime/setup.js"; +import { configSetBrowserChoice } from "../runtime/config.js"; +import { detectInstalledBrowsers, resolveBrowserChoice } from "../runtime/browser-choice.mjs"; import { bashCandidates, resolveBashCommand } from "../runtime/tooling.js"; import { BOOTSTRAP_SCHEMA_VERSION, @@ -96,13 +99,13 @@ describe("GStack runtime setup UX", () => { })); const retained = capture(); expect(await runInstallerCli([ - "--source", source, "--home", home, "--capabilities", "pdf", "--dry-run", "--json", + "--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--dry-run", "--json", ], { stdout: retained.stream, stderr: retained.stream })).toBe(0); expect(JSON.parse(retained.value()).preview.capabilities).toEqual(["browser", "design", "diagram", "pdf"]); const replaced = capture(); expect(await runInstallerCli([ - "--source", source, "--home", home, "--capabilities", "pdf", "--replace-capabilities", "--dry-run", "--json", + "--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--replace-capabilities", "--dry-run", "--json", ], { stdout: replaced.stream, stderr: replaced.stream })).toBe(0); expect(JSON.parse(replaced.value()).preview.capabilities).toEqual(["browser", "diagram", "pdf"]); } finally { @@ -146,6 +149,7 @@ describe("GStack runtime setup UX", () => { "--source", path.resolve(import.meta.dir, ".."), "--home", home, "--capabilities", "browser", + "--browser", "managed", "--dry-run", "--json", ], { @@ -166,6 +170,183 @@ describe("GStack runtime setup UX", () => { } }); + test("installed-browser preview skips every managed Chromium payload without persisting the choice", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-installed-preview-")); + const home = path.join(root, "home"); + const output = capture(); + try { + expect(await runInstallerCli([ + "--source", path.resolve(import.meta.dir, ".."), + "--home", home, + "--capabilities", "browser", + "--browser", "installed", + "--browser-path", process.execPath, + "--dry-run", + "--json", + ], { stdout: output.stream, stderr: output.stream })).toBe(0); + const preview = JSON.parse(output.value()).preview; + expect(preview.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) }); + expect(preview.materializations.some((item) => item.kind === "playwright-chromium-download")).toBe(false); + expect(runtimeSurfaceForCapabilities(["browser"], { + browserChoice: preview.browser, + }).entries.some((entry) => entry.path === ".gstack-runtime-browsers")).toBe(false); + await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("installed-browser detection preserves wrapper paths, deduplicates physical targets, and rejects invalid files", async () => { + if (process.platform === "win32") return; + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-detect-")); + try { + const physical = path.join(root, "snap"); + const chrome = path.join(root, "google-chrome"); + const chromium = path.join(root, "chromium"); + const invalid = path.join(root, "not-executable"); + await fs.writeFile(physical, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + await fs.writeFile(invalid, "not executable\n", { mode: 0o644 }); + await fs.symlink(physical, chrome); + await fs.symlink(physical, chromium); + + const detected = await detectInstalledBrowsers({ + platform: "linux", + env: { PATH: root }, + homeDir: root, + }); + expect(detected).toEqual([{ name: "Google Chrome", executablePath: chrome }]); + expect(await resolveBrowserChoice({ provider: "installed", executablePath: chrome }, { platform: "linux" })) + .toEqual({ provider: "installed", executablePath: chrome }); + await expect(resolveBrowserChoice({ provider: "installed", executablePath: invalid }, { platform: "linux" })) + .rejects.toMatchObject({ code: "BROWSER_PATH_INVALID" }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("interactive browser choice covers managed, installed, later, and invalid selections without installing", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-choice-")); + const source = path.join(root, "minimal-source"); + const installedBrowser = path.join(root, "google-chrome"); + await fs.mkdir(source); + await fs.writeFile(installedBrowser, "#!/bin/sh\nexit 0\n", { mode: 0o755 }); + try { + const cases = [ + { answer: "m", label: "managed isolated Chromium", code: 0 }, + { answer: "1", label: installedBrowser, code: 0 }, + { answer: "l", label: "No browser provider was selected", code: 0 }, + { answer: "9", label: "Invalid browser selection", code: 1 }, + ]; + for (const [index, fixture] of cases.entries()) { + const home = path.join(root, `home-${index}`); + const output = new PassThrough(); + const input = new PassThrough() as PassThrough & { isTTY: boolean }; + input.isTTY = true; + let outputValue = ""; + let answeredBrowser = false; + let answeredInstall = false; + output.on("data", (chunk) => { + outputValue += String(chunk); + if (!answeredBrowser && outputValue.includes("Select m, a browser number, or l")) { + answeredBrowser = true; + input.write(`${fixture.answer}\n`); + } + if (!answeredInstall && outputValue.includes("Install this optional local runtime now?")) { + answeredInstall = true; + input.end("later\n"); + } + }); + const code = await runInstallerCli([ + "--source", source, + "--home", home, + "--capabilities", "browser", + ], { + stdin: input, + stdout: output, + stderr: output, + platform: "linux", + env: { ...process.env, PATH: root }, + homeDir: root, + }); + expect(code, outputValue).toBe(fixture.code); + expect(outputValue).toContain(fixture.label); + await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" }); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + test("browser bootstrap options are local-only and browser preview requires an explicit choice", async () => { + const output = capture(); + let fetches = 0; + const browser = await fs.realpath(process.execPath); + expect(await bootstrapMain([ + "options", "--capability", "browser", "--json", + ], { + stdout: output.stream, + stderr: output.stream, + browserCandidates: [{ name: "Fixture Chromium", executablePath: browser }], + fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); }, + })).toBe(0); + expect(JSON.parse(output.value())).toMatchObject({ + ok: true, + action: "options", + mutated: false, + network: false, + installed: [{ name: "Fixture Chromium", executablePath: browser }], + }); + expect(fetches).toBe(0); + + const missing = capture(); + expect(await bootstrapMain(["preview", "--capability", "browser"], { + stdout: missing.stream, + stderr: missing.stream, + fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); }, + })).toBe(1); + expect(missing.value()).toContain("Choose a browser provider"); + expect(fetches).toBe(0); + }); + + test("official installed-browser preview reports exact adapter bytes and omits browser binaries", async () => { + const output = capture(); + const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; + let fetches = 0; + expect(await bootstrapMain([ + "preview", "--capability", "browser", "--browser", "installed", + "--browser-path", process.execPath, "--json", + ], { + stdout: output.stream, + stderr: output.stream, + libc: process.platform === "linux" ? "glibc" : undefined, + fetch: async (url: string) => { + fetches += 1; + return { ok: true, url, json: async () => officialManifestFixture(target) }; + }, + })).toBe(0); + const result = JSON.parse(output.value()); + expect(result.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) }); + expect(result.components).toEqual(["browser-code", "core"]); + expect(result.downloads.map((item) => item.component)).toEqual(["browser-code", "core"]); + expect(result.downloadBytes).toBe(16); + expect(fetches).toBe(1); + }); + + test("visible GStack Browser refuses installed Chrome before any network request", async () => { + const output = capture(); + let fetches = 0; + expect(await bootstrapMain([ + "preview", "--capability", "browser-visible", "--browser", "installed", + "--browser-path", process.execPath, + ], { + stdout: output.stream, + stderr: output.stream, + fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); }, + })).toBe(1); + expect(output.value()).toContain("requires managed Chromium"); + expect(fetches).toBe(0); + }); + test("Windows Bash discovery shared by doctor and launchers finds a standard Git installation", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-git-bash-")); const bash = path.join(root, "Git", "bin", "bash.exe"); @@ -239,6 +420,7 @@ describe("GStack runtime setup UX", () => { await fs.writeFile(paths.versionPointer, JSON.stringify({ schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture", })); + await configSetBrowserChoice(home, { provider: "managed", executablePath: null }); const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath }); expect(report.ok).toBe(false); expect(report.checks.find((check) => check.id === "capability:pdf")).toMatchObject({ status: "fail" }); @@ -274,6 +456,7 @@ describe("GStack runtime setup UX", () => { await fs.writeFile(paths.versionPointer, JSON.stringify({ schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture", })); + await configSetBrowserChoice(home, { provider: "managed", executablePath: null }); const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath }); expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({ status: "pass", @@ -285,6 +468,43 @@ describe("GStack runtime setup UX", () => { } }); + test("doctor launches the explicitly selected installed browser through the same Playwright adapter", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-installed-browser-")); + const home = path.join(root, "home"); + try { + await setupRuntime({ home, cwd: root }); + const paths = resolveRuntimePaths({ home }); + const active = path.join(paths.versions, "fixture"); + const managedBun = path.join(active, ".gstack-runtime-tools", process.platform === "win32" ? "bun.exe" : "bun"); + const playwright = path.join(active, "node_modules", "playwright"); + await fs.mkdir(path.dirname(managedBun), { recursive: true }); + await fs.mkdir(playwright, { recursive: true }); + await fs.copyFile(process.execPath, managedBun); + if (process.platform !== "win32") await fs.chmod(managedBun, 0o755); + const executable = await fs.realpath(process.execPath); + await fs.writeFile(path.join(playwright, "index.mjs"), + `export const chromium = { launch: async ({ headless, executablePath }) => { if (headless !== true || executablePath !== ${JSON.stringify(executable)}) throw new Error("wrong installed-browser launch"); return { version: () => "fixture-installed", close: async () => {} }; } };\n`); + await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({ + compatibility: { skillApi: "2.0" }, + selectedCapabilities: ["browser"], + capabilities: { browse: "browse/dist/browse" }, + tools: { bun: { path: path.relative(active, managedBun).split(path.sep).join("/"), version: "1.3.14" } }, + })); + await fs.writeFile(paths.versionPointer, JSON.stringify({ + schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture", + })); + await configSetBrowserChoice(home, { provider: "installed", executablePath: executable }); + const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath }); + expect(report.checks.find((check) => check.id === "browser-selection")).toMatchObject({ status: "pass" }); + expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({ + status: "pass", + details: { provider: "installed", executablePath: executable, version: "fixture-installed" }, + }); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + test("bootstrap help has no dependency or network side effects", async () => { const output = capture(); let fetches = 0; @@ -303,7 +523,7 @@ describe("GStack runtime setup UX", () => { let calls = 0; try { expect(await bootstrapMain([ - "preview", "--capability", "browser-visible", "--home", path.join(root, "home"), + "preview", "--capability", "browser-visible", "--browser", "managed", "--home", path.join(root, "home"), ], { stdout: output.stream, stderr: output.stream, @@ -355,7 +575,7 @@ describe("GStack runtime setup UX", () => { arrayBuffer: async () => new TextEncoder().encode("tampered").buffer, }; }; - expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], { + expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], { stdout: output.stream, stderr: output.stream, fetch: fetch_, @@ -381,7 +601,7 @@ describe("GStack runtime setup UX", () => { test("official Linux bootstrap rejects musl explicitly before any network request", async () => { const output = capture(); let fetches = 0; - expect(await bootstrapMain(["install", "--capability", "browser"], { + expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed"], { platform: "linux", arch: "x64", libc: "musl", @@ -397,7 +617,7 @@ describe("GStack runtime setup UX", () => { const output = capture(); const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`; let calls = 0; - expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], { + expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], { stdout: output.stream, stderr: output.stream, fetch: async (url: string) => { @@ -429,7 +649,7 @@ describe("GStack runtime setup UX", () => { process.env.BOOTSTRAP_TEST_LOG = log; try { expect(await bootstrapMain([ - "install", "--source", root, "--capability", "pdf", "--home", path.join(root, "home"), "--yes", + "install", "--source", root, "--capability", "pdf", "--browser", "managed", "--home", path.join(root, "home"), "--yes", ], { stdout: output.stream, stderr: output.stream })).toBe(0); } finally { if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG; diff --git a/test/gstack2-skill-ux.test.ts b/test/gstack2-skill-ux.test.ts index 8f7424a55..f137f7fa0 100644 --- a/test/gstack2-skill-ux.test.ts +++ b/test/gstack2-skill-ux.test.ts @@ -61,16 +61,19 @@ describe('GStack 2 canonical skill UX', () => { for (const tree of TREE_NAMES) { const runtime = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), 'utf8'); const bootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs')); + const browserChoice = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs')); const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8')); expect(bootstrap, tree).toEqual(source); + expect(browserChoice, tree).toEqual(fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs'))); expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' }); expect(runtime, tree).toContain('preview --capability '); expect(runtime, tree).toContain('It never downloads components or mutates runtime state.'); - expect(runtime, tree).toContain('install --capability --yes'); + expect(runtime, tree).toContain('options --capability '); + expect(runtime, tree).toContain('gstack config browser clear'); expect(runtime, tree).toContain('Never run `./setup` inside a standard-installed skill directory'); expect(runtime, tree).toContain('Deferring installation records no consent'); - expect(runtime, tree).toContain('Logical `browser` expands to `browser-code + browser-headless`'); - expect(runtime, tree).toContain('`browser-visible` expands to `browser-code + browser-visible` and does not require headless'); + expect(runtime, tree).toContain('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`'); + expect(runtime, tree).toContain('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only'); expect(runtime, tree).toContain('`pdf` depends on `diagram`'); expect(runtime, tree).toContain('`all` means those five and intentionally excludes visible Chromium'); expect(runtime, tree).toContain('summed compressed bytes'); @@ -85,8 +88,8 @@ describe('GStack 2 canonical skill UX', () => { for (const source of ['open-gstack-browser', 'pair-agent', 'setup-browser-cookies']) { const body = fs.readFileSync(ownerModule(source), 'utf8'); expect(body, source).toContain('## Visible-browser point-of-use gate'); - expect(body, source).toContain('preview --capability browser-visible'); - expect(body, source).toContain('install --capability browser-visible --yes'); + expect(body, source).toContain('preview --capability browser-visible --browser managed'); + expect(body, source).toContain('install --capability browser-visible --browser managed --yes'); expect(body, source).toContain('never requires `browser-headless`'); } expect(fs.readFileSync(ownerModule('browse'), 'utf8')).not.toContain('browser-visible'); @@ -124,7 +127,7 @@ describe('GStack 2 canonical skill UX', () => { let stdout = ''; let stderr = ''; const code = await module.main([ - 'install', '--source', source, '--capability', 'browser', '--home', home, '--yes', + 'install', '--source', source, '--capability', 'browser', '--browser', 'managed', '--home', home, '--yes', ], { stdout: { write: (chunk: string) => { stdout += chunk; } }, stderr: { write: (chunk: string) => { stderr += chunk; } }, diff --git a/test/release-hardening.test.ts b/test/release-hardening.test.ts index 1a2e87c21..54db38bd7 100644 --- a/test/release-hardening.test.ts +++ b/test/release-hardening.test.ts @@ -34,6 +34,7 @@ describe("release and CI hardening", () => { }); expect(pkg.files).toEqual(["bin/gstack", "runtime", "README.md", "LICENSE", "VERSION"]); expect(pkg.dependencies["puppeteer-core"]).toBeUndefined(); + expect(pkg.dependencies.playwright).toBe("npm:playwright-core@^1.58.2"); }); test("runtime identity is aligned independently of the legacy four-slot release counter", () => { @@ -53,6 +54,7 @@ describe("release and CI hardening", () => { expect(workflow).toContain("versions/current.json"); expect(workflow).not.toContain('active="$GSTACK_HOME/versions/2.0.0"'); expect(workflow).toContain(".gstack-runtime-browsers"); + expect(workflow).toContain("--browser managed"); // Exercise both the bundled browser and the explicit Chromium channel. Keep // this semantic: the workflow intentionally loops over launch options so a // harmless refactor does not invalidate release hardening. @@ -68,6 +70,8 @@ describe("release and CI hardening", () => { expect(workflow).toContain("pathToFileURL(p).href"); expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")'); expect(workflow).not.toContain("goto about:blank"); + expect(read("scripts/gstack2/runtime-install-smoke.sh")) + .toContain('./setup --home "$HOME_DIR" --browser managed --install-now --yes --json'); const manifest = read(".github/scripts/create-runtime-release-manifest.mjs"); expect(manifest).toContain("bytes: stat.size"); expect(manifest).toContain('certificateOidcIssuer: "https://token.actions.githubusercontent.com"'); @@ -85,6 +89,7 @@ describe("release and CI hardening", () => { const installer = read("runtime/install.js"); expect(installer).toContain('entry("runtime")'); expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)'); + expect(installer).not.toContain('entry("node_modules/playwright-core")'); const browser = read("browse/src/cli.ts"); expect(browser).toContain("Every installed/compiled client must use the adjacent Node-compatible daemon"); expect(browser).toContain("export function resolveServerLaunchTarget("); @@ -93,8 +98,8 @@ describe("release and CI hardening", () => { test("Windows setup lane installs, doctors, and uninstalls rather than only building", () => { const workflow = read(".github/workflows/windows-setup-e2e.yml"); - expect(workflow).toContain("--dry-run --capabilities browser"); - expect(workflow).toContain("--install-now --yes --capabilities browser"); + expect(workflow).toContain("--dry-run --capabilities browser --browser managed"); + expect(workflow).toContain("--install-now --yes --capabilities browser --browser managed"); expect(workflow).toContain("doctor --json"); expect(workflow).toContain("runtime/cli.js uninstall"); });