Merge codex/gstack-2 into gstack2-runtime-integration

Reconcile the four integrated v2 runtime implementations (unified execution
result contract, execution profiles, capability readiness, GitHub security)
with main's browser-provider hardening.

Conflict resolutions:
- runtimeContract() generator: keep new execution-result + doctor-capability
  paragraphs, adopt main's `[matching browser flags]` fallback wording;
  regenerate the six RUNTIME.md.
- package.json: keep the strict isolated test:gstack2 runner and marked 18.0.6
  security bump; adopt main's playwright-core alias.
- bun.lock: regenerated via bun install.
- release-hardening.test.ts: adopt main's browser-provider assertions
  (resolveServerLaunchTarget, --browser managed smoke loop).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sinabina
2026-07-21 13:15:03 -07:00
co-authored by Claude Opus 4.8
160 changed files with 4397 additions and 522 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.
@@ -25,4 +27,4 @@ Every optional-runtime tool result must satisfy `references/support/execution-re
Use `gstack doctor --capability browser|design|diagram|pdf|ios` (optionally `--json`) for a non-mutating, capability-specific readiness result. Its independent axes must remain distinct: pure judgment availability, platform support, preview consent, install consent, and runtime readiness. Readiness is exactly `ready`, `degraded`, `unavailable`, `unsupported`, or `failed`. Doctor never grants or persists consent, previews metadata, or installs anything. `unavailable` means setup may be offered; `failed` means selected runtime evidence failed; `unsupported` is a platform boundary; and `degraded` means the capability passed while the managed runtime has a warning.
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 -1
View File
@@ -7,4 +7,4 @@ When no public-web choice is stored, present: A) Context.dev free setup (recomme
Persist only the explicit choice with `gstack context select host`, `gstack context select local-browser`, or `gstack context select none`. For Context.dev, show `gstack context options`, then use `gstack context setup` and its hidden key prompt; consent and key storage belong to the runtime, never this judgment prompt. Do not infer Context choice or consent.
Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack` and is never inferred from runtime state.
Capability-dependent work follows `references/RUNTIME.md`. Pure judgment never requires the runtime. Skill placement remains owned by `npx skills add time-attack/gstack/skills` and is never inferred from runtime state.
@@ -1,5 +1,5 @@
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
<!-- GSTACK2_PROVENANCE source=office-hours/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=8568fe73cca76a80805fab3092cacd10db7e1d7f baseline_render_sha256=5af4dc503ee149ac5052617ec4d5ad1947c9fbf28c663f40457b0eb07f5fcea3 ported_render_sha256=1a5c9dbda769631df4c3e909fde6b97917780f6a7e9eca5a4edc8c2d0f302052 disposition=BUG_FIX -->
<!-- GSTACK2_PROVENANCE source=office-hours/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=8568fe73cca76a80805fab3092cacd10db7e1d7f baseline_render_sha256=5af4dc503ee149ac5052617ec4d5ad1947c9fbf28c663f40457b0eb07f5fcea3 ported_render_sha256=ebb8816907a17722d1e1d227de782684870805368fcdc07f69347f32d907a9ba disposition=BUG_FIX -->
<!-- GSTACK2_ROUTING replacement=$plan --mode Discovery --module office-hours visibility=primary depth=deep mutation=design-doc-only web=optional -->
<!-- GSTACK2_LEGACY_BODY_START source=office-hours -->
@@ -32,7 +32,7 @@ fi
```
If `NEEDS_SETUP`:
1. Tell the user: "The optional managed headless browser capability is missing. Do you want to preview its exact dependency-closed component plan and compressed bytes now?" Then STOP and wait.
1. Tell the user: "The browser-backed capability is not ready. Do you want to see the local setup options—GStack-managed Chromium or a detected installed Chromium executable—with no network access or changes?" Then STOP and wait.
2. Read `references/RUNTIME.md` and follow its explicit capability bootstrap. Never assume a standard-installed skill directory contains `./setup`.
3. The approved managed runtime includes its own pinned Bun at `$GSTACK_BIN/bun`; never download or install another Bun from a skill workflow.
@@ -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,11 +10,22 @@ 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.6";
export const OFFICIAL_MANIFEST_URL =
`https://github.com/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/gstack-runtime-manifest.json`;
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/gstack-runtime-manifest.json`;
const CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
const CAPABILITY_DEPENDENCIES = Object.freeze({
browser: Object.freeze([]),
@@ -47,9 +58,9 @@ const ALLOWED_DOWNLOAD_HOSTS = new Set([
"objects.githubusercontent.com",
"release-assets.githubusercontent.com",
]);
const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/v${BOOTSTRAP_RUNTIME_VERSION}/`;
const OFFICIAL_RELEASE_PREFIX = `/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/`;
const OFFICIAL_CERTIFICATE_IDENTITY =
`https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/v${BOOTSTRAP_RUNTIME_VERSION}`;
`https://github.com/time-attack/gstack/.github/workflows/release-artifacts.yml@refs/tags/${BOOTSTRAP_RELEASE_TAG}`;
const GITHUB_OIDC_ISSUER = "https://token.actions.githubusercontent.com";
export async function main(argv = process.argv.slice(2), options = {}) {
@@ -63,22 +74,85 @@ 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) {
const sourceHome = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const active = await inspectReusableRuntime(sourceHome, BOOTSTRAP_RUNTIME_VERSION).catch(() => null);
if (!browserChoice && active?.browserChoice) {
browserChoice = await resolveBrowserChoice(active.browserChoice, {
platform,
env: options.env,
homeDir: options.homeDir,
});
}
parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice);
if (browserChoiceRequired(parsed.capabilities) && !browserChoice) {
throw bootstrapError(
"The active browser capability does not record a reusable browser provider; choose a browser provider before changing this runtime.",
"BOOTSTRAP_BROWSER_CHOICE_REQUIRED",
);
}
if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
if (parsed.action === "preview") {
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
return 0;
}
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
io.stderr.write("Developer-only source install: only continue with a checkout you reviewed and trust.\n");
return await installFromSource(parsed.source, parsed, { ...options, ...io, prepared: false });
return await installFromSource(parsed.source, parsed, {
...options,
...io,
prepared: false,
replaceCapabilities: true,
browserChoice,
});
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -90,11 +164,29 @@ export async function main(argv = process.argv.slice(2), options = {}) {
);
const manifestUrl = options.manifestUrl ?? OFFICIAL_MANIFEST_URL;
assertOfficialUrl(manifestUrl, { manifest: true });
const manifest = await fetchJson(fetch_, manifestUrl);
const manifest = await fetchJson(fetch_, manifestUrl, {
official: manifestUrl === OFFICIAL_MANIFEST_URL,
});
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
const active = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const reusable = active?.releaseMatches ? active : null;
if (!browserChoice && active?.browserChoice) {
browserChoice = await resolveBrowserChoice(active.browserChoice, {
platform,
env: options.env,
homeDir: options.homeDir,
});
}
parsed.capabilities = mergeRetainedCapabilities(parsed.capabilities, active, browserChoice);
if (browserChoiceRequired(parsed.capabilities) && !browserChoice) {
throw bootstrapError(
"The active browser capability does not record a reusable browser provider; preview browser setup options before changing this runtime.",
"BOOTSTRAP_BROWSER_CHOICE_REQUIRED",
);
}
if (browserChoice) assertBrowserChoiceSupportsCapabilities(browserChoice, parsed.capabilities);
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;
@@ -117,7 +209,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 });
}
@@ -128,23 +220,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) {
@@ -206,7 +322,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);
@@ -220,11 +336,29 @@ function selectedComponents(capabilities) {
}
}
}
return applyBrowserProviderToComponents([...selected], browserChoice);
}
function mergeRetainedCapabilities(requested, reusable, browserChoice) {
const selected = new Set([
...(Array.isArray(reusable?.selectedCapabilities) ? reusable.selectedCapabilities : []),
...requested,
]);
if (browserChoice?.provider === "installed") selected.delete("browser-visible");
const pending = [...selected];
while (pending.length) {
for (const dependency of CAPABILITY_DEPENDENCIES[pending.pop()] ?? []) {
if (!selected.has(dependency)) {
selected.add(dependency);
pending.push(dependency);
}
}
}
return [...selected].sort();
}
function buildComponentPlan(manifest, target, capabilities, reusable) {
const components = selectedComponents(capabilities);
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))
@@ -234,6 +368,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
target,
version: manifest.version,
capabilities,
browser: browserChoice,
components,
reusedComponents: components.filter((component) => retained.has(component)),
downloads,
@@ -244,6 +379,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`);
@@ -258,10 +398,31 @@ async function inspectReusableRuntime(home, version) {
const stat = await fs.lstat(root);
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
const bundle = JSON.parse(await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8"));
if (bundle?.schemaVersion !== 2 || bundle?.version !== version || !Array.isArray(bundle.runtimeComponents) ||
const releaseMatches = bundle?.version === version ||
(typeof bundle?.version === "string" && bundle.version.startsWith(`${version}-caps-`));
if (bundle?.schemaVersion !== 2 || typeof bundle.version !== "string" ||
!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(bundle.version) || !Array.isArray(bundle.runtimeComponents) ||
!Array.isArray(bundle.files)) return null;
const components = [...new Set(bundle.runtimeComponents)];
if (!components.length || components.some((component) => !Object.hasOwn(COMPONENT_DEPENDENCIES, component))) return null;
const selectedCapabilities = Array.isArray(bundle.selectedCapabilities)
? [...new Set(bundle.selectedCapabilities)]
: [];
if (selectedCapabilities.some((capability) => !CAPABILITIES.has(capability))) return null;
let browserChoice = null;
if (browserChoiceRequired(selectedCapabilities)) {
const explicit = bundle.browserChoice;
if (!explicit || !["managed", "installed"].includes(explicit.provider)) return null;
if (explicit.provider === "installed") {
if (selectedCapabilities.includes("browser-visible") ||
typeof explicit.executablePath !== "string" || !path.isAbsolute(explicit.executablePath) ||
components.includes("browser-headless") || components.includes("browser-visible")) return null;
browserChoice = { provider: "installed", executablePath: explicit.executablePath };
} else {
if (!components.includes("browser-headless") && !components.includes("browser-visible")) return null;
browserChoice = { provider: "managed", executablePath: null };
}
}
await assertNoLinks(root);
const files = [];
const seen = new Set();
@@ -277,7 +438,7 @@ async function inspectReusableRuntime(home, version) {
await sha256File(file) !== entry.sha256) return null;
files.push(relative);
}
return { root, components, files };
return { root, components, files, selectedCapabilities, browserChoice, releaseMatches };
}
async function seedReusableRuntime(reusable, destination, claimedFiles) {
@@ -300,10 +461,18 @@ function sha256File(file) {
});
}
async function fetchJson(fetch_, url) {
async function fetchJson(fetch_, url, options = {}) {
const response = await fetch_(url, { headers: { Accept: "application/json" }, redirect: "follow" });
assertFinalDownloadUrl(response.url || url);
if (!response.ok) throw bootstrapError(`Download failed with HTTP ${response.status}`, "BOOTSTRAP_DOWNLOAD_FAILED");
if (!response.ok) {
if (options.official && response.status === 404) {
throw bootstrapError(
`Official runtime release ${BOOTSTRAP_RELEASE_TAG} is not published at ${url}. No files were downloaded or installed.`,
"BOOTSTRAP_RELEASE_UNAVAILABLE",
);
}
throw bootstrapError(`Manifest download failed with HTTP ${response.status} from ${url}. No files were downloaded or installed.`, "BOOTSTRAP_DOWNLOAD_FAILED");
}
const value = await response.json();
if (!value || typeof value !== "object") throw bootstrapError("Manifest returned invalid JSON", "BOOTSTRAP_MANIFEST_INVALID");
return value;
@@ -398,9 +567,14 @@ 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");
if (options.prepared || options.replaceCapabilities) args.push("--replace-capabilities");
await run(options.nodeCommand ?? process.execPath, args);
options.stdout.write(`Installed optional capabilities: ${parsed.capabilities.join(", ")}. No coding host was enrolled.\n`);
return 0;
@@ -521,12 +695,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([