From b0047cc525e4708ed0d05c6b724d915343b81f19 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Mon, 20 Jul 2026 16:51:59 -0700 Subject: [PATCH 1/7] fix: add production runtime RC release channel Publish signed prerelease artifacts from v2.0.0-rc.* tags, bind bootstrap trust to the immutable RC tag, make missing-release errors actionable, and install the six public skills from the canonical subpath. --- .../create-runtime-release-manifest.mjs | 16 +++-- .github/workflows/release-artifacts.yml | 10 ++- AGENTS.md | 2 +- CLAUDE.md | 2 +- README.md | 8 +-- docs/gstack-2/ARCHITECTURE.md | 2 +- docs/gstack-2/HOST-COMPATIBILITY.md | 4 +- docs/gstack-2/JUDGMENT-PROVENANCE.json | 2 +- docs/gstack-2/RELEASE-INTEGRITY.md | 17 +++-- docs/gstack-2/UPGRADE-AND-ROLLBACK.md | 2 +- evals/parity/contracts/gstack-upgrade.json | 2 +- evals/parity/manifest.json | 2 +- runtime/runtime-bootstrap.mjs | 26 ++++++-- scripts/gstack2/generate-skill-tree.ts | 4 +- scripts/gstack2/render-legacy.ts | 4 +- scripts/gstack2/test-install-matrix.ts | 27 +++++--- skills/.compat/autoplan/SKILL.md | 2 +- skills/.compat/benchmark-models/SKILL.md | 2 +- skills/.compat/benchmark/SKILL.md | 2 +- skills/.compat/browse/SKILL.md | 2 +- skills/.compat/canary/SKILL.md | 2 +- skills/.compat/careful/SKILL.md | 2 +- skills/.compat/claude/SKILL.md | 2 +- skills/.compat/codex/SKILL.md | 2 +- skills/.compat/context-restore/SKILL.md | 2 +- skills/.compat/context-save/SKILL.md | 2 +- skills/.compat/cso/SKILL.md | 2 +- skills/.compat/design-consultation/SKILL.md | 2 +- skills/.compat/design-html/SKILL.md | 2 +- skills/.compat/design-review/SKILL.md | 2 +- skills/.compat/design-shotgun/SKILL.md | 2 +- skills/.compat/devex-review/SKILL.md | 2 +- skills/.compat/diagram/SKILL.md | 2 +- skills/.compat/document-generate/SKILL.md | 2 +- skills/.compat/document-release/SKILL.md | 2 +- skills/.compat/freeze/SKILL.md | 2 +- skills/.compat/gstack-upgrade/SKILL.md | 2 +- skills/.compat/gstack/SKILL.md | 2 +- skills/.compat/guard/SKILL.md | 2 +- skills/.compat/health/SKILL.md | 2 +- skills/.compat/investigate/SKILL.md | 2 +- skills/.compat/ios-clean/SKILL.md | 2 +- skills/.compat/ios-design-review/SKILL.md | 2 +- skills/.compat/ios-fix/SKILL.md | 2 +- skills/.compat/ios-qa/SKILL.md | 2 +- skills/.compat/ios-sync/SKILL.md | 2 +- skills/.compat/land-and-deploy/SKILL.md | 2 +- skills/.compat/landing-report/SKILL.md | 2 +- skills/.compat/learn/SKILL.md | 2 +- skills/.compat/make-pdf/SKILL.md | 2 +- skills/.compat/office-hours/SKILL.md | 2 +- skills/.compat/open-gstack-browser/SKILL.md | 2 +- skills/.compat/pair-agent/SKILL.md | 2 +- skills/.compat/plan-ceo-review/SKILL.md | 2 +- skills/.compat/plan-design-review/SKILL.md | 2 +- skills/.compat/plan-devex-review/SKILL.md | 2 +- skills/.compat/plan-eng-review/SKILL.md | 2 +- skills/.compat/plan-tune/SKILL.md | 2 +- skills/.compat/qa-only/SKILL.md | 2 +- skills/.compat/retro/SKILL.md | 2 +- skills/.compat/scrape/SKILL.md | 2 +- skills/.compat/setup-browser-cookies/SKILL.md | 2 +- skills/.compat/setup-deploy/SKILL.md | 2 +- skills/.compat/setup-gbrain/SKILL.md | 2 +- skills/.compat/skillify/SKILL.md | 2 +- skills/.compat/spec/SKILL.md | 2 +- skills/.compat/sync-gbrain/SKILL.md | 2 +- skills/.compat/unfreeze/SKILL.md | 2 +- skills/debug/references/WEB-CONTEXT.md | 2 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- skills/design/references/WEB-CONTEXT.md | 2 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- skills/plan/references/WEB-CONTEXT.md | 2 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- skills/qa/references/WEB-CONTEXT.md | 2 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- skills/review/references/WEB-CONTEXT.md | 2 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- skills/ship/references/WEB-CONTEXT.md | 2 +- .../ship/references/legacy/gstack-upgrade.md | 4 +- .../references/support/runtime-bootstrap.mjs | 26 ++++++-- test/gstack2-installation.test.ts | 7 ++ test/gstack2-runtime-release-channel.test.ts | 66 +++++++++++++++++++ test/gstack2-runtime-setup-ux.test.ts | 28 +++++++- 84 files changed, 367 insertions(+), 140 deletions(-) create mode 100644 test/gstack2-runtime-release-channel.test.ts diff --git a/.github/scripts/create-runtime-release-manifest.mjs b/.github/scripts/create-runtime-release-manifest.mjs index 4fd487bac..198a48286 100644 --- a/.github/scripts/create-runtime-release-manifest.mjs +++ b/.github/scripts/create-runtime-release-manifest.mjs @@ -2,11 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; -const [directory, repository = process.env.GITHUB_REPOSITORY, version = "2.0.0"] = process.argv.slice(2); +const [ + directory, + repository = process.env.GITHUB_REPOSITORY, + version = "2.0.0", + releaseTag = `v${version}`, +] = process.argv.slice(2); if (!directory || !repository) { - console.error("Usage: create-runtime-release-manifest.mjs [version]"); + console.error("Usage: create-runtime-release-manifest.mjs [version] [release-tag]"); process.exit(2); } +if (!/^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(releaseTag)) { + throw new Error(`Invalid runtime release tag: ${releaseTag}`); +} const targets = [ "darwin-arm64", @@ -35,8 +43,8 @@ const capabilityComponents = { ios: ["ios"], }; const commonComponents = ["core", "browser-code", "browser-headless", "browser-visible", "design", "diagram", "pdf"]; -const release = `https://github.com/${repository}/releases/download/v${version}`; -const certificateIdentity = `https://github.com/${repository}/.github/workflows/release-artifacts.yml@refs/tags/v${version}`; +const release = `https://github.com/${repository}/releases/download/${releaseTag}`; +const certificateIdentity = `https://github.com/${repository}/.github/workflows/release-artifacts.yml@refs/tags/${releaseTag}`; const targetRecords = {}; for (const target of targets) { diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 4ef821ebd..bb4c7b14d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -2,7 +2,9 @@ name: Release runtime artifacts on: push: - tags: [v2.0.0] + tags: + - v2.0.0 + - v2.0.0-rc.* workflow_dispatch: permissions: @@ -170,7 +172,7 @@ jobs: - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 - name: Create strict six-target manifest - run: node .github/scripts/create-runtime-release-manifest.mjs release-output "$GITHUB_REPOSITORY" 2.0.0 + run: node .github/scripts/create-runtime-release-manifest.mjs release-output "$GITHUB_REPOSITORY" 2.0.0 "$GITHUB_REF_NAME" - name: Checksum and keyless-sign manifest run: | @@ -188,11 +190,13 @@ jobs: - name: Publish immutable release assets env: GH_TOKEN: ${{ github.token }} + PRERELEASE_FLAG: ${{ contains(github.ref_name, '-rc.') && '--prerelease' || '' }} run: | set -euo pipefail gh release create "$GITHUB_REF_NAME" \ --verify-tag \ - --title "GStack runtime 2.0.0" \ + $PRERELEASE_FLAG \ + --title "GStack runtime $GITHUB_REF_NAME" \ --notes "Signed optional runtime artifacts for the six portable GStack skills." \ release-output/* shell: bash diff --git a/AGENTS.md b/AGENTS.md index 09bfc9be5..9f53c3b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ they contain no copied judgment. Representative mappings: Canonical installation is standards-based: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` Delegate host detection, placement, project/global scope, selected-skill diff --git a/CLAUDE.md b/CLAUDE.md index 1e1a9c033..56eeb62ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ mutation boundaries, recommendations, or voice. Compatibility files under Specification, and Full chain. Do not expose its internal aliases as more top-level modes. -Canonical install is `npx skills add time-attack/gstack`. Standard installers +Canonical install is `npx skills add time-attack/gstack/skills`. Standard installers own host placement and scope. Do not add host-specific install logic to the 2.0 path. Pure judgment must work without the optional `bin/gstack` runtime. The host-neutral `./setup` installs only that managed runtime/capability bundle; diff --git a/README.md b/README.md index ed00ef336..07793085c 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Install with the standard Agent Skills installer; it owns host detection, scope, destination paths, updates, removal, and selected-skill installation: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` That installs the six judgment skills. Install a subset with the installer's @@ -87,7 +87,7 @@ extension, connector, MCP, settings entry, profile, alternate host, or browser runtime is configured without consent, and `./setup` is never a GStack 2 browser setup command. -Install through `npx skills add time-attack/gstack`; do not clone the repository +Install through `npx skills add time-attack/gstack/skills`; do not clone the repository under `.agents/skills/gstack`. A standards installation exposes exactly one canonical QA skill at `.agents/skills/qa/SKILL.md`. The cloned compatibility tree contains legacy GStack 1 entry points and is not the GStack 2 install @@ -96,7 +96,7 @@ surface. The npm package is deliberately not the skill installer and does not contain the six skill tree or compiled browser/design/PDF payloads. It is the small host-neutral runtime control/bootstrap surface used by release tooling. New -users should install skills with `npx skills add time-attack/gstack`; optional +users should install skills with `npx skills add time-attack/gstack/skills`; optional capabilities are downloaded by a skill after consent. Public web research is optional. Context.dev is the only new external service, @@ -161,7 +161,7 @@ archive. It is not the GStack 2 installation or first-run path. ## Legacy 1.x host-specific install (compatibility only) Do not use this section for a new GStack 2 install. Use -`npx skills add time-attack/gstack` above. These instructions remain while old +`npx skills add time-attack/gstack/skills` above. These instructions remain while old commands are documented as opt-in aliases. They describe a 1.x release/tag: the current branch's `./setup` is runtime-only and will not perform the host-specific actions shown below. diff --git a/docs/gstack-2/ARCHITECTURE.md b/docs/gstack-2/ARCHITECTURE.md index f6956c18e..b17286b9f 100644 --- a/docs/gstack-2/ARCHITECTURE.md +++ b/docs/gstack-2/ARCHITECTURE.md @@ -85,7 +85,7 @@ judgment. This prevents an alias from drifting away from the canonical source. The standard Agent Skills tree is the unit of distribution: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` The installer—not GStack—owns host detection, project/global scope, destination diff --git a/docs/gstack-2/HOST-COMPATIBILITY.md b/docs/gstack-2/HOST-COMPATIBILITY.md index 01135207f..b8b108091 100644 --- a/docs/gstack-2/HOST-COMPATIBILITY.md +++ b/docs/gstack-2/HOST-COMPATIBILITY.md @@ -18,7 +18,7 @@ bundle. ## Canonical installation ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` The standards installer owns host detection, destination paths, project/global @@ -32,7 +32,7 @@ Examples supported by the installer interface: npx skills add time-attack/gstack/skills --skill qa # Installer-managed global scope -npx skills add time-attack/gstack -g +npx skills add time-attack/gstack/skills -g ``` Run `npx skills add --help` for the installed CLI version before scripting diff --git a/docs/gstack-2/JUDGMENT-PROVENANCE.json b/docs/gstack-2/JUDGMENT-PROVENANCE.json index 89043877e..39ba08820 100644 --- a/docs/gstack-2/JUDGMENT-PROVENANCE.json +++ b/docs/gstack-2/JUDGMENT-PROVENANCE.json @@ -4552,7 +4552,7 @@ "source_path": "gstack-upgrade/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "5402a1da3c857cbf50668085fce53172b72bba0d", - "normalized_render_sha256": "a913cf77f76c4d68c576a06190b498e3d8d3b60b85498a68f173b23a7f800828", + "normalized_render_sha256": "2c8cf9e505b7da27730bf646a33ee38e8092259f3335a524c71e57ba5b602541", "target": "skills/ship/references/legacy/gstack-upgrade.md", "disposition": "DUPLICATE_INFRASTRUCTURE", "overlays": [ diff --git a/docs/gstack-2/RELEASE-INTEGRITY.md b/docs/gstack-2/RELEASE-INTEGRITY.md index 4ae6659fc..e8d12a94d 100644 --- a/docs/gstack-2/RELEASE-INTEGRITY.md +++ b/docs/gstack-2/RELEASE-INTEGRITY.md @@ -5,10 +5,11 @@ GStack has two explicit version identities during the 2.0 migration: - `VERSION` and `package.json.version` are the repository/package release counter. They remain byte-equal and retain the existing four-slot format so the 1.x compatibility ship queue does not silently fail open. -- `package.json.gstack.runtimeVersion`, `runtime/index.js`, - `runtime/install.js`, and every standards-installed bootstrap declare the - managed-runtime protocol release `2.0.0`. The official artifact tag and - manifest use that value. +- `package.json.gstack.runtimeVersion`, `runtime/index.js`, and + `runtime/install.js` declare the managed-runtime protocol release `2.0.0`. + Each standards-installed bootstrap separately pins one immutable artifact + release tag. Candidate bootstraps use `v2.0.0-rc.N`; the manifest and bundle + remain runtime-compatible with `2.0.0`. Stable bootstraps use `v2.0.0`. They are intentionally different namespaces. CI fails if either identity drifts inside its own namespace. @@ -24,6 +25,14 @@ linux-arm64 linux-x64 (glibc) windows-arm64 windows-x64 ``` +Both `v2.0.0-rc.*` and `v2.0.0` tags use the same build, signing, manifest, +attestation, and smoke path. RC tags publish GitHub prereleases so the exact +fresh-machine production bootstrap can be exercised before the stable tag is +created. Runtime compatibility and release-channel identity are deliberately +separate: archive names and manifest `version` remain `2.0.0`, while URLs and +Sigstore certificate identity bind to the immutable RC or stable tag that +actually published them. + Each archive has one `gstack/` root and no symlinks. CI records an exact byte count and SHA-256, signs the archive keylessly with Cosign, emits a Sigstore bundle, and also creates a GitHub build-provenance attestation. The release diff --git a/docs/gstack-2/UPGRADE-AND-ROLLBACK.md b/docs/gstack-2/UPGRADE-AND-ROLLBACK.md index 3382e70c7..4dd91a3d3 100644 --- a/docs/gstack-2/UPGRADE-AND-ROLLBACK.md +++ b/docs/gstack-2/UPGRADE-AND-ROLLBACK.md @@ -10,7 +10,7 @@ Install the canonical Agent Skills source, then use that installer's tracked source and lock metadata for discovery, updates, and removal: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills npx skills update # interactive scope npx skills update -p # project installs only npx skills update -g # global installs only diff --git a/evals/parity/contracts/gstack-upgrade.json b/evals/parity/contracts/gstack-upgrade.json index 77be5642c..f77ce1217 100644 --- a/evals/parity/contracts/gstack-upgrade.json +++ b/evals/parity/contracts/gstack-upgrade.json @@ -9,7 +9,7 @@ "source_path": "gstack-upgrade/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "5402a1da3c857cbf50668085fce53172b72bba0d", - "normalized_render_sha256": "a913cf77f76c4d68c576a06190b498e3d8d3b60b85498a68f173b23a7f800828", + "normalized_render_sha256": "2c8cf9e505b7da27730bf646a33ee38e8092259f3335a524c71e57ba5b602541", "target": "skills/ship/references/legacy/gstack-upgrade.md", "overlays": [ 679 diff --git a/evals/parity/manifest.json b/evals/parity/manifest.json index 89043877e..39ba08820 100644 --- a/evals/parity/manifest.json +++ b/evals/parity/manifest.json @@ -4552,7 +4552,7 @@ "source_path": "gstack-upgrade/SKILL.md.tmpl", "base_sha": "bb57306d98c97011b0919c6132705a15b1579781", "blob_sha": "5402a1da3c857cbf50668085fce53172b72bba0d", - "normalized_render_sha256": "a913cf77f76c4d68c576a06190b498e3d8d3b60b85498a68f173b23a7f800828", + "normalized_render_sha256": "2c8cf9e505b7da27730bf646a33ee38e8092259f3335a524c71e57ba5b602541", "target": "skills/ship/references/legacy/gstack-upgrade.md", "disposition": "DUPLICATE_INFRASTRUCTURE", "overlays": [ diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/scripts/gstack2/generate-skill-tree.ts b/scripts/gstack2/generate-skill-tree.ts index 90159925e..4dfe43335 100644 --- a/scripts/gstack2/generate-skill-tree.ts +++ b/scripts/gstack2/generate-skill-tree.ts @@ -384,7 +384,7 @@ Print this replacement invocation, then dispatch to it exactly: \`${assignment.replacement}\` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved \`${assignment.source}\` module. If that dispatcher is not installed, tell the user to install it with \`npx skills add time-attack/gstack --skill ${assignment.tree}\`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved \`${assignment.source}\` module. If that dispatcher is not installed, tell the user to install it with \`npx skills add time-attack/gstack/skills --skill ${assignment.tree}\`. `); rows.push(`| \`/${assignment.source}\` | \`${assignment.replacement}\` | \`skills/${assignment.tree}/references/legacy/${assignment.source}.md\` |`); aliases.push({ @@ -483,7 +483,7 @@ function webContextContract(): string { '', 'Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent.', '', - 'Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state.', + 'Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state.', '', ].join('\n'); } diff --git a/scripts/gstack2/render-legacy.ts b/scripts/gstack2/render-legacy.ts index e3631c963..f2fb4f882 100644 --- a/scripts/gstack2/render-legacy.ts +++ b/scripts/gstack2/render-legacy.ts @@ -188,7 +188,7 @@ export function renderLegacyBody(source: string): string { */ function portLegacyText(value: string, source: string): string { if (source === 'gstack-upgrade') { - return `# Legacy upgrade compatibility\n\nThe 1.x host-directory detector, vendored-copy synchronizer, and destructive Git replacement blocks were duplicated installation infrastructure. GStack 2 delegates skill placement and updates to the standard Agent Skills installer and manages the optional shared runtime atomically.\n\n- Update selected skills with \`npx skills add time-attack/gstack\` using the user's existing project/global choice. Never infer or enroll a host.\n- Upgrade a complete local runtime package with \`gstack upgrade --source --version \`.\n- Roll back the runtime with \`gstack upgrade --rollback\`.\n- Run \`gstack doctor\` after either operation.\n- Do not reset, delete, move, or rewrite a host skill directory. Do not infer Context.dev choice or consent.\n\nThis compatibility module contains no specialist judgment; release readiness and rollback judgment remain in the preserved ship modules.\n`; + return `# Legacy upgrade compatibility\n\nThe 1.x host-directory detector, vendored-copy synchronizer, and destructive Git replacement blocks were duplicated installation infrastructure. GStack 2 delegates skill placement and updates to the standard Agent Skills installer and manages the optional shared runtime atomically.\n\n- Update selected skills with \`npx skills add time-attack/gstack/skills\` using the user's existing project/global choice. Never infer or enroll a host.\n- Upgrade a complete local runtime package with \`gstack upgrade --source --version \`.\n- Roll back the runtime with \`gstack upgrade --rollback\`.\n- Run \`gstack doctor\` after either operation.\n- Do not reset, delete, move, or rewrite a host skill directory. Do not infer Context.dev choice or consent.\n\nThis compatibility module contains no specialist judgment; release readiness and rollback judgment remain in the preserved ship modules.\n`; } let body = value; @@ -280,7 +280,7 @@ function portLegacyText(value: string, source: string): string { .replaceAll('$GSTACK_ROOT/lib/redact-audit-log.ts', '$GSTACK_BIN/gstack-redact-audit-log') .replaceAll('bun $GSTACK_BIN/gstack-redact-audit-log', '$GSTACK_BIN/gstack-redact-audit-log') .replaceAll('Disk paths stay `$GSTACK_ROOT/[skill-name]/SKILL.md`.', 'Resolve retired names through `references/COMPATIBILITY.md`; skill placement is installer-owned.') - .replaceAll('Tell the user: "Done. Each developer now runs: `cd $GSTACK_ROOT && ./setup --team`"', 'Tell the user: "Done. Each developer installs the selected canonical skills with `npx skills add time-attack/gstack`; the optional runtime remains user-scoped."'); + .replaceAll('Tell the user: "Done. Each developer now runs: `cd $GSTACK_ROOT && ./setup --team`"', 'Tell the user: "Done. Each developer installs the selected canonical skills with `npx skills add time-attack/gstack/skills`; the optional runtime remains user-scoped."'); body = body .replace(/_VENDORED="no"\nif \[ -d "\.agents\/skills\/gstack" \][\s\S]*?echo "VENDORED_GSTACK: \$_VENDORED"/g, '_VENDORED="managed-by-standard-installer"\necho "VENDORED_GSTACK: $_VENDORED"') diff --git a/scripts/gstack2/test-install-matrix.ts b/scripts/gstack2/test-install-matrix.ts index fa2977f46..f0a07e1ac 100644 --- a/scripts/gstack2/test-install-matrix.ts +++ b/scripts/gstack2/test-install-matrix.ts @@ -438,6 +438,15 @@ function verifyInstalledCase( const installedSkills = listInstalledSkills(targetRoot); const sortedExpected = [...expectedSkills].sort(); record(checks, `${id}.command`, command.exitCode === 0, `exit=${command.exitCode}; signal=${command.signal ?? 'none'}`); + if (sourceSkillSegments.length === 1 && sourceSkillSegments[0] === 'skills') { + const reported = Number(stripTerminalControls(command.stdout).match(/Found\s+(\d+)\s+skills?/)?.[1]); + record( + checks, + `${id}.public-discovery-count`, + reported === PUBLIC_SKILLS.length, + `expected installer to report 6 public skills; found ${Number.isFinite(reported) ? reported : '(unparsed)'}`, + ); + } record( checks, `${id}.selected-skills`, @@ -595,10 +604,10 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence const supportsRemoval = /remove\s+\[skills\]/.test(helpCommand.stdout) && /Remove Options/.test(helpCommand.stdout); const discoveryCommand = execute( - // Exercise the repository root exactly as the documented - // `npx skills add time-attack/gstack` path will after checkout. The - // curated projection alone could hide stray root-level SKILL.md files. - skillsCliArgv(npxExecutable, ['add', repoRoot, '--list']), + // Exercise the documented public source directly. The repository root + // also contains opt-in 1.x compatibility aliases; skills@1.5.19 counts + // those internal entries before applying an explicit --skill filter. + skillsCliArgv(npxExecutable, ['add', path.join(repoRoot, 'skills'), '--list']), controlProject, controlEnv, ); @@ -626,7 +635,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence entry, scope, sourceKind, - sourceArgument: sourceKind === 'source-symlink' ? sourceLink : sourceRoot, + sourceArgument: path.join(sourceKind === 'source-symlink' ? sourceLink : sourceRoot, 'skills'), sourceRoot, expectedSkills: PUBLIC_SKILLS, explicitSelection: false, @@ -643,7 +652,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence entry: cursor, scope: 'project', sourceKind: 'repository-root', - sourceArgument: repoRoot, + sourceArgument: path.join(repoRoot, 'skills'), sourceRoot: repoRoot, expectedSkills: COLLISION_SKILLS, explicitSelection: true, @@ -659,7 +668,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence entry: codex, scope: 'global', sourceKind: 'path-with-spaces', - sourceArgument: sourceRoot, + sourceArgument: path.join(sourceRoot, 'skills'), sourceRoot, expectedSkills: COLLISION_SKILLS, explicitSelection: true, @@ -675,7 +684,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence entry: openclaw, scope: 'project', sourceKind: 'path-with-spaces', - sourceArgument: sourceRoot, + sourceArgument: path.join(sourceRoot, 'skills'), sourceRoot, expectedSkills: ['ship'], explicitSelection: true, @@ -747,7 +756,7 @@ export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence platform: process.platform, architecture: process.arch, repositoryRoot: repoRoot, - sourceProjection: 'repository-root-and-canonical-projection', + sourceProjection: 'canonical-skills-subpath-and-opt-in-compatibility-root', cli: { executable: npxExecutable, version, diff --git a/skills/.compat/autoplan/SKILL.md b/skills/.compat/autoplan/SKILL.md index c92a62d0d..54e834b0b 100644 --- a/skills/.compat/autoplan/SKILL.md +++ b/skills/.compat/autoplan/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Full chain --module autoplan` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `autoplan` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `autoplan` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/benchmark-models/SKILL.md b/skills/.compat/benchmark-models/SKILL.md index 277111610..f8d9bdc4d 100644 --- a/skills/.compat/benchmark-models/SKILL.md +++ b/skills/.compat/benchmark-models/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module benchmark-models` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `benchmark-models` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `benchmark-models` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/benchmark/SKILL.md b/skills/.compat/benchmark/SKILL.md index df8b2db72..6ec810395 100644 --- a/skills/.compat/benchmark/SKILL.md +++ b/skills/.compat/benchmark/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module benchmark` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `benchmark` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `benchmark` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/browse/SKILL.md b/skills/.compat/browse/SKILL.md index 5006f106c..d6a30c39b 100644 --- a/skills/.compat/browse/SKILL.md +++ b/skills/.compat/browse/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module browse` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `browse` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `browse` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/canary/SKILL.md b/skills/.compat/canary/SKILL.md index 28e8f5795..be745fc93 100644 --- a/skills/.compat/canary/SKILL.md +++ b/skills/.compat/canary/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module canary` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `canary` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `canary` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/careful/SKILL.md b/skills/.compat/careful/SKILL.md index 449c57a1b..daf42d3fe 100644 --- a/skills/.compat/careful/SKILL.md +++ b/skills/.compat/careful/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Diagnose-only --module careful` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `careful` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `careful` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/.compat/claude/SKILL.md b/skills/.compat/claude/SKILL.md index 9e3474b4a..25c25892a 100644 --- a/skills/.compat/claude/SKILL.md +++ b/skills/.compat/claude/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$review --mode Deep --module claude` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `claude` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill review`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `claude` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill review`. diff --git a/skills/.compat/codex/SKILL.md b/skills/.compat/codex/SKILL.md index 02f838ac3..9be72dfaf 100644 --- a/skills/.compat/codex/SKILL.md +++ b/skills/.compat/codex/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$review --mode Deep --module codex` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `codex` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill review`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `codex` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill review`. diff --git a/skills/.compat/context-restore/SKILL.md b/skills/.compat/context-restore/SKILL.md index 2d00f75a0..2d1ea108f 100644 --- a/skills/.compat/context-restore/SKILL.md +++ b/skills/.compat/context-restore/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module context-restore` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `context-restore` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `context-restore` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/context-save/SKILL.md b/skills/.compat/context-save/SKILL.md index c068d6598..da1108a96 100644 --- a/skills/.compat/context-save/SKILL.md +++ b/skills/.compat/context-save/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module context-save` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `context-save` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `context-save` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/cso/SKILL.md b/skills/.compat/cso/SKILL.md index a149e6199..11b6ff823 100644 --- a/skills/.compat/cso/SKILL.md +++ b/skills/.compat/cso/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$review --mode Security --module cso` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `cso` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill review`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `cso` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill review`. diff --git a/skills/.compat/design-consultation/SKILL.md b/skills/.compat/design-consultation/SKILL.md index 8b9bab86c..689edccb9 100644 --- a/skills/.compat/design-consultation/SKILL.md +++ b/skills/.compat/design-consultation/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Generate --module design-consultation` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-consultation` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-consultation` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/design-html/SKILL.md b/skills/.compat/design-html/SKILL.md index a9ed55261..dec4610a0 100644 --- a/skills/.compat/design-html/SKILL.md +++ b/skills/.compat/design-html/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Implement --module design-html` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-html` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-html` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/design-review/SKILL.md b/skills/.compat/design-review/SKILL.md index f1ac0ccbf..b23371b89 100644 --- a/skills/.compat/design-review/SKILL.md +++ b/skills/.compat/design-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Implement --module design-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/design-shotgun/SKILL.md b/skills/.compat/design-shotgun/SKILL.md index 9fc8a3790..09fa43ec7 100644 --- a/skills/.compat/design-shotgun/SKILL.md +++ b/skills/.compat/design-shotgun/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Explore --module design-shotgun` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-shotgun` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `design-shotgun` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/devex-review/SKILL.md b/skills/.compat/devex-review/SKILL.md index ba2d8caf4..7b812c06d 100644 --- a/skills/.compat/devex-review/SKILL.md +++ b/skills/.compat/devex-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module devex-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `devex-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `devex-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/diagram/SKILL.md b/skills/.compat/diagram/SKILL.md index adf1fc52d..c3e138a91 100644 --- a/skills/.compat/diagram/SKILL.md +++ b/skills/.compat/diagram/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Generate --module diagram` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `diagram` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `diagram` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/document-generate/SKILL.md b/skills/.compat/document-generate/SKILL.md index 0cf7bcf52..13e74ed12 100644 --- a/skills/.compat/document-generate/SKILL.md +++ b/skills/.compat/document-generate/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module document-generate` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `document-generate` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `document-generate` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/document-release/SKILL.md b/skills/.compat/document-release/SKILL.md index a371a75cc..be9d54267 100644 --- a/skills/.compat/document-release/SKILL.md +++ b/skills/.compat/document-release/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module document-release` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `document-release` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `document-release` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/freeze/SKILL.md b/skills/.compat/freeze/SKILL.md index 0e578ab2d..d4b531262 100644 --- a/skills/.compat/freeze/SKILL.md +++ b/skills/.compat/freeze/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Diagnose-only --module freeze` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `freeze` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `freeze` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/.compat/gstack-upgrade/SKILL.md b/skills/.compat/gstack-upgrade/SKILL.md index 5b64a9080..534cad396 100644 --- a/skills/.compat/gstack-upgrade/SKILL.md +++ b/skills/.compat/gstack-upgrade/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module gstack-upgrade` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `gstack-upgrade` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `gstack-upgrade` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/gstack/SKILL.md b/skills/.compat/gstack/SKILL.md index f4c2bce35..449b4f746 100644 --- a/skills/.compat/gstack/SKILL.md +++ b/skills/.compat/gstack/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module gstack` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `gstack` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `gstack` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/guard/SKILL.md b/skills/.compat/guard/SKILL.md index aebadfe6d..2e3a4e29a 100644 --- a/skills/.compat/guard/SKILL.md +++ b/skills/.compat/guard/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Diagnose-only --module guard` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `guard` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `guard` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/.compat/health/SKILL.md b/skills/.compat/health/SKILL.md index dc45be44b..069829932 100644 --- a/skills/.compat/health/SKILL.md +++ b/skills/.compat/health/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$review --mode Deep --module health` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `health` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill review`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `health` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill review`. diff --git a/skills/.compat/investigate/SKILL.md b/skills/.compat/investigate/SKILL.md index 0615b2ec8..570c820c9 100644 --- a/skills/.compat/investigate/SKILL.md +++ b/skills/.compat/investigate/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Diagnose-only --module investigate` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `investigate` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `investigate` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/.compat/ios-clean/SKILL.md b/skills/.compat/ios-clean/SKILL.md index 12f55f270..f55eb9125 100644 --- a/skills/.compat/ios-clean/SKILL.md +++ b/skills/.compat/ios-clean/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module ios-clean` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-clean` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-clean` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/ios-design-review/SKILL.md b/skills/.compat/ios-design-review/SKILL.md index 86422bb3f..0a70dc4b5 100644 --- a/skills/.compat/ios-design-review/SKILL.md +++ b/skills/.compat/ios-design-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Critique --module ios-design-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/ios-fix/SKILL.md b/skills/.compat/ios-fix/SKILL.md index af42c4cc3..a8548ba97 100644 --- a/skills/.compat/ios-fix/SKILL.md +++ b/skills/.compat/ios-fix/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Fix --module ios-fix` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-fix` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-fix` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/.compat/ios-qa/SKILL.md b/skills/.compat/ios-qa/SKILL.md index b9c8584c0..e5c83c655 100644 --- a/skills/.compat/ios-qa/SKILL.md +++ b/skills/.compat/ios-qa/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module ios-qa` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-qa` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-qa` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/ios-sync/SKILL.md b/skills/.compat/ios-sync/SKILL.md index 59e19f9ec..316fd1090 100644 --- a/skills/.compat/ios-sync/SKILL.md +++ b/skills/.compat/ios-sync/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module ios-sync` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-sync` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `ios-sync` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/land-and-deploy/SKILL.md b/skills/.compat/land-and-deploy/SKILL.md index ebd525b26..cb0317fd3 100644 --- a/skills/.compat/land-and-deploy/SKILL.md +++ b/skills/.compat/land-and-deploy/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Land --module land-and-deploy` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `land-and-deploy` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `land-and-deploy` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/landing-report/SKILL.md b/skills/.compat/landing-report/SKILL.md index 42dfd68bb..378b8a38a 100644 --- a/skills/.compat/landing-report/SKILL.md +++ b/skills/.compat/landing-report/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Prepare --module landing-report` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `landing-report` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `landing-report` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/learn/SKILL.md b/skills/.compat/learn/SKILL.md index 564b2d1d9..a3a68056a 100644 --- a/skills/.compat/learn/SKILL.md +++ b/skills/.compat/learn/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module learn` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `learn` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `learn` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/make-pdf/SKILL.md b/skills/.compat/make-pdf/SKILL.md index b6f77097f..5e40076f7 100644 --- a/skills/.compat/make-pdf/SKILL.md +++ b/skills/.compat/make-pdf/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Generate --module make-pdf` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `make-pdf` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `make-pdf` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/office-hours/SKILL.md b/skills/.compat/office-hours/SKILL.md index 44272ff0c..8619388ae 100644 --- a/skills/.compat/office-hours/SKILL.md +++ b/skills/.compat/office-hours/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module office-hours` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `office-hours` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `office-hours` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/open-gstack-browser/SKILL.md b/skills/.compat/open-gstack-browser/SKILL.md index 0d8cbfca4..01717a212 100644 --- a/skills/.compat/open-gstack-browser/SKILL.md +++ b/skills/.compat/open-gstack-browser/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module open-gstack-browser` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `open-gstack-browser` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `open-gstack-browser` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/pair-agent/SKILL.md b/skills/.compat/pair-agent/SKILL.md index 862827242..4ef10e5ba 100644 --- a/skills/.compat/pair-agent/SKILL.md +++ b/skills/.compat/pair-agent/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module pair-agent` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `pair-agent` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `pair-agent` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/plan-ceo-review/SKILL.md b/skills/.compat/plan-ceo-review/SKILL.md index d56d824ff..a2dfbbca7 100644 --- a/skills/.compat/plan-ceo-review/SKILL.md +++ b/skills/.compat/plan-ceo-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Product --module plan-ceo-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-ceo-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-ceo-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/plan-design-review/SKILL.md b/skills/.compat/plan-design-review/SKILL.md index bb1b3e3ea..0c0d143a1 100644 --- a/skills/.compat/plan-design-review/SKILL.md +++ b/skills/.compat/plan-design-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$design --mode Critique --module plan-design-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill design`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-design-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill design`. diff --git a/skills/.compat/plan-devex-review/SKILL.md b/skills/.compat/plan-devex-review/SKILL.md index 777ac1999..c4c0e9a51 100644 --- a/skills/.compat/plan-devex-review/SKILL.md +++ b/skills/.compat/plan-devex-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode DX --module plan-devex-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-devex-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-devex-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/plan-eng-review/SKILL.md b/skills/.compat/plan-eng-review/SKILL.md index 4e9c800ea..bc7e459b3 100644 --- a/skills/.compat/plan-eng-review/SKILL.md +++ b/skills/.compat/plan-eng-review/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Engineering --module plan-eng-review` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-eng-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-eng-review` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/plan-tune/SKILL.md b/skills/.compat/plan-tune/SKILL.md index dd2bc44db..ae932f0fd 100644 --- a/skills/.compat/plan-tune/SKILL.md +++ b/skills/.compat/plan-tune/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module plan-tune` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-tune` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `plan-tune` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/qa-only/SKILL.md b/skills/.compat/qa-only/SKILL.md index 7cfa6bff3..e53b2b25d 100644 --- a/skills/.compat/qa-only/SKILL.md +++ b/skills/.compat/qa-only/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module qa-only` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `qa-only` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `qa-only` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/retro/SKILL.md b/skills/.compat/retro/SKILL.md index eb1adf538..fb50c4015 100644 --- a/skills/.compat/retro/SKILL.md +++ b/skills/.compat/retro/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module retro` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `retro` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `retro` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/scrape/SKILL.md b/skills/.compat/scrape/SKILL.md index 54c69d5f2..b244708b5 100644 --- a/skills/.compat/scrape/SKILL.md +++ b/skills/.compat/scrape/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module scrape` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `scrape` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `scrape` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/setup-browser-cookies/SKILL.md b/skills/.compat/setup-browser-cookies/SKILL.md index 861dc9b5c..90b724e48 100644 --- a/skills/.compat/setup-browser-cookies/SKILL.md +++ b/skills/.compat/setup-browser-cookies/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module setup-browser-cookies` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-browser-cookies` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-browser-cookies` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/setup-deploy/SKILL.md b/skills/.compat/setup-deploy/SKILL.md index 1f48a55fb..587eab4a7 100644 --- a/skills/.compat/setup-deploy/SKILL.md +++ b/skills/.compat/setup-deploy/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$ship --mode Deploy --module setup-deploy` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-deploy` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill ship`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-deploy` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill ship`. diff --git a/skills/.compat/setup-gbrain/SKILL.md b/skills/.compat/setup-gbrain/SKILL.md index f120381ef..350277576 100644 --- a/skills/.compat/setup-gbrain/SKILL.md +++ b/skills/.compat/setup-gbrain/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module setup-gbrain` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-gbrain` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `setup-gbrain` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/skillify/SKILL.md b/skills/.compat/skillify/SKILL.md index 7400c78ef..58ea41f85 100644 --- a/skills/.compat/skillify/SKILL.md +++ b/skills/.compat/skillify/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$qa --mode Report --module skillify` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `skillify` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill qa`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `skillify` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill qa`. diff --git a/skills/.compat/spec/SKILL.md b/skills/.compat/spec/SKILL.md index ecfdd7e63..1ab212d15 100644 --- a/skills/.compat/spec/SKILL.md +++ b/skills/.compat/spec/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Specification --module spec` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `spec` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `spec` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/sync-gbrain/SKILL.md b/skills/.compat/sync-gbrain/SKILL.md index 71aac6ec5..d360fe6e3 100644 --- a/skills/.compat/sync-gbrain/SKILL.md +++ b/skills/.compat/sync-gbrain/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$plan --mode Discovery --module sync-gbrain` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `sync-gbrain` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill plan`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `sync-gbrain` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill plan`. diff --git a/skills/.compat/unfreeze/SKILL.md b/skills/.compat/unfreeze/SKILL.md index 81b0b94c0..b8902c999 100644 --- a/skills/.compat/unfreeze/SKILL.md +++ b/skills/.compat/unfreeze/SKILL.md @@ -12,4 +12,4 @@ Print this replacement invocation, then dispatch to it exactly: `$debug --mode Diagnose-only --module unfreeze` -Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `unfreeze` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack --skill debug`. +Do not reproduce or summarize the retired specialist here. The canonical dispatcher must load its preserved `unfreeze` module. If that dispatcher is not installed, tell the user to install it with `npx skills add time-attack/gstack/skills --skill debug`. diff --git a/skills/debug/references/WEB-CONTEXT.md b/skills/debug/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/debug/references/WEB-CONTEXT.md +++ b/skills/debug/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/skills/design/references/WEB-CONTEXT.md b/skills/design/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/design/references/WEB-CONTEXT.md +++ b/skills/design/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/skills/plan/references/WEB-CONTEXT.md b/skills/plan/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/plan/references/WEB-CONTEXT.md +++ b/skills/plan/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/skills/qa/references/WEB-CONTEXT.md b/skills/qa/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/qa/references/WEB-CONTEXT.md +++ b/skills/qa/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/skills/review/references/WEB-CONTEXT.md b/skills/review/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/review/references/WEB-CONTEXT.md +++ b/skills/review/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/skills/ship/references/WEB-CONTEXT.md b/skills/ship/references/WEB-CONTEXT.md index 81bbf5d08..2ba9cab1e 100644 --- a/skills/ship/references/WEB-CONTEXT.md +++ b/skills/ship/references/WEB-CONTEXT.md @@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent. -Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state. +Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state. diff --git a/skills/ship/references/legacy/gstack-upgrade.md b/skills/ship/references/legacy/gstack-upgrade.md index 084607f92..fc7d71f6a 100644 --- a/skills/ship/references/legacy/gstack-upgrade.md +++ b/skills/ship/references/legacy/gstack-upgrade.md @@ -1,5 +1,5 @@ - + @@ -7,7 +7,7 @@ The 1.x host-directory detector, vendored-copy synchronizer, and destructive Git replacement blocks were duplicated installation infrastructure. GStack 2 delegates skill placement and updates to the standard Agent Skills installer and manages the optional shared runtime atomically. -- Update selected skills with `npx skills add time-attack/gstack` using the user's existing project/global choice. Never infer or enroll a host. +- Update selected skills with `npx skills add time-attack/gstack/skills` using the user's existing project/global choice. Never infer or enroll a host. - Upgrade a complete local runtime package with `gstack upgrade --source --version `. - Roll back the runtime with `gstack upgrade --rollback`. - Run `gstack doctor` after either operation. diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index 84370d441..9419f2a87 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -13,8 +13,12 @@ import { fileURLToPath } from "node:url"; 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.1"; export const OFFICIAL_MANIFEST_URL = - `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`; + `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"]); const CAPABILITY_DEPENDENCIES = Object.freeze({ browser: Object.freeze([]), @@ -47,9 +51,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([ "objects.githubusercontent.com", "release-assets.githubusercontent.com", ]); -const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`; +const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`; const OFFICIAL_CERTIFICATE_IDENTITY = - `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`; + `https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`; const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com"; export async function main(argv = process.argv.slice(2), options = {}) { @@ -90,7 +94,9 @@ export async function main(argv = process.argv.slice(2), options = {}) { ); const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL; assertOfficialUrl(manifestUrl, { manifest: true }); - const manifest = await fetchJson(fetch_, manifestUrl); + const manifest = await fetchJson(fetch_, manifestUrl, { + official: manifestUrl === OFFICIAL_MANIFEST_URL, + }); 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); @@ -300,10 +306,18 @@ function sha256File(file) { }); } -async function fetchJson(fetch_, url) { +async function fetchJson(fetch_, url, options = {}) { const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" }); assertFinalDownloadUrl(response.url || url); - if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED"); + if (!response.ok) { + if (options.official && response.status === 404) { + throw bootstrapError( + `Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`, + "BOOTSTRAP_RELEASE_UNAVAILABLE", + ); + } + throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED"); + } const value = await response.json(); if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID"); return value; diff --git a/test/gstack2-installation.test.ts b/test/gstack2-installation.test.ts index 5b72f96cc..fa60f26e9 100644 --- a/test/gstack2-installation.test.ts +++ b/test/gstack2-installation.test.ts @@ -29,6 +29,13 @@ afterEach(() => { }); describe('GStack 2 standard installer surface', () => { + test('documents the canonical public subpath instead of the legacy-bearing repository root', () => { + for (const file of ['AGENTS.md', 'CLAUDE.md', 'README.md']) { + const content = fs.readFileSync(path.join(DEFAULT_REPO_ROOT, file), 'utf8'); + expect(content, file).toContain('npx skills add time-attack/gstack/skills'); + } + }); + test('publishes exactly six uniquely named canonical skills', () => { const result = inspectRepository(DEFAULT_REPO_ROOT); diff --git a/test/gstack2-runtime-release-channel.test.ts b/test/gstack2-runtime-release-channel.test.ts new file mode 100644 index 000000000..53628ede0 --- /dev/null +++ b/test/gstack2-runtime-release-channel.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawnSync } from "node:child_process"; + +const ROOT = path.resolve(import.meta.dir, ".."); +const SCRIPT = path.join(ROOT, ".github", "scripts", "create-runtime-release-manifest.mjs"); +const WORKFLOW = path.join(ROOT, ".github", "workflows", "release-artifacts.yml"); +const TARGETS = ["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]; +const COMMON = ["core", "browser-code", "browser-headless", "browser-visible", "design", "diagram", "pdf"]; + +async function stageFixture(directory: string) { + for (const target of TARGETS) { + const components = [...COMMON, ...(target.startsWith("darwin-") ? ["ios"] : [])]; + for (const component of components) { + const name = `gstack-runtime-2.0.0-${target}-${component}.tar.gz`; + await fs.writeFile(path.join(directory, name), "fixture\n"); + await fs.writeFile(path.join(directory, `${name}.sha256`), `${"a".repeat(64)} ${name}\n`); + await fs.writeFile(path.join(directory, `${name}.sigstore.json`), "{}\n"); + } + } +} + +describe("GStack runtime release channel", () => { + test("release candidates retain runtime compatibility while binding URLs and signatures to the RC tag", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-runtime-release-channel-")); + try { + await stageFixture(directory); + const result = spawnSync(process.execPath, [SCRIPT, directory, "time-attack/gstack", "2.0.0", "v2.0.0-rc.1"], { + encoding: "utf8", + }); + expect(result.status).toBe(0); + const manifest = JSON.parse(await fs.readFile(path.join(directory, "gstack-runtime-manifest.json"), "utf8")); + expect(manifest.version).toBe("2.0.0"); + expect(manifest.targets["darwin-arm64"].components["browser-visible"]).toMatchObject({ + url: "https://github.com/time-attack/gstack/releases/download/v2.0.0-rc.1/gstack-runtime-2.0.0-darwin-arm64-browser-visible.tar.gz", + certificateIdentity: "https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v2.0.0-rc.1", + }); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + test("release manifest generation rejects non-runtime tags before reading artifacts", async () => { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-runtime-invalid-tag-")); + try { + const result = spawnSync(process.execPath, [SCRIPT, directory, "time-attack/gstack", "2.0.0", "main"], { + encoding: "utf8", + }); + expect(result.status).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("Invalid runtime release tag"); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } + }); + + test("release workflow publishes both RC and stable tags through the same signed manifest path", async () => { + const workflow = await fs.readFile(WORKFLOW, "utf8"); + expect(workflow).toContain("v2.0.0-rc.*"); + expect(workflow).toContain('2.0.0 "$GITHUB_REF_NAME"'); + expect(workflow).toContain("PRERELEASE_FLAG:"); + expect(workflow).toContain("--prerelease"); + expect(workflow).toContain('gh release create "$GITHUB_REF_NAME"'); + }); +}); diff --git a/test/gstack2-runtime-setup-ux.test.ts b/test/gstack2-runtime-setup-ux.test.ts index b4880109b..4fca7bf4b 100644 --- a/test/gstack2-runtime-setup-ux.test.ts +++ b/test/gstack2-runtime-setup-ux.test.ts @@ -10,6 +10,7 @@ import { setupRuntime } from "../runtime/setup.js"; import { bashCandidates, resolveBashCommand } from "../runtime/tooling.js"; import { BOOTSTRAP_SCHEMA_VERSION, + BOOTSTRAP_RELEASE_TAG, BOOTSTRAP_RUNTIME_VERSION, CAPABILITY_COMPONENTS, COMPONENT_DEPENDENCIES, @@ -27,7 +28,7 @@ function officialManifestFixture(target: string, customize?: (component: string, .filter((component) => component !== "ios" || target.startsWith("darwin-")) .map((component) => { const artifact: Record = { - url: `https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/${component}.tar.gz`, + url: `https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/${component}.tar.gz`, sha256: "0".repeat(64), bytes: 8, format: "tar.gz", @@ -296,6 +297,31 @@ describe("GStack runtime setup UX", () => { expect(fetches).toBe(0); }); + test("missing official release stops before install with an actionable immutable-tag error", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-missing-release-")); + const output = capture(); + let calls = 0; + try { + expect(await bootstrapMain([ + "preview", "--capability", "browser-visible", "--home", path.join(root, "home"), + ], { + stdout: output.stream, + stderr: output.stream, + fetch: async (url: string) => { + calls += 1; + return { ok: false, status: 404, url }; + }, + })).toBe(1); + expect(calls).toBe(1); + expect(output.value()).toContain(`Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published`); + expect(output.value()).toContain(OFFICIAL_MANIFEST_URL); + expect(output.value()).toContain("No files were downloaded or installed"); + expect(await fs.readdir(root)).toEqual([]); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + test("bootstrap executes through a symlinked or aliased filesystem path", async () => { if (process.platform === "win32") return; const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-link-")); From dbf94804ad160d71f07548fb28e9ea9d1c1c97af Mon Sep 17 00:00:00 2001 From: Sinabina Date: Mon, 20 Jul 2026 16:58:31 -0700 Subject: [PATCH 2/7] fix: make runtime release six-platform safe Use an allowed local file URL for the browser smoke test, centralize keyless archive signing after native builds so Windows ARM64 does not require an unavailable Cosign binary, and advance the immutable bootstrap channel to RC2. --- .github/workflows/release-artifacts.yml | 21 +++++++++---------- runtime/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- .../references/support/runtime-bootstrap.mjs | 2 +- test/gstack2-runtime-release-channel.test.ts | 8 +++++++ test/release-hardening.test.ts | 13 +++++++++--- 10 files changed, 35 insertions(+), 21 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index bb4c7b14d..b6aab910d 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -53,8 +53,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.23.1 - - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 - - name: Install frozen dependencies run: bun install --frozen-lockfile --ignore-scripts shell: bash @@ -114,8 +112,9 @@ jobs: "$GSTACK_HOME/bin/browse" stop >/dev/null 2>&1 || true } trap browser_cleanup EXIT + smoke_url=$(node -e 'const fs=require("fs"),p=require("path").join(process.env.RUNNER_TEMP,"gstack-runtime-smoke.html");fs.writeFileSync(p,"GStack runtime smoke\n");process.stdout.write(require("url").pathToFileURL(p).href)') PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ - "$GSTACK_HOME/bin/browse" goto about:blank + "$GSTACK_HOME/bin/browse" goto "$smoke_url" PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ "$GSTACK_HOME/bin/browse" status browser_cleanup @@ -132,14 +131,6 @@ jobs: done shell: bash - - name: Keyless-sign component archives - run: | - set -euo pipefail - for archive in release-output/*.tar.gz; do - cosign sign-blob --yes --bundle "$archive.sigstore.json" "$archive" - done - shell: bash - - name: Attest component archive provenance uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 with: @@ -171,6 +162,14 @@ jobs: merge-multiple: true - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 + - name: Keyless-sign component archives + run: | + set -euo pipefail + for archive in release-output/*.tar.gz; do + cosign sign-blob --yes --bundle "$archive.sigstore.json" "$archive" + done + shell: bash + - name: Create strict six-target manifest run: node .github/scripts/create-runtime-release-manifest.mjs release-output "$GITHUB_REPOSITORY" 2.0.0 "$GITHUB_REF_NAME" diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index 9419f2a87..8270ed190 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.1"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.2"; 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"]); diff --git a/test/gstack2-runtime-release-channel.test.ts b/test/gstack2-runtime-release-channel.test.ts index 53628ede0..964424ad3 100644 --- a/test/gstack2-runtime-release-channel.test.ts +++ b/test/gstack2-runtime-release-channel.test.ts @@ -57,10 +57,18 @@ describe("GStack runtime release channel", () => { test("release workflow publishes both RC and stable tags through the same signed manifest path", async () => { const workflow = await fs.readFile(WORKFLOW, "utf8"); + const buildSection = workflow.slice(workflow.indexOf(" build:"), workflow.indexOf("\n manifest:")); + const manifestSection = workflow.slice(workflow.indexOf("\n manifest:")); expect(workflow).toContain("v2.0.0-rc.*"); expect(workflow).toContain('2.0.0 "$GITHUB_REF_NAME"'); expect(workflow).toContain("PRERELEASE_FLAG:"); expect(workflow).toContain("--prerelease"); expect(workflow).toContain('gh release create "$GITHUB_REF_NAME"'); + expect(workflow).toContain("pathToFileURL(p).href"); + expect(workflow).not.toContain("goto about:blank"); + expect(buildSection).not.toContain("sigstore/cosign-installer"); + expect(manifestSection).toContain("sigstore/cosign-installer"); + expect(manifestSection.indexOf("Keyless-sign component archives")) + .toBeLessThan(manifestSection.indexOf("Create strict six-target manifest")); }); }); diff --git a/test/release-hardening.test.ts b/test/release-hardening.test.ts index 529683957..bde6c9351 100644 --- a/test/release-hardening.test.ts +++ b/test/release-hardening.test.ts @@ -53,14 +53,20 @@ 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('chromium.launch({ headless: true, channel: "chromium" })'); + // 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. + expect(workflow).toMatch(/for \(const options of \[\{ headless: true \}, \{ headless: true, channel: ["']chromium["'] \}\]\)/); + expect(workflow).toContain("chromium.launch(options)"); + expect(workflow).toContain("await browser.close()"); expect(workflow).not.toContain("--with-deps"); expect(workflow).toContain(".gstack-runtime-tools/bun"); expect(workflow).toContain('"$GSTACK_HOME/bin/bun" --version'); expect(workflow).toContain("BUN-LICENSE-1.3.14.md"); expect(workflow).toContain("command -v bun"); expect(workflow).toContain("GSTACK_NODE=\"$node_command\""); - expect(workflow).toContain("goto about:blank"); + expect(workflow).toContain("pathToFileURL(p).href"); + expect(workflow).not.toContain("goto about:blank"); 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"'); @@ -80,7 +86,8 @@ describe("release and CI hardening", () => { expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)'); const browser = read("browse/src/cli.ts"); expect(browser).toContain("Every installed/compiled client must use the adjacent Node-compatible daemon"); - expect(browser).toContain("if (IS_COMPILED && !NODE_SERVER_SCRIPT)"); + expect(browser).toContain("export function resolveServerLaunchTarget("); + expect(browser).toContain("server-node.mjs not found. Rebuild the managed browser runtime"); }); test("Windows setup lane installs, doctors, and uninstalls rather than only building", () => { From f7d44a4e5a54ce457652200964a0d409eef9e1d6 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 10:43:57 -0700 Subject: [PATCH 3/7] fix: keep release smoke fixture inside project Create the browser smoke page under GITHUB_WORKSPACE so the runtime's local-file trust boundary permits it on every runner, and advance the immutable bootstrap channel to RC3. --- .github/workflows/release-artifacts.yml | 2 +- runtime/runtime-bootstrap.mjs | 2 +- skills/debug/references/support/runtime-bootstrap.mjs | 2 +- skills/design/references/support/runtime-bootstrap.mjs | 2 +- skills/plan/references/support/runtime-bootstrap.mjs | 2 +- skills/qa/references/support/runtime-bootstrap.mjs | 2 +- skills/review/references/support/runtime-bootstrap.mjs | 2 +- skills/ship/references/support/runtime-bootstrap.mjs | 2 +- test/gstack2-runtime-release-channel.test.ts | 1 + test/release-hardening.test.ts | 1 + 10 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index b6aab910d..4e993d7f0 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -112,7 +112,7 @@ jobs: "$GSTACK_HOME/bin/browse" stop >/dev/null 2>&1 || true } trap browser_cleanup EXIT - smoke_url=$(node -e 'const fs=require("fs"),p=require("path").join(process.env.RUNNER_TEMP,"gstack-runtime-smoke.html");fs.writeFileSync(p,"GStack runtime smoke\n");process.stdout.write(require("url").pathToFileURL(p).href)') + smoke_url=$(node -e 'const fs=require("fs"),p=require("path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html");fs.writeFileSync(p,"GStack runtime smoke\n");process.stdout.write(require("url").pathToFileURL(p).href)') PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ "$GSTACK_HOME/bin/browse" goto "$smoke_url" PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index 8270ed190..2f5c9dcf2 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.2"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.3"; 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"]); diff --git a/test/gstack2-runtime-release-channel.test.ts b/test/gstack2-runtime-release-channel.test.ts index 964424ad3..60d3a9205 100644 --- a/test/gstack2-runtime-release-channel.test.ts +++ b/test/gstack2-runtime-release-channel.test.ts @@ -65,6 +65,7 @@ describe("GStack runtime release channel", () => { expect(workflow).toContain("--prerelease"); expect(workflow).toContain('gh release create "$GITHUB_REF_NAME"'); 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(buildSection).not.toContain("sigstore/cosign-installer"); expect(manifestSection).toContain("sigstore/cosign-installer"); diff --git a/test/release-hardening.test.ts b/test/release-hardening.test.ts index bde6c9351..1a2e87c21 100644 --- a/test/release-hardening.test.ts +++ b/test/release-hardening.test.ts @@ -66,6 +66,7 @@ describe("release and CI hardening", () => { expect(workflow).toContain("command -v bun"); expect(workflow).toContain("GSTACK_NODE=\"$node_command\""); 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"); const manifest = read(".github/scripts/create-runtime-release-manifest.mjs"); expect(manifest).toContain("bytes: stat.size"); From bec9b9bea45bce052de2e7ea892a9b0f4a85f429 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 11:02:26 -0700 Subject: [PATCH 4/7] fix: exclude Windows validator from runtime artifacts --- runtime/install.js | 4 ++++ runtime/runtime-bootstrap.mjs | 2 +- skills/debug/references/support/runtime-bootstrap.mjs | 2 +- .../design/references/support/runtime-bootstrap.mjs | 2 +- skills/plan/references/support/runtime-bootstrap.mjs | 2 +- skills/qa/references/support/runtime-bootstrap.mjs | 2 +- .../review/references/support/runtime-bootstrap.mjs | 2 +- skills/ship/references/support/runtime-bootstrap.mjs | 2 +- test/gstack2-runtime-install.test.ts | 11 +++++++++++ 9 files changed, 22 insertions(+), 7 deletions(-) diff --git a/runtime/install.js b/runtime/install.js index 8d3dbe2e6..ea62fb5c2 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -1049,6 +1049,10 @@ export function runtimeReleaseComponentForPath(value) { const relative = component.slice(browserRoot.length); if (relative === ".links" || relative.startsWith(".links/")) return null; const top = relative.split("/")[0]; + // Playwright downloads winldd on Windows only to validate browser DLL + // dependencies during installation. It is not required to launch Chromium + // from the completed managed runtime, so keep it out of release artifacts. + if (/^winldd-\d/.test(top)) return null; if (top.startsWith("chromium_headless_shell-") || top.startsWith("ffmpeg-")) return "browser-headless"; if (/^chromium-\d/.test(top)) return "browser-visible"; throw installError(`Unknown managed browser payload path: ${component}`, "INSTALL_BROWSER_PAYLOAD_INVALID"); diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index 2f5c9dcf2..c25d572f2 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.3"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.4"; 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"]); diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index fd2c71103..77b84b578 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -14,6 +14,7 @@ import { defaultBunBuilder, installManagedRuntime, normalizeManagedBrowserTree, + runtimeReleaseComponentForPath, runtimeNativePackagePaths, uninstallManagedRuntime, runCommand, @@ -31,6 +32,16 @@ const REPO_ROOT = path.resolve(import.meta.dir, ".."); const FULL_RUNTIME_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 30_000; describe("GStack 2 managed runtime installer", () => { + test("release staging excludes Playwright bookkeeping and Windows dependency validators", () => { + expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/.links/example")).toBeNull(); + expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/winldd-1007/DEPENDENCIES_VALIDATED")).toBeNull(); + expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/winldd-1007/winldd.exe")).toBeNull(); + expect(runtimeReleaseComponentForPath(".gstack-runtime-browsers/chromium_headless_shell-1208/chrome.exe")) + .toBe("browser-headless"); + expect(() => runtimeReleaseComponentForPath(".gstack-runtime-browsers/unknown-1/payload")) + .toThrow("Unknown managed browser payload path"); + }); + test("browser link normalization accepts internal macOS-style links and rejects escape graphs", async () => { if (process.platform === "win32") return; const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-links-")); From 75b3576670794f1dc201f1de537da3078a822d3d Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 11:07:36 -0700 Subject: [PATCH 5/7] fix: use portable archive paths on Windows --- .github/workflows/release-artifacts.yml | 7 +++++-- runtime/runtime-bootstrap.mjs | 2 +- skills/debug/references/support/runtime-bootstrap.mjs | 2 +- skills/design/references/support/runtime-bootstrap.mjs | 2 +- skills/plan/references/support/runtime-bootstrap.mjs | 2 +- skills/qa/references/support/runtime-bootstrap.mjs | 2 +- skills/review/references/support/runtime-bootstrap.mjs | 2 +- skills/ship/references/support/runtime-bootstrap.mjs | 2 +- test/gstack2-runtime-release-channel.test.ts | 3 +++ 9 files changed, 15 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 4e993d7f0..e2c53d707 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -120,12 +120,15 @@ jobs: browser_cleanup trap - EXIT stage="$RUNNER_TEMP/runtime-components" - mkdir -p "$stage" "$GITHUB_WORKSPACE/release-output" + # GNU tar treats a Windows drive colon in an archive path as a + # remote-host separator. Keep archive output in Git Bash's POSIX view. + release_dir="$(pwd -P)/release-output" + mkdir -p "$stage" "$release_dir" node .github/scripts/stage-runtime-components.mjs "$active" "$stage" for component_dir in "$stage"/*; do test -d "$component_dir" || continue component=$(basename "$component_dir") - archive="$GITHUB_WORKSPACE/release-output/gstack-runtime-2.0.0-$TARGET-$component.tar.gz" + archive="$release_dir/gstack-runtime-2.0.0-$TARGET-$component.tar.gz" tar -czf "$archive" -C "$component_dir" gstack node -e 'const fs=require("fs"),c=require("crypto"),p=process.argv[1];const b=fs.readFileSync(p);fs.writeFileSync(p+".sha256",c.createHash("sha256").update(b).digest("hex")+" "+require("path").basename(p)+"\n")' "$archive" done diff --git a/runtime/runtime-bootstrap.mjs b/runtime/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/runtime/runtime-bootstrap.mjs +++ b/runtime/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/debug/references/support/runtime-bootstrap.mjs b/skills/debug/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/debug/references/support/runtime-bootstrap.mjs +++ b/skills/debug/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/design/references/support/runtime-bootstrap.mjs b/skills/design/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/design/references/support/runtime-bootstrap.mjs +++ b/skills/design/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/plan/references/support/runtime-bootstrap.mjs b/skills/plan/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/plan/references/support/runtime-bootstrap.mjs +++ b/skills/plan/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/qa/references/support/runtime-bootstrap.mjs b/skills/qa/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/qa/references/support/runtime-bootstrap.mjs +++ b/skills/qa/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/review/references/support/runtime-bootstrap.mjs b/skills/review/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/review/references/support/runtime-bootstrap.mjs +++ b/skills/review/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/skills/ship/references/support/runtime-bootstrap.mjs b/skills/ship/references/support/runtime-bootstrap.mjs index c25d572f2..75c1a91f1 100644 --- a/skills/ship/references/support/runtime-bootstrap.mjs +++ b/skills/ship/references/support/runtime-bootstrap.mjs @@ -16,7 +16,7 @@ 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.4"; +export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5"; 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"]); diff --git a/test/gstack2-runtime-release-channel.test.ts b/test/gstack2-runtime-release-channel.test.ts index 60d3a9205..0a30e8b00 100644 --- a/test/gstack2-runtime-release-channel.test.ts +++ b/test/gstack2-runtime-release-channel.test.ts @@ -66,6 +66,9 @@ describe("GStack runtime release channel", () => { expect(workflow).toContain('gh release create "$GITHUB_REF_NAME"'); expect(workflow).toContain("pathToFileURL(p).href"); expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")'); + expect(workflow).toContain('release_dir="$(pwd -P)/release-output"'); + expect(workflow).toContain('archive="$release_dir/gstack-runtime-2.0.0-$TARGET-$component.tar.gz"'); + expect(workflow).not.toContain('archive="$GITHUB_WORKSPACE/release-output/'); expect(workflow).not.toContain("goto about:blank"); expect(buildSection).not.toContain("sigstore/cosign-installer"); expect(manifestSection).toContain("sigstore/cosign-installer"); From e3effb3fc4eef75d83f501e31dde703d370e2207 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 11:51:39 -0700 Subject: [PATCH 6/7] 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"); }); From a84a6e233d6b47f1a99e16eeea901461919d8155 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Tue, 21 Jul 2026 12:13:33 -0700 Subject: [PATCH 7/7] fix: harden browser provider activation --- .github/docker/Dockerfile.ci | 8 +- .github/workflows/make-pdf-gate.yml | 2 +- browse/src/browser-manager.ts | 2 + browse/src/cli.ts | 15 +- browse/src/proxy-config.ts | 13 +- browse/src/write-commands.ts | 17 +- browse/test/commands.test.ts | 14 ++ browse/test/daemon-mismatch-refuse.test.ts | 41 ++++ browse/test/proxy-config.test.ts | 16 ++ docs/gstack-2/JUDGMENT-PROVENANCE.json | 30 +-- docs/gstack-2/RELEASE-INTEGRITY.md | 2 +- docs/gstack-2/STATUS.md | 20 +- docs/gstack-2/TEST-EVIDENCE.md | 5 +- evals/parity/contracts/benchmark.json | 2 +- evals/parity/contracts/browse.json | 2 +- evals/parity/contracts/canary.json | 2 +- .../parity/contracts/design-consultation.json | 2 +- evals/parity/contracts/design-html.json | 2 +- evals/parity/contracts/design-review.json | 2 +- evals/parity/contracts/devex-review.json | 2 +- evals/parity/contracts/land-and-deploy.json | 2 +- evals/parity/contracts/office-hours.json | 2 +- .../parity/contracts/open-gstack-browser.json | 2 +- evals/parity/contracts/pair-agent.json | 2 +- evals/parity/contracts/qa-only.json | 2 +- evals/parity/contracts/qa.json | 2 +- .../contracts/setup-browser-cookies.json | 2 +- evals/parity/manifest.json | 30 +-- .../deterministic/design-consultation.json | 8 +- .../deterministic/design-review.json | 8 +- .../deterministic/office-hours.json | 8 +- .../deterministic/qa-fix-verify.json | 8 +- .../deterministic/qa-report-only.json | 8 +- runtime/cli.js | 8 +- runtime/doctor.js | 31 ++- runtime/install.js | 21 +- runtime/managed-home.js | 2 +- runtime/runtime-bootstrap.mjs | 87 +++++++- runtime/upgrade.js | 70 +++++- scripts/build-app.sh | 2 +- scripts/gstack2/render-legacy.ts | 3 +- .../references/support/runtime-bootstrap.mjs | 87 +++++++- .../references/legacy/design-consultation.md | 4 +- .../design/references/legacy/design-html.md | 4 +- .../design/references/legacy/design-review.md | 4 +- .../references/support/runtime-bootstrap.mjs | 87 +++++++- skills/plan/references/legacy/office-hours.md | 4 +- .../references/support/runtime-bootstrap.mjs | 87 +++++++- skills/qa/references/legacy/benchmark.md | 4 +- skills/qa/references/legacy/browse.md | 4 +- skills/qa/references/legacy/canary.md | 4 +- skills/qa/references/legacy/devex-review.md | 4 +- .../references/legacy/open-gstack-browser.md | 4 +- skills/qa/references/legacy/pair-agent.md | 4 +- skills/qa/references/legacy/qa-only.md | 4 +- skills/qa/references/legacy/qa.md | 4 +- .../legacy/setup-browser-cookies.md | 4 +- .../references/support/runtime-bootstrap.mjs | 87 +++++++- .../references/support/runtime-bootstrap.mjs | 87 +++++++- skills/ship/references/legacy/canary.md | 4 +- .../ship/references/legacy/land-and-deploy.md | 4 +- .../references/support/runtime-bootstrap.mjs | 87 +++++++- test/gstack2-runtime-install.test.ts | 61 ++++++ test/gstack2-runtime-safety-config.test.ts | 19 ++ test/gstack2-runtime-setup-ux.test.ts | 205 ++++++++++++++++++ 65 files changed, 1203 insertions(+), 171 deletions(-) diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index ebf4a4d13..2be739d4d 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -70,11 +70,11 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://bun.sh/inst RUN npm i -g @anthropic-ai/claude-code # Playwright system deps (Chromium) — needed for browse E2E tests -RUN npx playwright install-deps chromium +RUN npx playwright-core install-deps chromium # Linux has neither Helvetica nor Arial. make-pdf's print CSS stacks fall back # to Liberation Sans (metric-compatible Arial clone, SIL OFL 1.1) so PDFs don't -# render in DejaVu Sans. playwright install-deps happens to pull this in today, +# render in DejaVu Sans. playwright-core install-deps happens to pull this in today, # but the dep is implicit and could change — install explicitly so upgrades # can't silently regress rendering. # @@ -100,12 +100,12 @@ RUN bun install --frozen-lockfile && rm -rf /tmp/* # Install Playwright Chromium to a shared location accessible by all users ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers -RUN npx playwright install chromium \ +RUN npx playwright-core install chromium \ && chmod -R a+rX /opt/playwright-browsers # Verify everything works RUN bun --version && node --version && claude --version && jq --version && gh --version \ - && npx playwright --version \ + && npx playwright-core --version \ && fc-match "Liberation Sans" | grep -qi "Liberation" \ || (echo "ERROR: fonts-liberation not installed — make-pdf PDFs will render in DejaVu Sans" && exit 1) diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index 9a3d13538..69c4d7eb1 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -66,7 +66,7 @@ jobs: fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' || true - name: Install Playwright Chromium - run: bunx playwright install chromium + run: bunx playwright-core install chromium - name: Build binaries run: bun run build diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index b8c56f7ca..5cdfbb8ec 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -382,6 +382,7 @@ export class BrowserManager { // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. // Extensions only work in headed mode, so we use an off-screen window. const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; + if (extensionsDir) assertHeadedBrowserProvider(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; let useHeadless = true; @@ -1587,6 +1588,7 @@ export class BrowserManager { * If step 2 fails → return error, headless browser untouched */ async handoff(message: string): Promise { + assertHeadedBrowserProvider(); if (this.connectionMode === 'headed' || this.isHeaded) { return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dee7b642f..93e4f17da 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -118,7 +118,7 @@ interface ServerState { serverPath: string; binaryVersion?: string; mode?: 'launched' | 'headed'; - /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ + /** Hash of proxy, headed mode, and browser-provider intent, used by daemon-mismatch checks. */ configHash?: string; /** Xvfb child PID for cleanup on disconnect. */ xvfbPid?: number; @@ -431,8 +431,8 @@ async function ensureServer(flags?: GlobalFlags): Promise { // hint. No silent restart — that would drop tab state, cookies, and // logged-in sessions without warning. if (desiredHash && state.configHash && state.configHash !== desiredHash) { - console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); - console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); + console.error(`[browse] existing daemon has different config (browser provider, proxy, or headed mode).`); + console.error(`[browse] run 'browse disconnect' first to apply the selected browser configuration.`); process.exit(1); } // Same path: existing daemon is plain (no flags) but caller passes @@ -782,7 +782,7 @@ export interface GlobalFlags { proxyUrl: string | null; /** Whether --headed was passed. */ headed: boolean; - /** Hash of (proxy + headed) for daemon-mismatch check. */ + /** Hash of proxy, headed mode, and browser-provider intent for daemon-mismatch checks. */ configHash: string; /** Redacted form of proxyUrl, safe for logs. */ redactedProxyUrl: string; @@ -842,7 +842,12 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G args: out, proxyUrl: canonicalProxyUrl, headed, - configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }), + configHash: computeConfigHash({ + proxyUrl: canonicalProxyUrl, + headed, + browserProvider: env.GSTACK_BROWSER_PROVIDER, + browserExecutable: env.GSTACK_CHROMIUM_PATH, + }), redactedProxyUrl: redactProxyUrl(canonicalProxyUrl), }; } diff --git a/browse/src/proxy-config.ts b/browse/src/proxy-config.ts index 161475825..e03f0f318 100644 --- a/browse/src/proxy-config.ts +++ b/browse/src/proxy-config.ts @@ -125,7 +125,7 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { } /** - * Compute a stable hash of (proxyUrl + headed flag) for daemon-mismatch + * Compute a stable hash of proxy, headed mode, and browser-provider intent for daemon-mismatch * detection (D2). The hash is deterministic across CLI invocations on the * same machine and survives daemon restarts via the state file. * @@ -135,9 +135,18 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { export function computeConfigHash(opts: { proxyUrl: string | null | undefined; headed: boolean; + browserProvider?: string | null; + browserExecutable?: string | null; }): string { const proxyKey = canonicalizeProxyUrl(opts.proxyUrl); - const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed }); + const browserProvider = opts.browserProvider || null; + const browserExecutable = browserProvider === "installed" ? opts.browserExecutable || null : null; + const input = JSON.stringify({ + proxy: proxyKey, + headed: opts.headed, + browserProvider, + browserExecutable, + }); return createHash('sha256').update(input).digest('hex').slice(0, 16); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 4a847141d..626ba8794 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -355,11 +355,18 @@ export async function handleWriteCommand( } } catch (err: any) { // Enhanced error guidance: clicking