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:
Sinabina
2026-07-20 16:01:24 -07:00
parent f14445bb00
commit d6ef673e4d
163 changed files with 5272 additions and 990 deletions
@@ -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.
+13 -5
View File
@@ -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.
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=freeze/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=c0b31aa7f9f216fc5a351d91f4bcff68c828d090 baseline_render_sha256=f033b466da6f43edbdb57c23664bb544317a1c11a253701a35edc14986ebddfc ported_render_sha256=433bb7c1909852c83978ae282c582590c5136abe56020e7d0be39749822c345b disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=freeze/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=c0b31aa7f9f216fc5a351d91f4bcff68c828d090 baseline_render_sha256=f033b466da6f43edbdb57c23664bb544317a1c11a253701a35edc14986ebddfc ported_render_sha256=036fe2c7d5c154982ba3509e9b9ab1f5867f86c1d71beb569802d7f7be46ed7c disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$debug --mode Diagnose-only --module freeze visibility=internal depth=standard mutation=safety-policy web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=freeze -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=guard/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=3d34ee0c181ec7b263bf6092ba8f384619c5efb6 baseline_render_sha256=fb2609e8e305dfceb554442c4c5a8717b9f77efd8bf9c7166b9ec6335b06f481 ported_render_sha256=ff8170babcc9ad20f6de292db838d2c4545f0ed98dddd184ab5e7c52c073dc7e disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=guard/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=3d34ee0c181ec7b263bf6092ba8f384619c5efb6 baseline_render_sha256=fb2609e8e305dfceb554442c4c5a8717b9f77efd8bf9c7166b9ec6335b06f481 ported_render_sha256=36072a06a2a2ab6c1beec2a888417fb96eb1a50f284cb26ee3796b7c69857be8 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$debug --mode Diagnose-only --module guard visibility=internal depth=standard mutation=safety-policy web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=guard -->
@@ -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=investigate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=67e254d743ffb9060f48e3f6d4b715c077ee688d baseline_render_sha256=e570ad1683d87ce10bb4603f942a3e80bc35b1e56a7e8a7df0ff46c1f551f5d9 ported_render_sha256=91f19c8d736a9a6077941f63f52853634325059e5ac4d8808c4ba6dbceca1a30 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=investigate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=67e254d743ffb9060f48e3f6d4b715c077ee688d baseline_render_sha256=e570ad1683d87ce10bb4603f942a3e80bc35b1e56a7e8a7df0ff46c1f551f5d9 ported_render_sha256=ee817d27e0b303b1e173ab2c572c70f55ef2211225d366a166d2e26cbbe3b2ab disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$debug --mode Diagnose-only --module investigate visibility=primary depth=deep mutation=investigate-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=investigate -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=unfreeze/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=88e413fe5a49a45d46d8867b2d80ace30b3b45aa baseline_render_sha256=8debfd573af54e7caf4a73b13f775d9b9189b932b7b61afbaf1b8590d5647a9f ported_render_sha256=51d6183901e866697382b7e900e2154e8bfb6a9ebf4f3e1bb3ad29925fad0e20 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=unfreeze/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=88e413fe5a49a45d46d8867b2d80ace30b3b45aa baseline_render_sha256=8debfd573af54e7caf4a73b13f775d9b9189b932b7b61afbaf1b8590d5647a9f ported_render_sha256=19cd17084af59cb912f9507bf8ffdc81e38aa1fbef14605a838efbc6521e9534 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$debug --mode Diagnose-only --module unfreeze visibility=internal depth=standard mutation=safety-policy web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=unfreeze -->
@@ -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) {
@@ -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.
+13 -5
View File
@@ -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.
+4 -20
View File
@@ -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"
+2 -1
View File
@@ -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"
+2 -1
View File
@@ -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) {
+188
View File
@@ -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.
+13 -5
View File
@@ -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.
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=autoplan/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=b2eaca9fde8f46001bea9961b8ed651d0f6f9e13 baseline_render_sha256=196a12aaa8c77da07af30d7d6187cc73fcda28a2f3660d2880d548e7ac7b633f ported_render_sha256=b11bef3e060400cd59a16e41c9b6f8395ace065867a1c2a70329319153a2c4dc disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=autoplan/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=b2eaca9fde8f46001bea9961b8ed651d0f6f9e13 baseline_render_sha256=196a12aaa8c77da07af30d7d6187cc73fcda28a2f3660d2880d548e7ac7b633f ported_render_sha256=dffedf321083151a10e7377dba6dcfd5507b678c5279f5d47a1b0d91bfe53e09 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Full chain --module autoplan visibility=primary depth=deep mutation=plan-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=autoplan -->
@@ -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=context-restore/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=55889f6e06d3ba896f33a16969959c700bc24f1e baseline_render_sha256=e431da4e5f0639e81b899a280cc92ccc759bb32f4f26e6ffaf2aad02253147bf ported_render_sha256=c14726232c67616485c115dd7ca0898992bab9c7990629b40dc3b6f1c0dec0ee disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=context-restore/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=55889f6e06d3ba896f33a16969959c700bc24f1e baseline_render_sha256=e431da4e5f0639e81b899a280cc92ccc759bb32f4f26e6ffaf2aad02253147bf ported_render_sha256=44c64ae4a63d0ed0fbc427ee2e4f0330d5074c14cdc589baa660dd7a22dbcdf9 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module context-restore visibility=internal depth=standard mutation=state-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=context-restore -->
@@ -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=context-save/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=a3702bc95439cddd87841aba836708bf511ef55d baseline_render_sha256=847b2ef6964b5e2839f7ce37e956ffcaa9cd85069d2e5d12dd1a3f026eb38ca8 ported_render_sha256=348b840c60cecd8818d75f63ebb8e3effae659437137ce75c1ba6124177120b6 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=context-save/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=a3702bc95439cddd87841aba836708bf511ef55d baseline_render_sha256=847b2ef6964b5e2839f7ce37e956ffcaa9cd85069d2e5d12dd1a3f026eb38ca8 ported_render_sha256=f457da8b2cc1f9a4ed8ba82f4ea570a69460ce0147e171b5023d66324b6dc899 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module context-save visibility=internal depth=standard mutation=state-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=context-save -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=learn/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=90d08d2298cccd0d5090f194a2cd76a5361b4323 baseline_render_sha256=6faffb5e7eafbe66828cfa6d363d0e676162a64e7ba0487da952c2c343bfda79 ported_render_sha256=0d1bbdf1bba2eaae206a350d47fb25ab50d49611262cb376861e83f78822f762 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=learn/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=90d08d2298cccd0d5090f194a2cd76a5361b4323 baseline_render_sha256=6faffb5e7eafbe66828cfa6d363d0e676162a64e7ba0487da952c2c343bfda79 ported_render_sha256=b56cfcec0690c47124cf62e40aea16658933b61d44adbe821449f8c65a96a7c4 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module learn visibility=internal depth=standard mutation=state-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=learn -->
@@ -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"
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=office-hours/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=8568fe73cca76a80805fab3092cacd10db7e1d7f baseline_render_sha256=5af4dc503ee149ac5052617ec4d5ad1947c9fbf28c663f40457b0eb07f5fcea3 ported_render_sha256=b633a6ef87928ca2434f3a2b1ce88946cbe528a9e3659469e0f9cbbe8a7b146b disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=office-hours/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=8568fe73cca76a80805fab3092cacd10db7e1d7f baseline_render_sha256=5af4dc503ee149ac5052617ec4d5ad1947c9fbf28c663f40457b0eb07f5fcea3 ported_render_sha256=1a5c9dbda769631df4c3e909fde6b97917780f6a7e9eca5a4edc8c2d0f302052 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module office-hours visibility=primary depth=deep mutation=design-doc-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=office-hours -->
@@ -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"
@@ -31,26 +32,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.
# YC Office Hours
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=plan-ceo-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=c43cfe64146fe79df74a974f1673fe36defcce00 baseline_render_sha256=f64e6c814fc528ebed69a2b59bf66cf4b6ef5f276d009eb27bea4f1c63a40d6c ported_render_sha256=b2f811568920dfdbd500372df13855041ecb2b8b73e3629f3c71d87c5a78e034 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=plan-ceo-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=c43cfe64146fe79df74a974f1673fe36defcce00 baseline_render_sha256=f64e6c814fc528ebed69a2b59bf66cf4b6ef5f276d009eb27bea4f1c63a40d6c ported_render_sha256=811b50b2cf2e20768138c50ef67471402ad1e361bd07958c63107c5081cf21b7 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Product --module plan-ceo-review visibility=primary depth=deep mutation=plan-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=plan-ceo-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"
@@ -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"
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=plan-devex-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=1ef723c10997a08ef87940daceb08bf8d60dd810 baseline_render_sha256=4f77b2fb446e9a79ac5027ab14d097b48d62435d162900d2471e5fa19a0a4605 ported_render_sha256=fff989dcab1f37d0c219378caef6b1e627c23c2537a717564be0d8567229bccc disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=plan-devex-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=1ef723c10997a08ef87940daceb08bf8d60dd810 baseline_render_sha256=4f77b2fb446e9a79ac5027ab14d097b48d62435d162900d2471e5fa19a0a4605 ported_render_sha256=2d39acb36fdc7f4f4b3492ff7dd21081fad98b256fa40d00aeee67bd41343f15 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode DX --module plan-devex-review visibility=primary depth=deep mutation=plan-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=plan-devex-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"
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=plan-eng-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=1d5be0e6f47f0896ee831b53c56818850b1dcfe4 baseline_render_sha256=43051d29df5b9f9fedc34ff9bf6a58560bfb6b9fc7037770f69aeb9bcfb875dd ported_render_sha256=ee268f1d24769509b89b7db998372a773d9e7bc376306a68319af4f74c6fe713 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=plan-eng-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=1d5be0e6f47f0896ee831b53c56818850b1dcfe4 baseline_render_sha256=43051d29df5b9f9fedc34ff9bf6a58560bfb6b9fc7037770f69aeb9bcfb875dd ported_render_sha256=0ed7d4a6ec9dadf363ef5d9311392067a3d90bddac9c1ec781d0f25db55e13ae disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Engineering --module plan-eng-review visibility=primary depth=deep mutation=plan-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=plan-eng-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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=plan-tune/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=dc1214d4c023ed2b126aca8aedb4153b222e73c5 baseline_render_sha256=906afb95787873a7fad1a71f54db620c774f2665b86b091202e6aaad97ce763f ported_render_sha256=306bbf971a2867e1f39cce82d64289d6c457bceb07439ddd6f4a3ff8f8ae5e9a disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=plan-tune/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=dc1214d4c023ed2b126aca8aedb4153b222e73c5 baseline_render_sha256=906afb95787873a7fad1a71f54db620c774f2665b86b091202e6aaad97ce763f ported_render_sha256=f2204078e497a347b48a2b0899400baf3b6ab54f097bbd57d06deb5f71126ce9 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module plan-tune visibility=primary depth=standard mutation=profile-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=plan-tune -->
@@ -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"
+3 -2
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=retro/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=b0819c8a6b736baf489179ba587442cf9973b9d2 baseline_render_sha256=cadf3aa3bbed16ce62179dd1cbd80a864a4eee2ba23afda9c5e750a66f1d6318 ported_render_sha256=c92e69da5c62e4bb5a426f4fb5623b41215e538315e2faebece8ba118cacaf71 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=retro/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=b0819c8a6b736baf489179ba587442cf9973b9d2 baseline_render_sha256=cadf3aa3bbed16ce62179dd1cbd80a864a4eee2ba23afda9c5e750a66f1d6318 ported_render_sha256=d2dd2fe80fbeb5573266700052e0ce5e781dcd31ed986cd9203880bc98c9d9c8 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module retro visibility=internal depth=deep mutation=source-defined web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=retro -->
@@ -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"
@@ -696,7 +697,7 @@ DISCOVER_BIN=""
[ -x $GSTACK_BIN/gstack-global-discover ] && DISCOVER_BIN=$GSTACK_BIN/gstack-global-discover
[ -z "$DISCOVER_BIN" ] && [ -x "$GSTACK_BIN/gstack-global-discover" ] && DISCOVER_BIN="$GSTACK_BIN/gstack-global-discover"
[ -z "$DISCOVER_BIN" ] && which gstack-global-discover >/dev/null 2>&1 && DISCOVER_BIN=$(which gstack-global-discover)
[ -z "$DISCOVER_BIN" ] && [ -f $GSTACK_BIN/gstack-global-discover ] && DISCOVER_BIN="bun run $GSTACK_BIN/gstack-global-discover"
[ -z "$DISCOVER_BIN" ] && [ -f $GSTACK_BIN/gstack-global-discover ] && DISCOVER_BIN="$GSTACK_BIN/gstack-global-discover"
echo "DISCOVER_BIN: $DISCOVER_BIN"
```
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=setup-gbrain/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f48581543b46ecf889f4f86fb7a0d9e75d0bd6ca baseline_render_sha256=5339a256e1d193c198b70387a3f3e0125070807036d2e161f29a640331e6dd73 ported_render_sha256=244ebee93e8871d59c1b0eac040bed362783e48a9b134171d743750ce9a59b29 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=setup-gbrain/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f48581543b46ecf889f4f86fb7a0d9e75d0bd6ca baseline_render_sha256=5339a256e1d193c198b70387a3f3e0125070807036d2e161f29a640331e6dd73 ported_render_sha256=ea0759f957601c05a6e131ee3d1d9e07c732b7852d8a7b10c1fdc259e6e12c3b disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module setup-gbrain visibility=internal depth=standard mutation=configuration web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=setup-gbrain -->
@@ -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"
+2 -2
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=spec/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=6c0c14e1b37e1e56d85427e9c8d080a6df908992 baseline_render_sha256=0a2be9d256d347193b69a6b542de7bde5f4aff9b18983654ab8852bdfac61d60 ported_render_sha256=059ec7a95791f6fc559f4e2321d7340ef5ae7e4ba89949df401f74d91411bfd3 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=spec/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=6c0c14e1b37e1e56d85427e9c8d080a6df908992 baseline_render_sha256=0a2be9d256d347193b69a6b542de7bde5f4aff9b18983654ab8852bdfac61d60 ported_render_sha256=76b7c03e6d8e7b852beda0a58409d97153771dd8f7b870bbdda23e996ac23fc1 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Specification --module spec visibility=primary depth=deep mutation=spec-and-issue web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=spec -->
@@ -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"
@@ -209,7 +210,6 @@ Scan-at-sink on the EXACT bytes that will be sent: write to a temp file, scan th
file, pass the SAME file downstream. Never scan a string then re-render it.
```bash
command -v bun >/dev/null 2>&1 || echo "redaction scan skipped — bun not on PATH"
# Resolve visibility once; cache + reuse. Order: local config ("${GSTACK_HOME:-$HOME/.gstack}", never
# committed) → gh → glab → unknown(=public-strict).
REDACT_VIS=$($GSTACK_BIN/gstack-config get redact_repo_visibility 2>/dev/null)
+3 -2
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=sync-gbrain/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=2ec065472e263a07f3818ed87ee9a6a2e13ca3ae baseline_render_sha256=8f58e6f0dcf18968590db858c7e3f1050d1729cacc573430df6f18bffaee74bf ported_render_sha256=175e7d0d2998bd4c8ddcd9a84228c41ba4a5cf3aca3a8bc09ddfc5a303c1df30 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=sync-gbrain/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=2ec065472e263a07f3818ed87ee9a6a2e13ca3ae baseline_render_sha256=8f58e6f0dcf18968590db858c7e3f1050d1729cacc573430df6f18bffaee74bf ported_render_sha256=973d3d126e97f2c720bc048e6a124e51c0195f9bc7a714ffc41fd57af37f4e73 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module sync-gbrain visibility=internal depth=standard mutation=state-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=sync-gbrain -->
@@ -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"
@@ -170,7 +171,7 @@ Pass user args to the orchestrator. Do not paraphrase them — pass through
as-is.
```bash
bun run $GSTACK_BIN/gstack-gbrain-sync.ts <user-args>
$GSTACK_BIN/gstack-gbrain-sync <user-args>
```
The orchestrator runs three stages: code → memory → brain-sync (per the
@@ -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) {
+188
View File
@@ -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.
+13 -5
View File
@@ -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=benchmark-models/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=034cda182406dc04a82c4336ac3ebc36b5fc41b1 baseline_render_sha256=797e7fd015e0915e33afde6c4b17c1ceecdb254c00943e0a081475a4cd343513 ported_render_sha256=2ef0679d45f21bacc09cd774ff96bb3b82853a8e89d8606847c4f9416a47a48b disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=benchmark-models/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=034cda182406dc04a82c4336ac3ebc36b5fc41b1 baseline_render_sha256=797e7fd015e0915e33afde6c4b17c1ceecdb254c00943e0a081475a4cd343513 ported_render_sha256=3ea3c6cd3542350d4130ee0d5e170c905d0c5c3da096b1fc0e805e095af9321c disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module benchmark-models visibility=internal depth=standard mutation=report-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=benchmark-models -->
@@ -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"
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=benchmark/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=038f16f5fba4ae4e9eae922e3276bba8ef88149e baseline_render_sha256=c1a8019b9b430790f0917df8d58e7a645f01f2784398343d64e5505b535c1ea7 ported_render_sha256=a4554b8b139ee95c9f25c2e747f9e2214b2f0db64401057529e5ec775be323cd disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=benchmark/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=038f16f5fba4ae4e9eae922e3276bba8ef88149e baseline_render_sha256=c1a8019b9b430790f0917df8d58e7a645f01f2784398343d64e5505b535c1ea7 ported_render_sha256=05ac1b123a605201546a7e95899a5b55708ca7fbb7569c58b4d755c52b45a92d disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module benchmark visibility=primary depth=standard mutation=report-only web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=benchmark -->
@@ -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"
@@ -31,26 +32,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.
# $qa --mode Report --module benchmark — Performance Regression Detection
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=browse/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9a159e4c9820172c229e2174d4a62a8f9668ab93 baseline_render_sha256=26c248b90f91a99d1e31e51afaec46385941ab2071bfab7bfdf6d044151ab3ac ported_render_sha256=b7fd526a00444003cad654abb15c2d17606cc5295f38533754e95108038d3467 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=browse/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=9a159e4c9820172c229e2174d4a62a8f9668ab93 baseline_render_sha256=26c248b90f91a99d1e31e51afaec46385941ab2071bfab7bfdf6d044151ab3ac ported_render_sha256=1b532bd904b1fa1686113e8c96b70015ea6b2e6df7319a72c299de901fe5e81b disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module browse visibility=internal depth=standard mutation=source-defined web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=browse -->
@@ -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"
@@ -36,26 +37,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.
## Core QA Patterns
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=canary/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d1eb2950aba2fa2b09d90f13143492c60d46793c baseline_render_sha256=8dd0ff918566e1c5536f1bfcc546eebbdbee5e43d0a05b14a8efb898d3574dbe ported_render_sha256=329b61120f60893d023533cbc493389d1c7476819fb785422de0e7d34e0c2c0b disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=canary/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d1eb2950aba2fa2b09d90f13143492c60d46793c baseline_render_sha256=8dd0ff918566e1c5536f1bfcc546eebbdbee5e43d0a05b14a8efb898d3574dbe ported_render_sha256=89be5f218da2bd812303c87b8c177081727727e5e0d2dc74eb7a73299794d5ef disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module canary visibility=primary depth=deep mutation=report-only web=production -->
<!-- GSTACK2_LEGACY_BODY_START source=canary -->
@@ -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"
@@ -31,26 +32,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.
## Step 0: Detect platform and base branch
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=devex-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=081d4f35bbdec0c6b3da8ae71615ec4d41a84551 baseline_render_sha256=d070b5d50c0b8a59efc7be04881734419f815f01b065ee9d15cf151dba9afb18 ported_render_sha256=9fbc11b3fe252c281581512d67f6915c72e34bb2fdc7bc795e3077ee0e48d34e disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=devex-review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=081d4f35bbdec0c6b3da8ae71615ec4d41a84551 baseline_render_sha256=d070b5d50c0b8a59efc7be04881734419f815f01b065ee9d15cf151dba9afb18 ported_render_sha256=4a907c759b6cf4202fbacaea504b1eb601a53dd35b206109d6c5105168ade7e1 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module devex-review visibility=primary depth=deep mutation=report-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=devex-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"
@@ -70,26 +71,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.
# $qa --mode Report --module devex-review: Live Developer Experience Audit
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=investigate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=67e254d743ffb9060f48e3f6d4b715c077ee688d baseline_render_sha256=e570ad1683d87ce10bb4603f942a3e80bc35b1e56a7e8a7df0ff46c1f551f5d9 ported_render_sha256=91f19c8d736a9a6077941f63f52853634325059e5ac4d8808c4ba6dbceca1a30 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=investigate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=67e254d743ffb9060f48e3f6d4b715c077ee688d baseline_render_sha256=e570ad1683d87ce10bb4603f942a3e80bc35b1e56a7e8a7df0ff46c1f551f5d9 ported_render_sha256=ee817d27e0b303b1e173ab2c572c70f55ef2211225d366a166d2e26cbbe3b2ab disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$debug --mode Diagnose-only --module investigate visibility=primary depth=deep mutation=investigate-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=investigate -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=ios-qa/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=e93d2831a31df65ec8e4e8693919ef5bf148457f baseline_render_sha256=5e84e675bafd3c598f971309f0e062f7a27f14d2415d32a90aea841210f0464a ported_render_sha256=a9353a4d837240f85c0e13144e66575f9e267926090cfc0f344a08b5fb09b98c disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=ios-qa/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=e93d2831a31df65ec8e4e8693919ef5bf148457f baseline_render_sha256=5e84e675bafd3c598f971309f0e062f7a27f14d2415d32a90aea841210f0464a ported_render_sha256=73ea0aabaa9e7c8fdd4ae279f519303a78682540b2340816f97e239718908e62 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module ios-qa visibility=primary depth=deep mutation=report-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=ios-qa -->
@@ -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=open-gstack-browser/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ef91a527890a3ac3622cc7dc84bad1ff7b64443b baseline_render_sha256=f68b483619f37175687c64510c4de5c718ad3aa2d644134f6539d28df3a9ad7c ported_render_sha256=9e4a08db3e17badfc67703f7c5e66f80a7f05e0b96fad31fc48b8216964d7449 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=open-gstack-browser/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ef91a527890a3ac3622cc7dc84bad1ff7b64443b baseline_render_sha256=f68b483619f37175687c64510c4de5c718ad3aa2d644134f6539d28df3a9ad7c ported_render_sha256=df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module open-gstack-browser visibility=internal depth=standard mutation=configuration web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=open-gstack-browser -->
@@ -12,10 +12,17 @@ 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"
```
## Visible-browser point-of-use gate
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.
# $qa --mode Report --module open-gstack-browser — Launch GStack Browser
Launch GStack Browser — AI-controlled Chromium with the sidebar extension,
@@ -36,26 +43,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.
## Step 0: Pre-flight cleanup
+10 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=pair-agent/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75ed42d590f99c46cd0883c37bb1f2f9f499211c baseline_render_sha256=6bb659c03b5df7c36f446fad30aaec4ab6d5e0d25fb8392702573e66923b02fb ported_render_sha256=6882bc549c689ff50fb06d5e72a597939aa651926c50067330d1e6fd86cd6b58 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=pair-agent/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75ed42d590f99c46cd0883c37bb1f2f9f499211c baseline_render_sha256=6bb659c03b5df7c36f446fad30aaec4ab6d5e0d25fb8392702573e66923b02fb ported_render_sha256=8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module pair-agent visibility=internal depth=standard mutation=configuration web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=pair-agent -->
@@ -12,10 +12,17 @@ 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"
```
## Visible-browser point-of-use gate
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.
# $qa --mode Report --module pair-agent — Share Your Browser With Another AI Agent
You're sitting in Claude Code with a browser running. You also have another AI agent
@@ -54,26 +61,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.
## Step 1: Check prerequisites
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=qa-only/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75c4123cc5c406ffdd36c71a094335c137135b1e baseline_render_sha256=7f8c42379e748156bf131a5bae121ab9a3f307e57619e261f5db7865eead3029 ported_render_sha256=e360ea826399287e00b030c9aa0bcda16e06ab004f5dcdb14db309bad0ccea25 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=qa-only/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75c4123cc5c406ffdd36c71a094335c137135b1e baseline_render_sha256=7f8c42379e748156bf131a5bae121ab9a3f307e57619e261f5db7865eead3029 ported_render_sha256=376eff42459f5b8755bd95934cce615db0fca16504c8e82f84b2704c63f62af3 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module qa-only visibility=primary depth=deep mutation=report-only web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=qa-only -->
@@ -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"
@@ -51,26 +52,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.
**Create output directories:**
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=qa/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=11997f7b878282c34b6bfd3d4b7a8131f9ad4da8 baseline_render_sha256=07e2c6a841c6701d186b3b6536cfdb87af49566029971502065f636576ba071c ported_render_sha256=b1387290067842593297c8d64dc578a890a336ce5a8b60085e51b52c55ebaade disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=qa/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=11997f7b878282c34b6bfd3d4b7a8131f9ad4da8 baseline_render_sha256=07e2c6a841c6701d186b3b6536cfdb87af49566029971502065f636576ba071c ported_render_sha256=e7cd5615adaf54413daa97838cb364810317dd7d661cec5cc4ed40eb48192e55 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Fix --module qa visibility=primary depth=deep mutation=fix-safe web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=qa -->
@@ -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"
@@ -122,26 +123,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):**
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=scrape/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=4cb4f17c074edcdce0bc8d133f19a6a739964851 baseline_render_sha256=fc2f222a75825e92458b3503bc122edda87cfe9d92a41c4e87e89fc68d5bdcd5 ported_render_sha256=03ef708a4c9a1f3de961c48767617502c564690faa7240b3ca39b20496a17f98 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=scrape/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=4cb4f17c074edcdce0bc8d133f19a6a739964851 baseline_render_sha256=fc2f222a75825e92458b3503bc122edda87cfe9d92a41c4e87e89fc68d5bdcd5 ported_render_sha256=82b7214021c997000822b2b4adcae19443490198556a7e6af54d8937ae504943 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module scrape visibility=internal depth=standard mutation=report-only web=production -->
<!-- GSTACK2_LEGACY_BODY_START source=scrape -->
@@ -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=setup-browser-cookies/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f812d9f56f27c32fb5f102083bbe418344c1a652 baseline_render_sha256=22b03503fa8ba63de98866d64ab0563291f4b41e1577c8022d09add3e0bdb59c ported_render_sha256=8ab5dfb05136a637ce41067d9e50ade76c4a8b957587e4eab134eaa260b29ac3 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=setup-browser-cookies/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f812d9f56f27c32fb5f102083bbe418344c1a652 baseline_render_sha256=22b03503fa8ba63de98866d64ab0563291f4b41e1577c8022d09add3e0bdb59c ported_render_sha256=04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module setup-browser-cookies visibility=internal depth=standard mutation=configuration web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=setup-browser-cookies -->
@@ -12,10 +12,17 @@ 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"
```
## Visible-browser point-of-use gate
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.
# Setup Browser Cookies
Import logged-in sessions from your real Chromium browser into the headless browse session.
@@ -54,26 +61,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.
### 2. Open the cookie picker
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=skillify/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=21fb2f503e3d40683fa782b21abf05a8f0fef69b baseline_render_sha256=dae4eabf35e1e8f5cadf5e238774d3ccdfdf48bff526f9d902a564af79ecd49a ported_render_sha256=b0743c957157e19bd90b457fd3ac9d6924c3aedce969c10af8d7973c2bcd6c9f disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=skillify/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=21fb2f503e3d40683fa782b21abf05a8f0fef69b baseline_render_sha256=dae4eabf35e1e8f5cadf5e238774d3ccdfdf48bff526f9d902a564af79ecd49a ported_render_sha256=8743671a165c951e852d1f37418aa6c0bd7eeb4f32866dc25f55f7d8eb26f1d5 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module skillify visibility=internal depth=standard mutation=code-generation web=local-browser -->
<!-- GSTACK2_LEGACY_BODY_START source=skillify -->
@@ -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) {
@@ -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.
+13 -5
View File
@@ -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.
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=codex/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=333de7d8d590cc78184b0e2371171f6121408f73 baseline_render_sha256=55c274320bd6e2085ffe9c12ceab336ef2f9e2ba7c7b6500f797db9bea708065 ported_render_sha256=0df6ff685d230f87763b4c43f957e20854fe1319d78247d683e5250e2accb188 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=codex/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=333de7d8d590cc78184b0e2371171f6121408f73 baseline_render_sha256=55c274320bd6e2085ffe9c12ceab336ef2f9e2ba7c7b6500f797db9bea708065 ported_render_sha256=94ebbe955a1063f88712c51723023765519eab8986b029bf1222d0ec19b79e12 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$review --mode Deep --module codex visibility=primary depth=standard mutation=report-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=codex -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=cso/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=413fb099597b55dadca116e45a202aa693a94b74 baseline_render_sha256=1878443a5ffe2b5535bb39106a97b9003e180501c0dfd4fd77ce873cdb737e79 ported_render_sha256=2cecaf39e950a0ec848f85c11f9678b6a7644c0251c07be42cd52d4b2e6b61a6 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=cso/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=413fb099597b55dadca116e45a202aa693a94b74 baseline_render_sha256=1878443a5ffe2b5535bb39106a97b9003e180501c0dfd4fd77ce873cdb737e79 ported_render_sha256=d79cf702cef33e878da8569c63936e3bdf811b194d8abbce925b6bb759f05cf7 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$review --mode Security --module cso visibility=primary depth=deep mutation=report-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=cso -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=health/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f92eb7347ec4f77dd8dbf464d63fcdf6a3459908 baseline_render_sha256=34884311c30b46d7dbb814d4ccd238c6ca74a9fe54f9df50bbb2838a340be4d9 ported_render_sha256=ff664221e7de57e66c1b45d719982d90f38e0288914b34731ba4dd73541347e8 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=health/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f92eb7347ec4f77dd8dbf464d63fcdf6a3459908 baseline_render_sha256=34884311c30b46d7dbb814d4ccd238c6ca74a9fe54f9df50bbb2838a340be4d9 ported_render_sha256=514ff165b393a25707c9ed336869ac0b50c63a00d90a21b1656276e1b97842d9 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$review --mode Deep --module health visibility=primary depth=standard mutation=report-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=health -->
@@ -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"
+3 -2
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ba1ea88068de4b09cf717eb4ae42aa247d198314 baseline_render_sha256=d1fb8cf7b1a058fe144dc47ce29c69332aaa68f553b424e85b0d34d9e4918f06 ported_render_sha256=9d6398fe0d2900fcce8ae1eb0b390e21f05c94d6ae3a4e21c911025cdc1fd963 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=review/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ba1ea88068de4b09cf717eb4ae42aa247d198314 baseline_render_sha256=d1fb8cf7b1a058fe144dc47ce29c69332aaa68f553b424e85b0d34d9e4918f06 ported_render_sha256=0afa76278ec061e56a36048eb9034a2cda6e56ea70bba321a0414bc279ddf84b disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$review --mode Normal --module review visibility=primary depth=deep mutation=fix-safe web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=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"
@@ -349,7 +350,7 @@ Check whether this PR's claimed VERSION still points at a free slot in the queue
BRANCH_VERSION=$(git show HEAD:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
BASE_BRANCH=$(gh pr view --json baseRefName -q .baseRefName 2>/dev/null || echo main)
BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:space:]' || echo "")
QUEUE_JSON=$(bun run $GSTACK_BIN/gstack-next-version \
QUEUE_JSON=$($GSTACK_BIN/gstack-next-version \
--base "$BASE_BRANCH" \
--bump patch \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
@@ -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) {
+188
View File
@@ -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.
+13 -5
View File
@@ -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.
+4 -20
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=canary/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d1eb2950aba2fa2b09d90f13143492c60d46793c baseline_render_sha256=8dd0ff918566e1c5536f1bfcc546eebbdbee5e43d0a05b14a8efb898d3574dbe ported_render_sha256=329b61120f60893d023533cbc493389d1c7476819fb785422de0e7d34e0c2c0b disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=canary/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d1eb2950aba2fa2b09d90f13143492c60d46793c baseline_render_sha256=8dd0ff918566e1c5536f1bfcc546eebbdbee5e43d0a05b14a8efb898d3574dbe ported_render_sha256=89be5f218da2bd812303c87b8c177081727727e5e0d2dc74eb7a73299794d5ef disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module canary visibility=primary depth=deep mutation=report-only web=production -->
<!-- GSTACK2_LEGACY_BODY_START source=canary -->
@@ -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"
@@ -31,26 +32,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.
## Step 0: Detect platform and base branch
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=context-restore/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=55889f6e06d3ba896f33a16969959c700bc24f1e baseline_render_sha256=e431da4e5f0639e81b899a280cc92ccc759bb32f4f26e6ffaf2aad02253147bf ported_render_sha256=c14726232c67616485c115dd7ca0898992bab9c7990629b40dc3b6f1c0dec0ee disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=context-restore/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=55889f6e06d3ba896f33a16969959c700bc24f1e baseline_render_sha256=e431da4e5f0639e81b899a280cc92ccc759bb32f4f26e6ffaf2aad02253147bf ported_render_sha256=44c64ae4a63d0ed0fbc427ee2e4f0330d5074c14cdc589baa660dd7a22dbcdf9 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module context-restore visibility=internal depth=standard mutation=state-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=context-restore -->
@@ -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=document-generate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d3ef0cbc3786c4010b3c692fb94246f56a7e1d7b baseline_render_sha256=5412105d826c409192d420311f05f9d6549c993e5d425b4cea37c93235624a15 ported_render_sha256=71726ef3082b6c00a5cad19d28da4d830f8e9b261d213a7938c4e1ce3b8c54bb disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=document-generate/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=d3ef0cbc3786c4010b3c692fb94246f56a7e1d7b baseline_render_sha256=5412105d826c409192d420311f05f9d6549c993e5d425b4cea37c93235624a15 ported_render_sha256=d91adb16f1749c24bc7a05aa6cff3ff00bb0db005872751f417e66de2057e620 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Prepare --module document-generate visibility=internal depth=standard mutation=docs-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=document-generate -->
@@ -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=document-release/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=7621cb31290217b5c7cc8fb000e910b3dd38128f baseline_render_sha256=2aafac6ff412ccc65ce702ad4c705d23cd7639a39a84b4d034fe1e91a10220d2 ported_render_sha256=e5aa85ac93b36b638d075d152f9d7108c0108727056f61331b25fe33de569a59 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=document-release/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=7621cb31290217b5c7cc8fb000e910b3dd38128f baseline_render_sha256=2aafac6ff412ccc65ce702ad4c705d23cd7639a39a84b4d034fe1e91a10220d2 ported_render_sha256=21de7096aa788e3c4206587761f3122016f4cfc3080ec07f07cd7698075307a5 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Prepare --module document-release visibility=primary depth=deep mutation=docs-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=document-release -->
@@ -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"
+2 -1
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=ios-sync/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=156a33c4c35d3bad804e44c93ae4c931878489f3 baseline_render_sha256=3b3bbcd550b43c26761774c1f9c461c3af53a1b4cbcbc722d9dbc024d317acbd ported_render_sha256=c51e8865876efae833e92d54b37780afd4fa6240c13231dfb7d4635948fbc73f disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=ios-sync/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=156a33c4c35d3bad804e44c93ae4c931878489f3 baseline_render_sha256=3b3bbcd550b43c26761774c1f9c461c3af53a1b4cbcbc722d9dbc024d317acbd ported_render_sha256=88f9f20aa406c13098ebd2709afc6487bb42c5466ac9f757bf4d9baff7f028d0 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Prepare --module ios-sync visibility=internal depth=standard mutation=code-generation web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=ios-sync -->
@@ -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=land-and-deploy/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=98976ad020d541d251cc7e34802a13458ddc88e2 baseline_render_sha256=be77d9332d68281785eb2daf1d094f53bad537dfa282a4abb8638aca398cd2b9 ported_render_sha256=3fab0678a9a7a76664330c74db2bd25b56c335ac72ce4e691c2f2564a7ede819 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=land-and-deploy/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=98976ad020d541d251cc7e34802a13458ddc88e2 baseline_render_sha256=be77d9332d68281785eb2daf1d094f53bad537dfa282a4abb8638aca398cd2b9 ported_render_sha256=6920f3d97ce474b8f20c8b3e38ca9d3c03973e47af33103a60bab7c02eb867bd disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Land --module land-and-deploy visibility=primary depth=deep mutation=merge-deploy web=production -->
<!-- GSTACK2_LEGACY_BODY_START source=land-and-deploy -->
@@ -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"
@@ -31,26 +32,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.
## Step 0: Detect platform and base branch
@@ -439,7 +423,7 @@ BASE_VERSION=$(git show origin/$BASE_BRANCH:VERSION 2>/dev/null | tr -d '\r\n[:s
# We don't need the exact original level — we just need "a level" that passes to the util.
# If the minor digit advanced, call it minor; patch digit, patch; etc. If base > branch, skip (not ours to land).
# For simplicity: use "patch" as a conservative default; util handles collision-past regardless of input level.
QUEUE_JSON=$(bun run $GSTACK_BIN/gstack-next-version \
QUEUE_JSON=$($GSTACK_BIN/gstack-next-version \
--base "$BASE_BRANCH" \
--bump patch \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=landing-report/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=32a8cc1ab036b1ff8692f0400b68d8b56224a251 baseline_render_sha256=bfffdaf9d78e60374207ec8119b735e3e2d094f8a0dc1bb8c0abed5f085b4569 ported_render_sha256=4f9512bafcfd995c4c35cdff869ed60f8a9a9ef12f20bc18bf7b898cbd2faa53 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=landing-report/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=32a8cc1ab036b1ff8692f0400b68d8b56224a251 baseline_render_sha256=bfffdaf9d78e60374207ec8119b735e3e2d094f8a0dc1bb8c0abed5f085b4569 ported_render_sha256=ae5050b0558b5324f58ee9181dc83ac0189b63da70d94f86792c08e112765fe9 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Prepare --module landing-report visibility=primary depth=standard mutation=report-only web=none -->
<!-- GSTACK2_LEGACY_BODY_START source=landing-report -->
@@ -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"
@@ -64,7 +65,7 @@ they'd claim for micro/patch/minor/major. Cheap (same gh call cached by bun).
```bash
for LEVEL in micro patch minor major; do
bun run $GSTACK_BIN/gstack-next-version \
$GSTACK_BIN/gstack-next-version \
--base "$BASE_BRANCH" \
--bump "$LEVEL" \
--current-version "$BASE_VERSION" \
+5 -4
View File
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=ship/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=068ac4fe54bcb46572295d48263b11bd38fcde18 baseline_render_sha256=604d742ea769fc7e0e46ddc2dc9130331c8137e470718290afe9f3c015ab2702 ported_render_sha256=ab7a7219653b0fc5cdad0bb2959394798bc50ae4db4dc24b76c0454dd1b1397a disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=ship/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=068ac4fe54bcb46572295d48263b11bd38fcde18 baseline_render_sha256=604d742ea769fc7e0e46ddc2dc9130331c8137e470718290afe9f3c015ab2702 ported_render_sha256=ca55c56483c524c732cf4cd1a7b7ef36f379666577549ef989823b98bce8c5b5 disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$ship --mode Prepare --module ship visibility=primary depth=deep mutation=commit-push-pr web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=ship -->
@@ -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"
@@ -251,7 +252,7 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
1. **Classify state** — pure reader, never writes:
```bash
bun run $GSTACK_BIN/gstack-version-bump classify --base <base>
$GSTACK_BIN/gstack-version-bump classify --base <base>
```
Read the JSON `state` and dispatch:
- **FRESH** → do the bump (steps 2-4).
@@ -266,14 +267,14 @@ stay agent judgment; the slot pick stays `gstack-next-version`.
3. **Queue-aware pick** (workspace-aware ship):
```bash
QUEUE_JSON=$(bun run $GSTACK_BIN/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
QUEUE_JSON=$($GSTACK_BIN/gstack-next-version --base <base> --bump "$BUMP_LEVEL" --current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
```
If `offline`/util fails: fall back to local `BUMP_LEVEL` arithmetic and print `⚠ workspace-aware ship offline — using local bump only`. If `claimed` is non-empty, render the queue table so the user sees landing order. If an active sibling workspace holds a version `>= NEW_VERSION`, **AskUserQuestion**: advance past (unrelated work) or abort and sync with the sibling.
4. **Write the bump** (FRESH, or an approved rebump):
```bash
bun run $GSTACK_BIN/gstack-version-bump write --version "$NEW_VERSION"
$GSTACK_BIN/gstack-version-bump write --version "$NEW_VERSION"
```
The CLI validates the 4-digit `MAJOR.MINOR.PATCH.MICRO` pattern and writes **both** VERSION and package.json. On a half-write (VERSION written, package.json failed) it exits 3 — re-run, and classify will report DRIFT_STALE_PKG for `repair` to fix.
@@ -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) {