feat: require explicit browser provider consent

This commit is contained in:
Sinabina
2026-07-21 12:07:01 -07:00
parent 75b3576670
commit e3effb3fc4
48 changed files with 2798 additions and 220 deletions
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -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=df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=open-gstack-browser/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ef91a527890a3ac3622cc7dc84bad1ff7b64443b baseline_render_sha256=f68b483619f37175687c64510c4de5c718ad3aa2d644134f6539d28df3a9ad7c ported_render_sha256=e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6 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 -->
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
# $qa --mode Report --module open-gstack-browser — Launch GStack Browser
+2 -2
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=8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=pair-agent/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75ed42d590f99c46cd0883c37bb1f2f9f499211c baseline_render_sha256=6bb659c03b5df7c36f446fad30aaec4ab6d5e0d25fb8392702573e66923b02fb ported_render_sha256=256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a 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 -->
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
# $qa --mode Report --module pair-agent — Share Your Browser With Another AI Agent
@@ -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=04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=setup-browser-cookies/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f812d9f56f27c32fb5f102083bbe418344c1a652 baseline_render_sha256=22b03503fa8ba63de98866d64ab0563291f4b41e1577c8022d09add3e0bdb59c ported_render_sha256=7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8 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 -->
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
# Setup Browser Cookies
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([
+6 -4
View File
@@ -3,15 +3,17 @@
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <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.
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <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.
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <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.
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
@@ -0,0 +1,154 @@
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
const NAMED_CANDIDATES = Object.freeze({
darwin: Object.freeze([
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
]),
win32: Object.freeze([
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
]),
});
const PATH_CANDIDATES = Object.freeze([
["Google Chrome", "google-chrome"],
["Google Chrome", "google-chrome-stable"],
["Chromium", "chromium"],
["Chromium", "chromium-browser"],
["Microsoft Edge", "microsoft-edge"],
["Microsoft Edge", "microsoft-edge-stable"],
["Brave", "brave-browser"],
]);
export function browserChoiceRequired(capabilities) {
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
}
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
throw browserChoiceError(
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
"BROWSER_PROVIDER_UNSUPPORTED",
);
}
return choice;
}
export function applyBrowserProviderToComponents(components, choice) {
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
return Object.freeze(components
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
.sort());
}
export async function detectInstalledBrowsers(options = {}) {
if (Array.isArray(options.candidates)) {
const resolved = [];
for (const candidate of options.candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
const platform = options.platform ?? process.platform;
const env = options.env ?? process.env;
const homeDir = options.homeDir ?? os.homedir();
const candidates = [];
if (platform === "darwin") {
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
candidates.push({ name, executablePath });
candidates.push({
name,
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
});
}
} else if (platform === "win32") {
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
const base = env[variable];
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
}
} else if (platform === "linux") {
for (const [name, command] of PATH_CANDIDATES) {
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
candidates.push({ name, executablePath: path.join(directory, command) });
}
}
}
const resolved = [];
for (const candidate of candidates) {
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
if (browser) resolved.push(browser);
}
return deduplicate(resolved);
}
export async function resolveBrowserChoice(choice, options = {}) {
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
throw browserChoiceError(
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
"BROWSER_CHOICE_REQUIRED",
);
}
if (choice.provider === "managed") {
if (choice.executablePath != null) {
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
}
return Object.freeze({ provider: "managed", executablePath: null });
}
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
}
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
if (!inspected) {
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
}
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
}
async function inspectCandidate(name, executablePath, options) {
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
const fs_ = options.fs ?? fs;
try {
const invocationPath = path.resolve(executablePath);
const physical = await fs_.realpath(invocationPath);
const stat = await fs_.lstat(physical);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
} catch {
return null;
}
}
function deduplicate(candidates) {
const seen = new Set();
return Object.freeze(candidates.flatMap((candidate) => {
const identity = candidate.physicalPath ?? candidate.executablePath;
if (seen.has(identity)) return [];
seen.add(identity);
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
}));
}
function browserChoiceError(message, code) {
const error = new Error(message);
error.code = code;
return error;
}
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
import { constants as fsConstants, createReadStream } from "node:fs";
import { spawn } from "node:child_process";
import { fileURLToPath } from "node:url";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
export const BOOTSTRAP_SCHEMA_VERSION = 2;
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
// Keep the runtime compatibility version separate from the immutable release
// channel. Release candidates carry the 2.0.0 runtime contract while letting
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
io.stdout.write(usage());
return 0;
}
if (!["preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
if (!["options", "preview", "install"].includes(parsed.action)) {
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
}
const platform = options.platform ?? process.platform;
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
}
const requiresBrowser = browserChoiceRequired(parsed.capabilities);
if (parsed.action === "options") {
if (!requiresBrowser) {
throw bootstrapError("Browser options apply only to browser-backed capabilities", "BOOTSTRAP_USAGE");
}
const detected = await detectInstalledBrowsers({
platform,
env: options.env,
homeDir: options.homeDir,
candidates: options.browserCandidates,
});
const installedSupported = !parsed.capabilities.includes("browser-visible");
const installed = detected.map((browser) => ({
...browser,
supported: installedSupported,
...(installedSupported ? {} : { reason: "Visible GStack Browser requires managed Chromium for extension loading" }),
}));
const result = {
managed: {
provider: "managed",
description: "GStack-managed isolated Chromium; exact signed component bytes are shown by preview before consent",
},
installed,
mutated: false,
network: false,
};
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: "options", ...result }, null, 2)}\n`);
else printBrowserOptions(io.stdout, result);
return 0;
}
let browserChoice = null;
if (requiresBrowser) {
browserChoice = await resolveBrowserChoice({
provider: parsed.browserProvider,
executablePath: parsed.browserPath,
}, { platform, env: options.env, homeDir: options.homeDir });
assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
} else if (parsed.browserProvider || parsed.browserPath) {
throw bootstrapError("Browser options require a browser-backed capability", "BOOTSTRAP_USAGE");
}
if (parsed.source) {
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false, browserChoice });
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable, browserChoice);
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
else printComponentPlan(io.stdout, plan);
if (parsed.action === "preview") return 0;
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
await assertNoLinks(componentRoot);
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
}
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version });
return await installFromSource(root, parsed, { ...options, ...io, prepared: true, version: manifest.version, browserChoice });
} finally {
await fs.rm(temporary, { recursive: true, force: true });
}
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
}
function parseArgs(argv) {
const result = { action: null, capabilities: [], source: null, home: null, yes: false, json: false, help: false };
const result = {
action: null,
capabilities: [],
source: null,
home: null,
browserProvider: null,
browserPath: null,
yes: false,
json: false,
help: false,
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (["-h", "--help"].includes(arg)) result.help = true;
else if (arg === "--yes") result.yes = true;
else if (arg === "--json") result.json = true;
else if (!result.action && !arg.startsWith("-")) result.action = arg;
else if (["--capability", "--source", "--home"].includes(arg)) {
else if (["--capability", "--source", "--home", "--browser", "--browser-path"].includes(arg)) {
const value = argv[++index];
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
if (arg === "--capability") result.capabilities.push(value);
else if (arg === "--source") result.source = value;
else result.home = value;
else if (arg === "--home") result.home = value;
else if (arg === "--browser") result.browserProvider = value;
else result.browserPath = value;
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
}
if (result.help) return result;
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
if (result.action === "options" && (result.yes || result.source || result.browserProvider || result.browserPath)) {
throw bootstrapError("options cannot be combined with install or browser-selection flags", "BOOTSTRAP_USAGE");
}
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw bootstrapError("--browser must be `managed` or `installed`", "BOOTSTRAP_USAGE");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw bootstrapError("--browser-path is valid only with `--browser installed`", "BOOTSTRAP_USAGE");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw bootstrapError("--browser-path requires `--browser installed`", "BOOTSTRAP_USAGE");
}
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
result.capabilities = [...new Set(result.capabilities)].sort();
for (const capability of result.capabilities) {
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
}
function selectedComponents(capabilities) {
function selectedComponents(capabilities, browserChoice) {
const selected = new Set(["core"]);
for (const capability of capabilities) {
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
}
}
}
return [...selected].sort();
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
const downloads = components
.filter((component) => !retained.has(component))
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
function printComponentPlan(stdout, plan) {
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\n`);
if (plan.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${plan.browser.executablePath}; isolated automation profile, no Chromium download\n`);
} else if (plan.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium\n");
}
stdout.write(`Components: ${plan.components.join(", ")}\n`);
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.join(", ")}\n`);
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
const stat = await fs.lstat(installer).catch(() => null);
if (!stat?.isFile() || stat.isSymbolicLink()) throw bootstrapError("Source does not contain a safe runtime installer", "BOOTSTRAP_SOURCE_INVALID");
const args = [installer, "--source", physical, "--install-now", "--yes", "--capabilities", parsed.capabilities.join(",")];
if (options.browserChoice) {
args.push("--browser", options.browserChoice.provider);
if (options.browserChoice.executablePath) args.push("--browser-path", options.browserChoice.executablePath);
}
if (parsed.home) args.push("--home", path.resolve(parsed.home));
if (options.version) args.push("--version", options.version);
if (options.prepared) args.push("--prepared");
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
}
function usage() {
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\n" +
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
" node runtime-bootstrap.mjs preview|install --capability <name> [--capability <name>...]\n" +
" --browser managed|installed [--browser-path <absolute-path>] [--yes]\n" +
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --browser <choice>\n\n" +
"Downloads only a versioned official GStack runtime release and never enrolls a coding host.\n" +
"--source is a developer-only fallback for a checkout you have reviewed and trust.\n";
}
function printBrowserOptions(stdout, result) {
stdout.write("GStack browser setup options (no network access and no changes made)\n");
stdout.write(`managed: ${result.managed.description}\n`);
if (!result.installed.length) stdout.write("installed: no supported Chromium executable detected; an absolute path may be supplied explicitly\n");
for (const browser of result.installed) stdout.write(browser.supported
? `installed: ${browser.name}${browser.executablePath}\n`
: `installed (unavailable for this capability): ${browser.name}${browser.executablePath}; ${browser.reason}\n`);
stdout.write("No provider is selected until the user chooses one and separately approves the previewed install.\n");
}
async function isDirectExecution() {
if (!process.argv[1]) return false;
const [modulePath, invokedPath] = await Promise.all([