mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 20:30:47 +02:00
feat: add provider-aware browser QA setup
Detect host-native browser tools before offering the isolated local Chromium fallback, add a common readiness fixture, harden managed browser startup, and verify standards installs expose one canonical QA skill.
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
# Browser provider setup and readiness
|
||||
|
||||
Browser setup is optional and consented. The Agent Skills installer owns skill placement; this flow never enrolls another host. At onboarding, offer this flow only when the user asks to configure browser capabilities now. Otherwise run it just in time when a selected workflow first needs interactive browser evidence.
|
||||
|
||||
## Routing flow
|
||||
|
||||
1. Identify the active host and provider from callable tools in the current session, not from unrelated installed binaries, parent-application environment variables, documentation, or guessed host names. Inherited `CODEX_*`, bundle identifiers, and similar process metadata never override the actual agent/tool surface.
|
||||
2. Show the detected provider, what user-controlled setup it requires, and the GStack local-browser fallback.
|
||||
3. Ask whether to use the host-native provider, set up GStack local browser, continue without browser evidence, or defer. A selection is not permission to install another product or attach private browser state.
|
||||
4. For a host-native selection, follow the matching provider section below. Never install an extension, grant site access, sign in, add an MCP server, attach an existing profile, or change host settings without the user's explicit action.
|
||||
5. After setup, run the common readiness journey. Tool names or metadata alone are insufficient evidence.
|
||||
6. If the journey fails, report `needs-user-action`, `unavailable`, or `failed` with the exact observed cause and offer the local GStack browser. Do not silently fall back.
|
||||
|
||||
## Provider states
|
||||
|
||||
- `available`: the current session exposes callable navigation, reading, and interaction tools, but readiness has not yet been proven.
|
||||
- `needs-user-action`: the host has a documented provider, but the user must enable, open, approve, or connect it.
|
||||
- `ready`: the selected provider passed the common local readiness journey in this session.
|
||||
- `unavailable`: no suitable interactive tool is exposed in the current session.
|
||||
- `failed`: a callable provider attempted the readiness journey and failed; include the exact failing step.
|
||||
|
||||
Never report `ready` merely because a browser binary, CLI, plugin, extension, MCP name, settings entry, or documentation exists. Present one provider decision, then continue through the selected path. Do not run `./setup`; it is not a GStack 2 browser setup command.
|
||||
|
||||
## Common local readiness journey
|
||||
|
||||
Start the dependency-free fixture from this skill root with:
|
||||
|
||||
`node references/support/browser-provider-smoke.mjs`
|
||||
|
||||
The process prints one JSON line containing a loopback URL and remains alive. Through the selected browser provider:
|
||||
|
||||
1. Open that exact URL.
|
||||
2. Verify the heading `GStack browser readiness`.
|
||||
3. Click `Complete readiness check`.
|
||||
4. Verify the page status becomes `READY`.
|
||||
5. When supported, confirm the console message `gstack-browser-readiness:ready` and the successful `POST /proof` request.
|
||||
6. Stop the fixture process and confirm it releases its listener.
|
||||
|
||||
Mark the provider `ready` only after navigation, page reading, and interaction all succeed. The fixture binds to `127.0.0.1`, uses a per-run random token, sends no repository data, and does not persist cookies or credentials.
|
||||
|
||||
## Claude in Chrome
|
||||
|
||||
Classification: `native-extension`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that Claude in Chrome requires Google Chrome, a paid Claude plan, the user-installed extension, the same signed-in Claude account, granted site permissions, and an enabled connector.
|
||||
2. Ask whether the user wants to configure it now. Never install the extension, grant permissions, enable a connector, sign in, or attach a Chrome profile for them.
|
||||
3. After the user completes setup, retry the active host tab/context discovery rather than treating the presence of the Claude CLI as browser readiness.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- The Claude in Chrome tool surface is visible to the active Claude Code session.
|
||||
- Tab/context discovery returns an attached browser peer instead of a missing-extension or no-peer error.
|
||||
- The common local readiness journey completes through the Claude browser tools.
|
||||
|
||||
If unavailable: Offer GStack local browser or continue without browser evidence; do not substitute a different extension or remote browser.
|
||||
|
||||
## Codex built-in browser
|
||||
|
||||
Classification: `native-in-app`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that the built-in browser is a ChatGPT desktop-app surface on macOS and Windows and uses its own browser state.
|
||||
2. Ask the user to open it from the Work/Codex toolbar (Command-Shift-B on macOS or Control-Shift-B on Windows) and approve the test site when prompted.
|
||||
3. Do not infer readiness from the Codex CLI or skill installation; the active session must expose an in-app browser provider.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- Active browser discovery returns the in-app browser provider instead of an empty provider list.
|
||||
- A tab can be created or selected after the user opens the browser surface.
|
||||
- The common local readiness journey completes through the Codex browser tools.
|
||||
|
||||
If unavailable: Offer GStack local browser or defer browser work; do not claim that restarting a browser-less session will create the missing host capability.
|
||||
|
||||
## Gemini CLI browser agent
|
||||
|
||||
Classification: `native-agent`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that Gemini CLI has an experimental bundled `browser_agent`, disabled by default, which requires a recent local Chrome and displays a first-run consent dialog.
|
||||
2. Ask whether the user wants to enable it themselves. Never edit Gemini settings, accept its consent dialog, attach an existing Chrome session, or infer readiness from the Gemini executable.
|
||||
3. Prefer Gemini isolated browser mode for public or localhost QA unless the user explicitly needs and approves an existing signed-in browser session.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- The active Gemini session exposes the callable `browser_agent` tool rather than only `google_web_search` or `web_fetch`.
|
||||
- The user has completed Gemini's own enablement and one-time consent without GStack acting on their behalf.
|
||||
- The common local readiness journey completes through the Gemini browser agent.
|
||||
|
||||
If unavailable: Offer the consented GStack local browser, evidence-limited fetch/search, or deferral; do not install a browser MCP because Gemini already bundles its experimental browser agent.
|
||||
|
||||
## Cursor interactive browser provider
|
||||
|
||||
Classification: `native-mcp`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Inspect the active Cursor tool surface for an interactive provider such as Chrome DevTools; Cursor CLI presence and parent-process environment variables are not provider evidence.
|
||||
2. If an interactive provider is exposed, explain its current session/profile boundary and ask whether to use it. Never add or approve an MCP server, extension, or browser profile for the user.
|
||||
3. If no interactive provider is exposed in this Cursor session, offer GStack local browser rather than assuming every Cursor installation has the same browser tools.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- The active Cursor session exposes callable navigation, page-reading, and interaction tools and returns a live browser peer.
|
||||
- The selected browser session does not require silently attaching the user's personal profile.
|
||||
- The common local readiness journey completes through the discovered Cursor provider.
|
||||
|
||||
If unavailable: Offer GStack local browser, evidence-limited web access, or deferral; do not turn one machine's configured Chrome DevTools MCP into a universal Cursor capability claim.
|
||||
|
||||
## GitHub Copilot and VS Code integrated browser
|
||||
|
||||
Classification: `native-in-app`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that current VS Code can expose built-in browser agent tools, subject to the `workbench.browser.enableChatTools` organization setting and the user's active tool selection.
|
||||
2. Ask the user to enable the Built-in > Browser tools in the active agent session when they want to use them. Never change VS Code or organization settings for them.
|
||||
3. Prefer an agent-created isolated browser page; sharing an existing browser page or its signed-in state requires the user's explicit action.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- The active Copilot/VS Code agent session exposes callable browser tools such as page navigation, reading, screenshot, and interaction.
|
||||
- An isolated browser page can be opened without silently sharing an existing tab or cookie store.
|
||||
- The common local readiness journey completes through the VS Code integrated browser tools.
|
||||
|
||||
If unavailable: Offer GStack local browser or defer; distinguish organization-policy disablement from a transient missing browser tab.
|
||||
|
||||
## OpenClaw browser plugin
|
||||
|
||||
Classification: `native-plugin`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that OpenClaw includes a browser plugin with a dedicated managed profile, while its `user` profile can attach to an existing browser session.
|
||||
2. Inspect the active OpenClaw tool policy for the callable browser tool. Never edit `plugins.allow`, enable the plugin, or attach the `user` profile without explicit user action.
|
||||
3. Prefer the isolated managed profile for public and localhost QA; use an existing signed-in profile only when the user explicitly selects it.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- The active OpenClaw agent exposes the browser tool after its plugin and tool-policy checks.
|
||||
- Browser doctor/status and tab discovery succeed for the explicitly selected profile.
|
||||
- The common local readiness journey completes through OpenClaw browser actions.
|
||||
|
||||
If unavailable: Offer GStack local browser or defer; do not silently repair OpenClaw plugin policy or attach a personal browser profile.
|
||||
|
||||
## Kimi Code
|
||||
|
||||
Classification: `no-native-automation`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that Kimi Code loads standard Agent Skills and provides WebSearch/FetchURL when configured, but does not currently document a native interactive browser-automation harness.
|
||||
2. Do not describe `kimi web` as browser automation: it is the browser-based user interface for the Kimi session.
|
||||
3. Offer GStack local browser when interactive navigation, clicking, screenshots, console, or network evidence is required.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- Kimi discovers the canonical GStack skills through its standard Agent Skills directories.
|
||||
- Fetch/search-only work may use Kimi host tools when their limitations satisfy the task.
|
||||
- Interactive browser readiness is tested against GStack local browser, not `kimi web`.
|
||||
|
||||
If unavailable: Continue with fetch/search-only evidence or offer the consented GStack local browser; never silently add a browser MCP or alternate backend.
|
||||
|
||||
## Pi coding agent
|
||||
|
||||
Classification: `extension-only`
|
||||
|
||||
Setup:
|
||||
|
||||
1. Explain that Pi intentionally has no core interactive browser tool; browser automation is available only through optional third-party packages or an already exposed host tool.
|
||||
2. Inspect the active Pi tool surface. Never install a Pi package, run its browser installer, or treat a package listing as a live browser peer.
|
||||
3. Offer GStack local browser when no already-configured interactive provider is callable.
|
||||
|
||||
Readiness evidence:
|
||||
|
||||
- An explicitly user-installed Pi browser extension exposes a callable browser tool in the active session.
|
||||
- The extension returns a live isolated session without triggering an unapproved Chromium download or profile attachment.
|
||||
- The common local readiness journey completes through that active tool.
|
||||
|
||||
If unavailable: Offer GStack local browser, evidence-limited continuation, or deferral; never recommend or install a third-party Pi browser package automatically.
|
||||
|
||||
## GStack local browser fallback
|
||||
|
||||
GStack's existing local Chromium/Playwright implementation remains the only bundled browser backend. Follow `RUNTIME.md` for preview and separate install consent, run `gstack doctor`, then run the same readiness journey with the `browse` launcher. Do not run `./setup`, add a cloud browser, remote provider, alternate local backend, or personal-profile attachment as a fallback.
|
||||
@@ -3,14 +3,22 @@
|
||||
|
||||
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.
|
||||
|
||||
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. Installation requires the user's explicit approval after disclosing the selected capabilities, that an official versioned release artifact will be downloaded and SHA-256 verified, and any exact byte/build information available from the bootstrap manifest. Never call the bootstrap before approval.
|
||||
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.
|
||||
|
||||
Capability names are `browser`, `design`, `pdf`, `diagram`, and `ios`; the core state/runtime CLI is managed separately. After approval, from this skill root run `node references/support/runtime-bootstrap.mjs install --capability <name>` (repeat `--capability` for additional approved capabilities). The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates the fixed manifest schema/version/skill API/platform artifact 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.
|
||||
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.
|
||||
|
||||
Resolve capability dependencies before preview and approval: `diagram` implies `browser`; `pdf` implies both `diagram` and `browser` transitively. `design` and `ios` are standalone. The disclosure and approval must name the complete expanded set, not only the capability first requested.
|
||||
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 <name>` (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.
|
||||
|
||||
After doctor confirms readiness, canonical launcher bindings are `GSTACK_HOME=${GSTACK_HOME:-$HOME/.gstack}`, `GSTACK_BIN=$GSTACK_HOME/bin`, `B=$GSTACK_BIN/browse`, `D=$GSTACK_BIN/gstack-design`, and `P=$GSTACK_BIN/make-pdf`. Specialist modules may bind only the launchers they use. These assignments do not install or grant 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 <name> --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.
|
||||
|
||||
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.
|
||||
|
||||
After doctor confirms readiness, canonical launcher bindings are `GSTACK_HOME=${GSTACK_HOME:-$HOME/.gstack}`, `GSTACK_BIN=$GSTACK_HOME/bin`, `BUN_CMD=$GSTACK_BIN/bun`, `B=$GSTACK_BIN/browse`, `D=$GSTACK_BIN/gstack-design`, and `P=$GSTACK_BIN/make-pdf`. Specialist modules may bind only the launchers they use. These assignments do not install or grant consent. Never download or install a second Bun for GStack helpers; the managed runtime owns its pinned Bun executable in the active slot.
|
||||
|
||||
Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on Windows, discovers Git for Windows Bash; disclose and stop at the affected helper if that prerequisite is unavailable. Python is not a global GStack prerequisite: only a specialist flow explicitly labeled as Python-dependent may request it, at the point of use and with the user's approval.
|
||||
|
||||
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 <reviewed-checkout> --capability <name>`; 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 <reviewed-checkout> --capability <name> --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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-consultation/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=64af56ecdbd132cb7c28344e8e4ecb2e5dacf811 baseline_render_sha256=62b8141e0b3edb26dcfd175c25c7021d4713b64add121137ace0a123e6e6ea8a ported_render_sha256=afa1f2b58d22d9f0f9064336ce7e9836430a298e1deb56f5330390f91df3f680 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-consultation/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=64af56ecdbd132cb7c28344e8e4ecb2e5dacf811 baseline_render_sha256=62b8141e0b3edb26dcfd175c25c7021d4713b64add121137ace0a123e6e6ea8a ported_render_sha256=d323457820291635bc4c46e4559ce6f4d194b940607b76208e95df0c86ffcb0b disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Generate --module design-consultation visibility=primary depth=deep mutation=design-artifacts web=optional -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=design-consultation -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
@@ -73,26 +74,9 @@ fi
|
||||
```
|
||||
|
||||
If `NEEDS_SETUP`:
|
||||
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
|
||||
1. Tell the user: "The optional managed headless browser capability is missing. Do you want to preview its exact dependency-closed component plan and compressed bytes now?" Then STOP and wait.
|
||||
2. Read `references/RUNTIME.md` and follow its explicit capability bootstrap. Never assume a standard-installed skill directory contains `./setup`.
|
||||
3. If `bun` is not installed:
|
||||
```bash
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
BUN_VERSION="1.3.10"
|
||||
BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd"
|
||||
tmpfile=$(mktemp)
|
||||
curl -fsSL "https://bun.sh/install" -o "$tmpfile"
|
||||
actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}')
|
||||
if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then
|
||||
echo "ERROR: bun install script checksum mismatch" >&2
|
||||
echo " expected: $BUN_INSTALL_SHA" >&2
|
||||
echo " got: $actual_sha" >&2
|
||||
rm "$tmpfile"; exit 1
|
||||
fi
|
||||
BUN_VERSION="$BUN_VERSION" bash "$tmpfile"
|
||||
rm "$tmpfile"
|
||||
fi
|
||||
```
|
||||
3. The approved managed runtime includes its own pinned Bun at `$GSTACK_BIN/bun`; never download or install another Bun from a skill workflow.
|
||||
|
||||
If browse is not available, that's fine — visual research is optional. The skill works without it using WebSearch and your built-in design knowledge.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-html/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=3cdec9a14d62d2e046ed924c972efc30a7d43aca baseline_render_sha256=d16ec32f4c07da49d32efc309e621b514854ce355db8347647b9f9fc215ff66d ported_render_sha256=cbb1b4357bedbb0fffe23c3e0ad31ee8c2a198a31443edb660e5c836e67e94e6 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-html/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=3cdec9a14d62d2e046ed924c972efc30a7d43aca baseline_render_sha256=d16ec32f4c07da49d32efc309e621b514854ce355db8347647b9f9fc215ff66d ported_render_sha256=40682d97ac83aa9178487348d5abf176334fd439e2d12f8e5cda1f8b20cd2c30 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Implement --module design-html visibility=primary depth=standard mutation=design-artifacts web=local-browser -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=design-html -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
@@ -166,26 +167,9 @@ fi
|
||||
```
|
||||
|
||||
If `NEEDS_SETUP`:
|
||||
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
|
||||
1. Tell the user: "The optional managed headless browser capability is missing. Do you want to preview its exact dependency-closed component plan and compressed bytes now?" Then STOP and wait.
|
||||
2. Read `references/RUNTIME.md` and follow its explicit capability bootstrap. Never assume a standard-installed skill directory contains `./setup`.
|
||||
3. If `bun` is not installed:
|
||||
```bash
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
BUN_VERSION="1.3.10"
|
||||
BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd"
|
||||
tmpfile=$(mktemp)
|
||||
curl -fsSL "https://bun.sh/install" -o "$tmpfile"
|
||||
actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}')
|
||||
if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then
|
||||
echo "ERROR: bun install script checksum mismatch" >&2
|
||||
echo " expected: $BUN_INSTALL_SHA" >&2
|
||||
echo " got: $actual_sha" >&2
|
||||
rm "$tmpfile"; exit 1
|
||||
fi
|
||||
BUN_VERSION="$BUN_VERSION" bash "$tmpfile"
|
||||
rm "$tmpfile"
|
||||
fi
|
||||
```
|
||||
3. The approved managed runtime includes its own pinned Bun at `$GSTACK_BIN/bun`; never download or install another Bun from a skill workflow.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=bdcda48e29b489a1cc49faa333922412251d4b41 baseline_render_sha256=ff6d5d4858ed45db1e9581080739c0b4c5029bca44ecabe0c385637ece68e0cb ported_render_sha256=33584047a11aa46a1b6a2bef5bf97a4d0b443bb6c9542685732e7d4dff795a2e disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=bdcda48e29b489a1cc49faa333922412251d4b41 baseline_render_sha256=ff6d5d4858ed45db1e9581080739c0b4c5029bca44ecabe0c385637ece68e0cb ported_render_sha256=fe15a4fae62fba41432ae18bbf4ef5620058b784b7bf9768304d0d1dd17bf45b disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Implement --module design-review visibility=primary depth=deep mutation=fix-safe web=local-browser -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=design-review -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
@@ -80,26 +81,9 @@ fi
|
||||
```
|
||||
|
||||
If `NEEDS_SETUP`:
|
||||
1. Tell the user: "gstack browse needs a one-time build (~10 seconds). OK to proceed?" Then STOP and wait.
|
||||
1. Tell the user: "The optional managed headless browser capability is missing. Do you want to preview its exact dependency-closed component plan and compressed bytes now?" Then STOP and wait.
|
||||
2. Read `references/RUNTIME.md` and follow its explicit capability bootstrap. Never assume a standard-installed skill directory contains `./setup`.
|
||||
3. If `bun` is not installed:
|
||||
```bash
|
||||
if ! command -v bun >/dev/null 2>&1; then
|
||||
BUN_VERSION="1.3.10"
|
||||
BUN_INSTALL_SHA="bab8acfb046aac8c72407bdcce903957665d655d7acaa3e11c7c4616beae68dd"
|
||||
tmpfile=$(mktemp)
|
||||
curl -fsSL "https://bun.sh/install" -o "$tmpfile"
|
||||
actual_sha=$(shasum -a 256 "$tmpfile" | awk '{print $1}')
|
||||
if [ "$actual_sha" != "$BUN_INSTALL_SHA" ]; then
|
||||
echo "ERROR: bun install script checksum mismatch" >&2
|
||||
echo " expected: $BUN_INSTALL_SHA" >&2
|
||||
echo " got: $actual_sha" >&2
|
||||
rm "$tmpfile"; exit 1
|
||||
fi
|
||||
BUN_VERSION="$BUN_VERSION" bash "$tmpfile"
|
||||
rm "$tmpfile"
|
||||
fi
|
||||
```
|
||||
3. The approved managed runtime includes its own pinned Bun at `$GSTACK_BIN/bun`; never download or install another Bun from a skill workflow.
|
||||
|
||||
**Check test framework (bootstrap if needed):**
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-shotgun/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=230dbc2922f05bf272bf5168a958a12604fac1bc baseline_render_sha256=08818e1cfbd831a8f30fc673facc0cf6a2821b88cef641007e7516ed795610e8 ported_render_sha256=1db4cd23ee115ce841d2db898cc442141588635d43018393703aa28ce40d48f2 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=design-shotgun/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=230dbc2922f05bf272bf5168a958a12604fac1bc baseline_render_sha256=08818e1cfbd831a8f30fc673facc0cf6a2821b88cef641007e7516ed795610e8 ported_render_sha256=e27c29760f1edffffee08f131b2c0ee76720eb447d3bb05eee4278467d785620 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Explore --module design-shotgun visibility=primary depth=deep mutation=design-artifacts web=optional -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=design-shotgun -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=diagram/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9e19a52c6b7f727ce4faf5c1f9c14514ecb52f50 baseline_render_sha256=a06717ae84aaaa6c22444e57622f265324bab5769f1d6bdfff7b805d6e20418d ported_render_sha256=21f18fa4957b79bf5941abfebd1b222dee3eb960520e0deac1c32d0b6fd62a38 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=diagram/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9e19a52c6b7f727ce4faf5c1f9c14514ecb52f50 baseline_render_sha256=a06717ae84aaaa6c22444e57622f265324bab5769f1d6bdfff7b805d6e20418d ported_render_sha256=18b1db5eb50d85a6da26b60ac72a19c144e95bb4a2d92b2d23a87086c49c01b7 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Generate --module diagram visibility=internal depth=standard mutation=design-artifacts web=none -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=diagram -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=make-pdf/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9133a711d4f3d056a21f790e8ec3b98f13fbaa50 baseline_render_sha256=808606af9faeac0fca5aabf82f766f23cdd80c67597ee7fd0a6a86e494627139 ported_render_sha256=c092bc644ff9b8929d9cbdfedb6f2761b40ce8e1fca53d13ad350ba91195104d disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=make-pdf/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9133a711d4f3d056a21f790e8ec3b98f13fbaa50 baseline_render_sha256=808606af9faeac0fca5aabf82f766f23cdd80c67597ee7fd0a6a86e494627139 ported_render_sha256=ece1cfa64bb3cbb3979ce06890e09bbc2ccab4e1d49310c57dc28e70e1236ee9 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Generate --module make-pdf visibility=internal depth=standard mutation=design-artifacts web=none -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=make-pdf -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||
<!-- GSTACK2_PROVENANCE source=plan-design-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=7178c991e41277410de500298cf81372543861af baseline_render_sha256=7dfab0ea92c44ae6d6ebc36353cec4ebd56331d2740e0e2138c5cdc922ee53a7 ported_render_sha256=2f7a5d113c466d75ca4524e424c072044d92ae7ab93009f1fbe3ae5965e8aa34 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_PROVENANCE source=plan-design-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=7178c991e41277410de500298cf81372543861af baseline_render_sha256=7dfab0ea92c44ae6d6ebc36353cec4ebd56331d2740e0e2138c5cdc922ee53a7 ported_render_sha256=1ff3644728bee14a4a002b91bada44b1c95a20ee5779ed458dabca2485a19e78 disposition=BUG_FIX -->
|
||||
<!-- GSTACK2_ROUTING replacement=$design --mode Critique --module plan-design-review visibility=primary depth=deep mutation=plan-only web=optional -->
|
||||
|
||||
<!-- GSTACK2_LEGACY_BODY_START source=plan-design-review -->
|
||||
@@ -12,6 +12,7 @@ GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"
|
||||
GSTACK_ROOT="$GSTACK_HOME"
|
||||
GSTACK_STATE_ROOT="$GSTACK_HOME"
|
||||
GSTACK_BIN="$GSTACK_HOME/bin"
|
||||
BUN_CMD="$GSTACK_BIN/bun"
|
||||
B="$GSTACK_BIN/browse"
|
||||
D="$GSTACK_BIN/gstack-design"
|
||||
P="$GSTACK_BIN/make-pdf"
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomBytes } from "node:crypto";
|
||||
import http from "node:http";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const HOST = "127.0.0.1";
|
||||
|
||||
export function createReadinessServer(options = {}) {
|
||||
const token = options.token ?? randomBytes(24).toString("hex");
|
||||
if (!/^[a-f0-9]{32,128}$/.test(token)) throw new TypeError("Readiness token must be 32-128 lowercase hex characters");
|
||||
let completed = false;
|
||||
let baseUrl = null;
|
||||
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url ?? "/", baseUrl ?? `http://${HOST}`);
|
||||
const supplied = url.searchParams.get("token");
|
||||
const headers = {
|
||||
"Cache-Control": "no-store",
|
||||
"Content-Security-Policy": "default-src 'none'; script-src 'unsafe-inline'; connect-src 'self'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
};
|
||||
|
||||
if (url.pathname === "/" && request.method === "GET") {
|
||||
response.writeHead(200, { ...headers, "Content-Type": "text/html; charset=utf-8" });
|
||||
response.end(renderPage(token));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/proof") {
|
||||
if (request.method !== "POST") {
|
||||
response.writeHead(405, { ...headers, Allow: "POST" });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
if (supplied !== token) {
|
||||
response.writeHead(403, headers);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
completed = true;
|
||||
response.writeHead(200, { ...headers, "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, status: "READY" }));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/status" && request.method === "GET") {
|
||||
if (supplied !== token) {
|
||||
response.writeHead(403, headers);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, { ...headers, "Content-Type": "application/json" });
|
||||
response.end(JSON.stringify({ ok: true, completed }));
|
||||
return;
|
||||
}
|
||||
response.writeHead(404, headers);
|
||||
response.end();
|
||||
});
|
||||
|
||||
return {
|
||||
server,
|
||||
token,
|
||||
get completed() { return completed; },
|
||||
async start() {
|
||||
if (baseUrl) return { url: `${baseUrl}/?token=${token}`, baseUrl, token };
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(options.port ?? 0, HOST, resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Readiness fixture did not acquire a TCP port");
|
||||
baseUrl = `http://${HOST}:${address.port}`;
|
||||
return { url: `${baseUrl}/?token=${token}`, baseUrl, token };
|
||||
},
|
||||
async stop() {
|
||||
if (!server.listening) return;
|
||||
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderPage(token) {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>GStack browser readiness</title>
|
||||
<style>body{font:16px system-ui;max-width:44rem;margin:4rem auto;padding:0 1rem}button{font:inherit;padding:.7rem 1rem}#gstack-readiness-status{font-weight:700}</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>GStack browser readiness</h1>
|
||||
<p>This local page verifies navigation, reading, interaction, console, and network access.</p>
|
||||
<button id="gstack-readiness-action" type="button">Complete readiness check</button>
|
||||
<p id="gstack-readiness-status" role="status">WAITING</p>
|
||||
</main>
|
||||
<script>
|
||||
document.querySelector('#gstack-readiness-action').addEventListener('click', async () => {
|
||||
const response = await fetch('/proof?token=${token}', { method: 'POST' });
|
||||
const result = await response.json();
|
||||
document.querySelector('#gstack-readiness-status').textContent = result.status;
|
||||
console.log('gstack-browser-readiness:ready');
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const fixture = createReadinessServer();
|
||||
const started = await fixture.start();
|
||||
process.stdout.write(`${JSON.stringify({ ...started, pid: process.pid })}\n`);
|
||||
const stop = async () => {
|
||||
await fixture.stop();
|
||||
process.exitCode = fixture.completed ? 0 : 2;
|
||||
};
|
||||
process.once("SIGINT", stop);
|
||||
process.once("SIGTERM", stop);
|
||||
}
|
||||
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch((error) => {
|
||||
process.stderr.write(`gstack browser readiness: ${error.message}\n`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -7,21 +7,41 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const BOOTSTRAP_SCHEMA_VERSION = 1;
|
||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||
export const OFFICIAL_MANIFEST_URL =
|
||||
`https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`;
|
||||
const CAPABILITIES = new Set(["browser", "design", "pdf", "diagram", "ios"]);
|
||||
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||
const CAPABILITY_DEPENDENCIES = Object.freeze({
|
||||
browser: Object.freeze([]),
|
||||
"browser-visible": Object.freeze([]),
|
||||
design: Object.freeze([]),
|
||||
pdf: Object.freeze(["browser", "diagram"]),
|
||||
diagram: Object.freeze(["browser"]),
|
||||
ios: Object.freeze([]),
|
||||
});
|
||||
export const COMPONENT_DEPENDENCIES = Object.freeze({
|
||||
core: Object.freeze([]),
|
||||
"browser-code": Object.freeze(["core"]),
|
||||
"browser-headless": Object.freeze(["browser-code"]),
|
||||
"browser-visible": Object.freeze(["browser-code"]),
|
||||
design: Object.freeze(["core"]),
|
||||
diagram: Object.freeze(["browser-headless"]),
|
||||
pdf: Object.freeze(["diagram"]),
|
||||
ios: Object.freeze(["core"]),
|
||||
});
|
||||
export const CAPABILITY_COMPONENTS = Object.freeze({
|
||||
browser: Object.freeze(["browser-code", "browser-headless"]),
|
||||
"browser-visible": Object.freeze(["browser-code", "browser-visible"]),
|
||||
design: Object.freeze(["design"]),
|
||||
diagram: Object.freeze(["diagram"]),
|
||||
pdf: Object.freeze(["pdf"]),
|
||||
ios: Object.freeze(["ios"]),
|
||||
});
|
||||
const ALLOWED_DOWNLOAD_HOSTS = new Set([
|
||||
"github.com",
|
||||
"objects.githubusercontent.com",
|
||||
@@ -43,18 +63,26 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
io.stdout.write(usage());
|
||||
return 0;
|
||||
}
|
||||
if (parsed.action !== "install") throw bootstrapError("Expected `install`", "BOOTSTRAP_USAGE");
|
||||
if (!["preview", "install"].includes(parsed.action)) {
|
||||
throw bootstrapError("Expected `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");
|
||||
}
|
||||
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");
|
||||
return 0;
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||
if (typeof fetch_ !== "function") throw bootstrapError("Node 18+ with fetch is required", "BOOTSTRAP_NODE_UNSUPPORTED");
|
||||
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 target = platformTarget(
|
||||
platform,
|
||||
options.arch ?? process.arch,
|
||||
@@ -63,19 +91,32 @@ 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 artifact = validateManifest(manifest, target);
|
||||
io.stdout.write(`Official runtime artifact: ${formatBytes(artifact.bytes)} for ${target}.\n`);
|
||||
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);
|
||||
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;
|
||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after reviewing this exact component plan", "BOOTSTRAP_CONSENT_REQUIRED");
|
||||
const temporary = await fs.mkdtemp(path.join(options.tmpDir ?? os.tmpdir(), "gstack-bootstrap-"));
|
||||
try {
|
||||
const archive = path.join(temporary, "runtime.tar.gz");
|
||||
await downloadVerified(fetch_, artifact.url, archive, artifact.sha256, artifact.bytes);
|
||||
io.stdout.write(`Verified SHA-256 for GStack runtime ${manifest.version} (${target}).\n`);
|
||||
await verifyCosignWhenAvailable(archive, artifact, temporary, { ...options, fetch: fetch_, ...io });
|
||||
const extracted = path.join(temporary, "extracted");
|
||||
await fs.mkdir(extracted, { mode: 0o700 });
|
||||
await extractTarSafely(archive, extracted, options);
|
||||
const root = safeArtifactRoot(extracted, artifact.root ?? "gstack");
|
||||
await assertNoLinks(root);
|
||||
const root = path.join(temporary, "merged", "gstack");
|
||||
await fs.mkdir(root, { recursive: true, mode: 0o700 });
|
||||
const claimedFiles = new Set();
|
||||
if (reusable) await seedReusableRuntime(reusable, root, claimedFiles);
|
||||
for (const item of plan.downloads) {
|
||||
const archive = path.join(temporary, `${item.component}.tar.gz`);
|
||||
await downloadVerified(fetch_, item.artifact.url, archive, item.artifact.sha256, item.artifact.bytes);
|
||||
io.stdout.write(`Verified SHA-256 for ${item.component} (${target}).\n`);
|
||||
await verifyCosignWhenAvailable(archive, item.artifact, path.join(temporary, item.component), { ...options, fetch: fetch_, ...io });
|
||||
const extracted = path.join(temporary, "extracted", item.component);
|
||||
await fs.mkdir(extracted, { recursive: true, mode: 0o700 });
|
||||
await extractTarSafely(archive, extracted, options);
|
||||
const componentRoot = safeArtifactRoot(extracted, item.artifact.root ?? "gstack");
|
||||
await assertNoLinks(componentRoot);
|
||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
||||
}
|
||||
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
|
||||
} finally {
|
||||
await fs.rm(temporary, { recursive: true, force: true });
|
||||
@@ -87,10 +128,12 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const result = { action: null, capabilities: [], source: null, home: null, help: false };
|
||||
const result = { action: null, capabilities: [], source: null, home: 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)) {
|
||||
const value = argv[++index];
|
||||
@@ -101,6 +144,7 @@ function parseArgs(argv) {
|
||||
} 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.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) {
|
||||
@@ -122,25 +166,138 @@ function parseArgs(argv) {
|
||||
|
||||
function validateManifest(manifest, target) {
|
||||
if (manifest?.schemaVersion !== BOOTSTRAP_SCHEMA_VERSION || manifest?.version !== BOOTSTRAP_RUNTIME_VERSION ||
|
||||
manifest?.skillApi !== "2.0" || typeof manifest?.artifacts !== "object") {
|
||||
manifest?.skillApi !== "2.0" || typeof manifest?.targets !== "object" ||
|
||||
!sameGraph(manifest.capabilityComponents, CAPABILITY_COMPONENTS) ||
|
||||
!sameGraph(manifest.componentDependencies, COMPONENT_DEPENDENCIES)) {
|
||||
throw bootstrapError("Official runtime manifest is incompatible", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
const artifact = manifest.artifacts[target];
|
||||
if (!artifact || artifact.format !== "tar.gz" || !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
|
||||
!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1 || artifact.bytes > 2 * 1024 * 1024 * 1024) {
|
||||
const targetRecord = manifest.targets[target];
|
||||
const expected = Object.keys(COMPONENT_DEPENDENCIES)
|
||||
.filter((component) => component !== "ios" || target.startsWith("darwin-"))
|
||||
.sort();
|
||||
if (!targetRecord || typeof targetRecord.components !== "object" ||
|
||||
JSON.stringify(Object.keys(targetRecord.components).sort()) !== JSON.stringify(expected)) {
|
||||
throw bootstrapError(`No valid official runtime artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE");
|
||||
}
|
||||
assertOfficialReleaseAssetUrl(artifact.url);
|
||||
if (artifact.cosignBundleUrl) {
|
||||
assertOfficialReleaseAssetUrl(artifact.cosignBundleUrl);
|
||||
if (artifact.certificateIdentity !== OFFICIAL_CERTIFICATE_IDENTITY ||
|
||||
artifact.certificateOidcIssuer !== GITHUB_OIDC_ISSUER) {
|
||||
throw bootstrapError("Cosign metadata does not bind the official GStack release workflow", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
for (const [component, artifact] of Object.entries(targetRecord.components)) {
|
||||
if (!artifact || artifact.format !== "tar.gz" || !/^[a-f0-9]{64}$/.test(artifact.sha256) ||
|
||||
!Number.isSafeInteger(artifact.bytes) || artifact.bytes < 1 || artifact.bytes > 2 * 1024 * 1024 * 1024) {
|
||||
throw bootstrapError(`Invalid ${component} artifact for ${target}`, "BOOTSTRAP_ARTIFACT_UNAVAILABLE");
|
||||
}
|
||||
assertOfficialReleaseAssetUrl(artifact.url);
|
||||
if (artifact.cosignBundleUrl) {
|
||||
assertOfficialReleaseAssetUrl(artifact.cosignBundleUrl);
|
||||
if (artifact.certificateIdentity !== OFFICIAL_CERTIFICATE_IDENTITY ||
|
||||
artifact.certificateOidcIssuer !== GITHUB_OIDC_ISSUER) {
|
||||
throw bootstrapError("Cosign metadata does not bind the official GStack release workflow", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
} else if (artifact.certificateIdentity || artifact.certificateOidcIssuer) {
|
||||
throw bootstrapError("Cosign certificate metadata requires a bundle URL", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
} else if (artifact.certificateIdentity || artifact.certificateOidcIssuer) {
|
||||
throw bootstrapError("Cosign certificate metadata requires a bundle URL", "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
return artifact;
|
||||
return targetRecord;
|
||||
}
|
||||
|
||||
function sameGraph(actual, expected) {
|
||||
if (!actual || typeof actual !== "object" || Array.isArray(actual)) return false;
|
||||
const normalize = (graph) => Object.fromEntries(Object.entries(graph)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, values]) => [key, Array.isArray(values) ? [...values].sort() : values]));
|
||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||
}
|
||||
|
||||
function selectedComponents(capabilities) {
|
||||
const selected = new Set(["core"]);
|
||||
for (const capability of capabilities) {
|
||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||
}
|
||||
const pending = [...selected];
|
||||
while (pending.length) {
|
||||
for (const dependency of COMPONENT_DEPENDENCIES[pending.pop()] ?? []) {
|
||||
if (!selected.has(dependency)) {
|
||||
selected.add(dependency);
|
||||
pending.push(dependency);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...selected].sort();
|
||||
}
|
||||
|
||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
||||
const components = selectedComponents(capabilities);
|
||||
const retained = new Set(reusable?.components ?? []);
|
||||
const downloads = components
|
||||
.filter((component) => !retained.has(component))
|
||||
.map((component) => ({ component, artifact: manifest.targets[target].components[component] }));
|
||||
const downloadBytes = downloads.reduce((total, item) => total + item.artifact.bytes, 0);
|
||||
return {
|
||||
target,
|
||||
version: manifest.version,
|
||||
capabilities,
|
||||
components,
|
||||
reusedComponents: components.filter((component) => retained.has(component)),
|
||||
downloads,
|
||||
downloadBytes,
|
||||
};
|
||||
}
|
||||
|
||||
function printComponentPlan(stdout, plan) {
|
||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
||||
}
|
||||
|
||||
async function inspectReusableRuntime(home, version) {
|
||||
const versions = path.join(home, "versions");
|
||||
const pointer = JSON.parse(await fs.readFile(path.join(versions, "current.json"), "utf8"));
|
||||
if (pointer?.schemaVersion !== 2 || pointer?.status !== "active" ||
|
||||
typeof pointer.current !== "string" || !/^[A-Za-z0-9._-]{1,128}$/.test(pointer.current)) return null;
|
||||
const root = path.join(versions, pointer.current);
|
||||
const stat = await fs.lstat(root);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
||||
const bundle = JSON.parse(await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8"));
|
||||
if (bundle?.schemaVersion !== 2 || bundle?.version !== version || !Array.isArray(bundle.runtimeComponents) ||
|
||||
!Array.isArray(bundle.files)) return null;
|
||||
const components = [...new Set(bundle.runtimeComponents)];
|
||||
if (!components.length || components.some((component) => !Object.hasOwn(COMPONENT_DEPENDENCIES, component))) return null;
|
||||
await assertNoLinks(root);
|
||||
const files = [];
|
||||
const seen = new Set();
|
||||
for (const entry of bundle.files) {
|
||||
const relative = entry?.path;
|
||||
if (typeof relative !== "string" || !relative || relative.includes("\\") || path.posix.isAbsolute(relative) ||
|
||||
path.posix.normalize(relative) !== relative || relative.split("/").includes("..") || seen.has(relative) ||
|
||||
!Number.isSafeInteger(entry.size) || entry.size < 0 || !/^[a-f0-9]{64}$/.test(entry.sha256)) return null;
|
||||
seen.add(relative);
|
||||
const file = path.join(root, ...relative.split("/"));
|
||||
const fileStat = await fs.lstat(file).catch(() => null);
|
||||
if (!fileStat?.isFile() || fileStat.isSymbolicLink() || fileStat.size !== entry.size ||
|
||||
await sha256File(file) !== entry.sha256) return null;
|
||||
files.push(relative);
|
||||
}
|
||||
return { root, components, files };
|
||||
}
|
||||
|
||||
async function seedReusableRuntime(reusable, destination, claimedFiles) {
|
||||
for (const relative of reusable.files) {
|
||||
if (claimedFiles.has(relative)) throw bootstrapError(`Runtime components overlap at ${relative}`, "BOOTSTRAP_MANIFEST_INVALID");
|
||||
claimedFiles.add(relative);
|
||||
const target = path.join(destination, ...relative.split("/"));
|
||||
await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(path.join(reusable.root, ...relative.split("/")), target, fsConstants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
|
||||
function sha256File(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = createHash("sha256");
|
||||
const stream = createReadStream(file);
|
||||
stream.on("error", reject);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("end", () => resolve(hash.digest("hex")));
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchJson(fetch_, url) {
|
||||
@@ -277,6 +434,31 @@ function safeArtifactRoot(extracted, relative) {
|
||||
return target;
|
||||
}
|
||||
|
||||
async function mergeComponentRoot(source, destination, claimedFiles, component) {
|
||||
async function visit(relative = "") {
|
||||
for (const entry of await fs.readdir(path.join(source, relative), { withFileTypes: true })) {
|
||||
const child = relative ? `${relative}/${entry.name}` : entry.name;
|
||||
const from = path.join(source, ...child.split("/"));
|
||||
const to = path.join(destination, ...child.split("/"));
|
||||
if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
|
||||
throw bootstrapError(`Runtime component ${component} contains a link or special file`, "BOOTSTRAP_ARCHIVE_UNSAFE");
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
await fs.mkdir(to, { recursive: true, mode: 0o700 });
|
||||
await visit(child);
|
||||
} else {
|
||||
if (claimedFiles.has(child)) {
|
||||
throw bootstrapError(`Runtime components overlap at ${child}`, "BOOTSTRAP_MANIFEST_INVALID");
|
||||
}
|
||||
claimedFiles.add(child);
|
||||
await fs.mkdir(path.dirname(to), { recursive: true, mode: 0o700 });
|
||||
await fs.copyFile(from, to, fsConstants.COPYFILE_EXCL);
|
||||
}
|
||||
}
|
||||
}
|
||||
await visit();
|
||||
}
|
||||
|
||||
async function assertNoLinks(root) {
|
||||
const pending = [root];
|
||||
while (pending.length) {
|
||||
|
||||
Reference in New Issue
Block a user