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
+154
View File
@@ -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;
}
+97 -2
View File
@@ -9,10 +9,12 @@ import { setupRuntime } from "./setup.js";
import {
configGet,
configSet,
configSetBrowserChoice,
configSetNetworkChoice,
parseConfigValue,
secretSet,
} from "./config.js";
import { resolveBrowserChoice } from "./browser-choice.mjs";
import { discoverProjectIdentity } from "./identity.js";
import {
beginRun,
@@ -229,12 +231,88 @@ async function configCommand({ args, home, cwd, stdout }) {
if (action === "set") {
const [key, value, ...rest] = tail;
if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set <key> <value>", "USAGE");
if (key === "browser" || key.startsWith("browser.")) {
throw cliError(
"Browser selection is coherent state; use `gstack config browser managed`, `gstack config browser installed <absolute-path>`, or `gstack config browser clear`.",
"CONFIG_BROWSER_COMMAND_REQUIRED",
);
}
await setupRuntime({ home, cwd });
const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value)));
write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`);
return 0;
}
throw cliError("Usage: gstack config get [key] | gstack config set <key> <value>", "USAGE");
if (action === "browser") {
const [provider, executablePath, ...rest] = tail;
if (rest.length || !["managed", "installed", "clear"].includes(provider) ||
(provider === "installed" ? !executablePath : executablePath != null)) {
throw cliError("Usage: gstack config browser managed | installed <absolute-executable-path> | clear", "USAGE");
}
await setupRuntime({ home, cwd });
const choice = provider === "clear"
? null
: await resolveBrowserChoice({ provider, executablePath });
await assertBrowserChoiceCompatibleWithActiveRuntime(home, choice);
const result = await withOwnedRuntimeMutation(home, () => configSetBrowserChoice(home, choice));
write(stdout, provider === "clear"
? "browser selection cleared\n"
: `browser = ${JSON.stringify(result)}\n`);
return 0;
}
throw cliError("Usage: gstack config get [key] | gstack config set <key> <value> | gstack config browser managed | installed <path> | clear", "USAGE");
}
async function activeRuntimeBrowserChoice(home) {
const paths = resolveRuntimePaths({ home });
const pointer = await readJson(paths.versionPointer, null);
if (typeof pointer?.current !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(pointer.current)) return null;
return runtimeBrowserChoiceAtPath(path.join(paths.versions, pointer.current));
}
async function runtimeBrowserChoiceAtPath(runtimePath) {
const manifest = await readJson(path.join(runtimePath, ".gstack-bundle.json"), null);
if (!manifest || typeof manifest !== "object") return null;
const selected = Array.isArray(manifest.selectedCapabilities) ? manifest.selectedCapabilities : [];
const components = Array.isArray(manifest.runtimeComponents) ? manifest.runtimeComponents : [];
if (!selected.includes("browser") && !selected.includes("browser-visible")) return null;
const explicit = manifest.browserChoice;
const provider = explicit?.provider ?? (
components.includes("browser-headless") || components.includes("browser-visible")
? "managed"
: components.includes("browser-code")
? "installed"
: null
);
return provider ? {
provider,
executablePath: provider === "installed" ? explicit?.executablePath ?? null : null,
visible: selected.includes("browser-visible"),
} : null;
}
async function assertBrowserChoiceCompatibleWithActiveRuntime(home, choice) {
if (!choice) return;
const active = await activeRuntimeBrowserChoice(home);
if (!active) return;
if (choice.provider !== active.provider) {
throw cliError(
`The active runtime was installed for ${active.provider} Chromium. Use the signed capability bootstrap to install a ${choice.provider} browser slot before switching providers.`,
"BROWSER_PROVIDER_SLOT_MISMATCH",
);
}
if (choice.provider === "installed" && active.visible) {
throw cliError("Visible GStack Browser is managed-only; install a managed browser slot before selecting it", "BROWSER_PROVIDER_UNSUPPORTED");
}
}
async function resolvedBrowserChoiceForRuntimePath(runtimePath) {
const choice = await runtimeBrowserChoiceAtPath(runtimePath);
if (!choice) return null;
if (choice.provider === "managed") return { provider: "managed", executablePath: null };
if (typeof choice.executablePath !== "string") {
throw cliError("The rollback slot does not record its installed browser executable", "BROWSER_PATH_REQUIRED");
}
return resolveBrowserChoice({ provider: "installed", executablePath: choice.executablePath });
}
async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
@@ -665,7 +743,11 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE");
if (parsed.flags.has("--rollback")) {
if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE");
const pointer = await rollbackUpgrade(home);
const pointer = await rollbackUpgrade(home, {
prepareActivation: async (fallbackPath) => ({
browserChoice: await resolvedBrowserChoiceForRuntimePath(fallbackPath),
}),
});
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`);
return 0;
}
@@ -674,10 +756,22 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
if (!sourceDir || !version) {
throw cliError("Usage: gstack upgrade --source <complete-gstack-package> --version <version> | --rollback", "USAGE");
}
let browserChoice;
if (installOptions.entries == null) {
const configuredBrowser = await configGet(home, "browser");
if (!configuredBrowser?.provider) {
throw cliError(
"Upgrade needs the browser choice that setup normally records. Run `gstack config browser managed` or `gstack config browser installed <absolute-path>` first.",
"BROWSER_CHOICE_REQUIRED",
);
}
browserChoice = await resolveBrowserChoice(configuredBrowser);
}
const result = await installManagedRuntime({
home,
sourceDir,
version,
...(browserChoice ? { browserChoice } : {}),
...installOptions,
buildMissing: false,
rejectSourceRootLink: true,
@@ -828,6 +922,7 @@ function usage() {
" gstack runtime path <bundle-relative-path>\n" +
" gstack config get [key]\n" +
" gstack config set <key> <value>\n" +
" gstack config browser managed|installed <absolute-path>|clear\n" +
" gstack state inspect [run-id]\n" +
" gstack state begin <workflow> [--run-id <id>] [--goal <goal>] [--plan <pointer>] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>]\n" +
" gstack state update <run-id> [--plan <pointer>|--clear-plan] [--stage <stage>] [--depth quick|standard|deep] [--mutation <authority>] [--modules <a,b>] [--push-detour <goal>|--pop-detour]\n" +
+40
View File
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import { atomicWriteJson, readJson, withLock } from "./storage.js";
import { resolveRuntimePaths } from "./paths.js";
import { BROWSER_PROVIDERS } from "./browser-choice.mjs";
export const DEFAULT_CONFIG = Object.freeze({
schemaVersion: 2,
@@ -10,6 +11,7 @@ export const DEFAULT_CONFIG = Object.freeze({
baseUrl: "https://api.context.dev/v1",
validation: Object.freeze({ status: "unverified", checkedAt: null }),
}),
browser: Object.freeze({ provider: null, executablePath: null }),
cleanup: Object.freeze({ retentionDays: 30 }),
});
@@ -125,6 +127,18 @@ export async function configSetNetworkChoice(home, choice) {
});
}
/** Persist one coherent browser-engine choice or clear it atomically. */
export async function configSetBrowserChoice(home, choice) {
const normalized = choice == null
? { provider: null, executablePath: null }
: { provider: choice.provider, executablePath: choice.executablePath ?? null };
validateBrowserChoice(normalized);
return updateConfig(home, (config) => {
config.browser = normalized;
return { ...config.browser };
});
}
async function updateConfig(home, mutate) {
const paths = resolveRuntimePaths({ home });
return withLock(path.join(paths.locks, "config.lock"), async () => {
@@ -211,6 +225,31 @@ function validateConfig(config) {
throw new TypeError("context.validation.checkedAt must be an ISO timestamp or null");
}
}
validateBrowserChoice(config.browser ?? { provider: null, executablePath: null });
}
function validateBrowserChoice(browser) {
if (browser == null || typeof browser !== "object" || Array.isArray(browser)) {
throw new TypeError("browser must be an object");
}
const keys = Object.keys(browser).sort();
if (keys.join(",") !== "executablePath,provider") {
throw new TypeError("browser requires exactly provider and executablePath");
}
if (browser.provider == null) {
if (browser.executablePath != null) throw new TypeError("An unselected browser cannot have an executable path");
return;
}
if (!BROWSER_PROVIDERS.includes(browser.provider)) {
throw new TypeError("browser.provider must be `managed`, `installed`, or null");
}
if (browser.provider === "managed" && browser.executablePath != null) {
throw new TypeError("Managed Chromium cannot have an installed executable path");
}
if (browser.provider === "installed" &&
(typeof browser.executablePath !== "string" || !path.isAbsolute(browser.executablePath))) {
throw new TypeError("An installed browser requires an absolute executable path");
}
}
function cloneDefaultConfig() {
@@ -223,6 +262,7 @@ function mergeDefaults(stored) {
...stored,
network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) },
context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) },
browser: { ...DEFAULT_CONFIG.browser, ...(stored.browser ?? {}) },
cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) },
};
}
+71 -11
View File
@@ -10,6 +10,7 @@ import { RUNTIME_SCHEMA_VERSION, RUNTIME_MIGRATION_ID } from "./migrations.js";
import { assertManagedHome } from "./managed-home.js";
import { recoverPendingUpgrade } from "./upgrade.js";
import { bashCandidates } from "./tooling.js";
import { resolveBrowserChoice } from "./browser-choice.mjs";
import {
OPTIONAL_RUNTIME_CAPABILITIES,
RUNTIME_CAPABILITY_DEPENDENCIES,
@@ -31,6 +32,7 @@ export async function runDoctor(options = {}) {
const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) });
const now = options.now ? options.now() : new Date();
const expectedSkillApi = options.expectedSkillApi ?? RUNTIME_COMPATIBILITY.skillApi;
let runtimeConfig = null;
if (typeof expectedSkillApi !== "string" || !/^[0-9A-Za-z][0-9A-Za-z._-]{0,31}$/.test(expectedSkillApi)) {
throw new TypeError("Expected skill API must be a short version identifier");
}
@@ -59,12 +61,16 @@ export async function runDoctor(options = {}) {
}
try {
const config = await readJson(paths.config);
add("config", config?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
`Config schema ${config?.schemaVersion ?? "unknown"}`);
const enabled = config?.network?.mode === "context" && config?.network?.consent === true;
runtimeConfig = await readJson(paths.config);
add("config", runtimeConfig?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
`Config schema ${runtimeConfig?.schemaVersion ?? "unknown"}`);
const enabled = runtimeConfig?.network?.mode === "context" && runtimeConfig?.network?.consent === true;
add("network", enabled ? "pass" : "warn",
enabled ? "Context.dev network mode has explicit consent" : "Network access is off (safe default)");
const browserProvider = runtimeConfig?.browser?.provider;
add("browser-selection", browserProvider ? "pass" : "warn", browserProvider
? `Browser provider explicitly selected: ${browserProvider}`
: "No browser provider selected; browser-backed skills will ask at first use");
} catch (error) {
add("config", "fail", `Config cannot be read: ${error.message}`);
}
@@ -145,7 +151,11 @@ export async function runDoctor(options = {}) {
add("specialist-tool:python", python.ok ? "pass" : "warn", python.message, python.details);
const selected = new Set(Array.isArray(manifest?.selectedCapabilities) ? manifest.selectedCapabilities : []);
const launchers = manifest?.capabilities ?? {};
for (const capability of OPTIONAL_RUNTIME_CAPABILITIES) {
const capabilitiesToInspect = [
...OPTIONAL_RUNTIME_CAPABILITIES,
...(selected.has("browser-visible") ? ["browser-visible"] : []),
];
for (const capability of capabilitiesToInspect) {
if (!selected.has(capability)) {
add(`capability:${capability}`, "warn", "not selected");
continue;
@@ -160,8 +170,24 @@ export async function runDoctor(options = {}) {
add(`capability:${capability}`, "fail", "selected but required launcher metadata is missing");
continue;
}
if (capability === "browser") {
const browser = await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
if (capability === "browser" || capability === "browser-visible") {
const visible = capability === "browser-visible";
const browser = visible && runtimeConfig?.browser?.provider !== "managed"
? { ok: false, message: "visible GStack Browser requires the managed Chromium provider" }
: runtimeConfig?.browser?.provider === "installed"
? await inspectInstalledChromium(
activeRoot,
options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
runtimeConfig.browser,
options,
)
: runtimeConfig?.browser?.provider === "managed"
? await inspectManagedChromium(
activeRoot,
options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
{ visible },
)
: { ok: false, message: "browser capability is installed, but no browser provider was explicitly selected" };
add(`capability:${capability}`, browser.ok ? "pass" : "fail", browser.message, browser.details);
continue;
}
@@ -314,7 +340,7 @@ async function inspectPython(env) {
return { ok: false, message: "Python 3 is absent; only specialist flows that explicitly request it are unavailable" };
}
async function inspectManagedChromium(activeRoot, nodeCommand) {
async function inspectManagedChromium(activeRoot, nodeCommand, options = {}) {
const browserRoot = path.join(activeRoot, ".gstack-runtime-browsers");
const modulePath = path.join(activeRoot, "node_modules", "playwright", "index.mjs");
const [browserStat, moduleStat] = await Promise.all([
@@ -329,16 +355,50 @@ async function inspectManagedChromium(activeRoot, nodeCommand) {
const result = await captureCommand(nodeCommand, [
"--input-type=module",
"--eval",
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); const browser = await chromium.launch({ headless: true }); try { process.stdout.write(browser.version()); } finally { await browser.close(); }`,
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); const browser = await chromium.launch(${options.visible ? '{ headless: true, channel: "chromium" }' : "{ headless: true }"}); try { process.stdout.write(browser.version()); } finally { await browser.close(); }`,
], { env: { ...process.env, PLAYWRIGHT_BROWSERS_PATH: browserRoot } });
const version = result.stdout.trim();
if (!version) return { ok: false, message: "managed Chromium launched without reporting a browser version" };
return { ok: true, message: `managed headless Chromium ${version} launches and exits cleanly`, details: { browserRoot, version } };
return {
ok: true,
message: `managed ${options.visible ? "visible-capable" : "headless"} Chromium ${version} launches and exits cleanly`,
details: { browserRoot, version },
};
} catch (error) {
return { ok: false, message: `managed Chromium is not runnable: ${error.message}` };
}
}
async function inspectInstalledChromium(activeRoot, nodeCommand, configured, options = {}) {
const modulePath = path.join(activeRoot, "node_modules", "playwright", "index.mjs");
const moduleStat = await fs.lstat(modulePath).catch(() => null);
if (!moduleStat?.isFile() || moduleStat.isSymbolicLink()) {
return { ok: false, message: "Playwright module for the installed-browser adapter is missing/unsafe" };
}
try {
const choice = await resolveBrowserChoice(configured, {
platform: options.platform,
env: options.env,
homeDir: options.homeDir,
});
const moduleUrl = pathToFileURL(modulePath).href;
const result = await captureCommand(nodeCommand, [
"--input-type=module",
"--eval",
`const { chromium } = await import(${JSON.stringify(moduleUrl)}); const browser = await chromium.launch({ headless: true, executablePath: ${JSON.stringify(choice.executablePath)} }); try { process.stdout.write(browser.version()); } finally { await browser.close(); }`,
]);
const version = result.stdout.trim();
if (!version) return { ok: false, message: "installed Chromium launched without reporting a browser version" };
return {
ok: true,
message: `installed Chromium ${version} launches through the Playwright adapter and exits cleanly`,
details: { provider: "installed", executablePath: choice.executablePath, version },
};
} catch (error) {
return { ok: false, message: `installed Chromium is not runnable through the Playwright adapter: ${error.message}` };
}
}
async function inspectXcrun() {
if (process.platform !== "darwin") return { ok: false, message: "physical-iOS capability requires macOS" };
try {
@@ -350,7 +410,7 @@ async function inspectXcrun() {
}
function capabilityLaunchersReady(capability, launchers) {
if (capability === "browser") return typeof launchers.browse === "string";
if (capability === "browser" || capability === "browser-visible") return typeof launchers.browse === "string";
if (capability === "design") return typeof launchers["gstack-design"] === "string";
if (capability === "pdf") return typeof launchers["make-pdf"] === "string";
if (capability === "diagram") return true;
+216 -28
View File
@@ -20,6 +20,14 @@ import {
} from "./managed-home.js";
import { errorWithCode as installError } from "./errors.js";
import { currentIsoTimestamp as isoNow } from "./time.js";
import { configSetBrowserChoice, loadConfig } from "./config.js";
import {
applyBrowserProviderToComponents,
assertBrowserChoiceSupportsCapabilities,
browserChoiceRequired,
detectInstalledBrowsers,
resolveBrowserChoice,
} from "./browser-choice.mjs";
const INSTALL_SCHEMA_VERSION = 2;
export const MAX_RUNTIME_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024;
@@ -266,7 +274,6 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([
entry("browse/src"),
entry("extension"),
entry("node_modules/playwright"),
entry("node_modules/playwright-core"),
entry(managedBunRelativePath(), "managed-bun", true),
entry(".gstack-runtime-browsers", "browser"),
entry("node_modules/diff"),
@@ -324,10 +331,11 @@ const CAPABILITY_PATH_PREFIXES = Object.freeze({
});
/** Resolve the audited core plus only explicitly selected optional capabilities. */
export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES) {
export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES, options = {}) {
const selected = normalizeCapabilitySelection(input);
const includesBrowserCode = selected.includes("browser") || selected.includes("browser-visible");
const entries = DEFAULT_RUNTIME_BUNDLE.filter((item) => {
if (options.browserChoice?.provider === "installed" && item.path === ".gstack-runtime-browsers") return false;
const owner = capabilityForPath(item.path);
return owner == null || selected.includes(owner) || (owner === "browser" && includesBrowserCode);
});
@@ -339,7 +347,7 @@ export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILIT
}
/** Expand logical runtime capabilities into the signed internal components. */
export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES) {
export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABILITIES, options = {}) {
const capabilities = normalizeCapabilitySelection(input);
const selected = new Set(["core"]);
for (const capability of capabilities) {
@@ -354,13 +362,16 @@ export function runtimeComponentsForCapabilities(input = OPTIONAL_RUNTIME_CAPABI
}
}
}
return Object.freeze([...selected].sort());
return applyBrowserProviderToComponents([...selected], options.browserChoice);
}
export function runtimeSlotVersion(releaseVersion, capabilityIds) {
export function runtimeSlotVersion(releaseVersion, capabilityIds, options = {}) {
validateVersion(releaseVersion);
const selected = normalizeCapabilitySelection(capabilityIds);
const digest = createHash("sha256").update(selected.join(",") || "core").digest("hex").slice(0, 12);
const browserProvider = browserChoiceRequired(selected)
? options.browserChoice?.provider ?? "legacy-managed"
: "no-browser";
const digest = createHash("sha256").update(`${selected.join(",") || "core"}|${browserProvider}`).digest("hex").slice(0, 12);
const prefix = String(releaseVersion).slice(0, 60);
return `${prefix}-caps-${digest}`;
}
@@ -368,7 +379,7 @@ export function runtimeSlotVersion(releaseVersion, capabilityIds) {
export async function previewManagedRuntime(options = {}) {
if (!options.sourceDir) throw installError("sourceDir is required", "INSTALL_SOURCE_REQUIRED");
const sourceDir = await resolvePhysicalSource(options.sourceDir);
const surface = runtimeSurfaceForCapabilities(options.capabilityIds);
const surface = runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice });
let bytes = 0;
let files = 0;
const missing = [];
@@ -422,6 +433,7 @@ export async function previewManagedRuntime(options = {}) {
return Object.freeze({
sourceDir,
capabilities: surface.selected,
browser: browserChoiceRequired(surface.selected) ? options.browserChoice ?? null : null,
components: surface.entries.length,
files,
bytes,
@@ -463,7 +475,7 @@ export async function installManagedRuntime(options = {}) {
if (options.requirePackageIdentity) validatePackageIdentity(packageMetadata, version);
const selectedSurface = options.entries == null
? runtimeSurfaceForCapabilities(options.capabilityIds)
? runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice })
: null;
const entries = normalizeEntries(options.entries ?? selectedSurface.entries);
const capabilities = normalizeCapabilities(options.capabilities ?? selectedSurface.capabilities, entries);
@@ -584,7 +596,15 @@ export async function installManagedRuntime(options = {}) {
version,
compatibility: RUNTIME_COMPATIBILITY,
selectedCapabilities: selectedSurface?.selected ?? null,
runtimeComponents: selectedSurface ? runtimeComponentsForCapabilities(selectedSurface.selected) : null,
browserChoice: selectedSurface && browserChoiceRequired(selectedSurface.selected)
? {
provider: options.browserChoice?.provider ?? null,
executablePath: options.browserChoice?.executablePath ?? null,
}
: null,
runtimeComponents: selectedSurface
? runtimeComponentsForCapabilities(selectedSurface.selected, { browserChoice: options.browserChoice })
: null,
components: entries.map(({ path: component }) => component),
capabilities,
stableSourceFiles,
@@ -620,6 +640,7 @@ export async function installManagedRuntime(options = {}) {
nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
run: options.runCommand ?? runCommand,
commandTimeoutMs: options.commandTimeoutMs,
browserChoice: selectedSurface ? options.browserChoice : null,
});
},
beforeActivate: async ({ active, previous, previousExists, destination }) => {
@@ -632,6 +653,7 @@ export async function installManagedRuntime(options = {}) {
await removeObsoleteLaunchers(paths, snapshot, launcherSurface);
const manifestWriter = options.manifestWriter ?? writeInstallManifest;
installManifest = await manifestWriter(paths, active, launcherSurface, options.now);
if (options.browserChoice) await configSetBrowserChoice(home, options.browserChoice);
},
afterActivate: async () => fs.rm(path.join(home, RUNTIME_TRANSACTION_FILE), { force: true }),
onRollback: async ({ pointerRollbackError }) => {
@@ -898,6 +920,26 @@ export async function smokeRuntimeBundle(directory, options = {}) {
cause,
);
}
} else if (options.browserChoice?.provider === "installed") {
const playwrightFile = path.join(directory, "node_modules", "playwright", "index.mjs");
const moduleStat = await fs.lstat(playwrightFile).catch(() => null);
if (!moduleStat?.isFile() || moduleStat.isSymbolicLink()) {
throw installError("Playwright adapter for the installed browser is missing or unsafe", "INSTALL_SMOKE_FAILED");
}
const browserChoice = await resolveBrowserChoice(options.browserChoice);
try {
await run(command, [
"--input-type=module",
"--eval",
`const { chromium } = await import(${JSON.stringify(pathToFileURL(playwrightFile).href)}); const browser = await chromium.launch({ headless: true, executablePath: ${JSON.stringify(browserChoice.executablePath)} }); try { if (!browser.version()) throw new Error("browser version unavailable"); } finally { await browser.close(); }`,
], { cwd: directory, capture: true, timeoutMs: Math.max(timeoutMs, 30_000) });
} catch (cause) {
throw installError(
"The selected installed Chromium failed its Playwright launch smoke test; the active runtime and browser selection were not changed",
"INSTALL_SMOKE_FAILED",
cause,
);
}
}
}
@@ -916,7 +958,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
const stdout = options.stdout ?? process.stdout;
const bunCommand = parsed.bunCommand ?? env.BUN_CMD ?? "bun";
let capabilityIds = parsed.capabilityIds;
if (parsed.installMode == null && !parsed.dryRun && stdin.isTTY && !parsed.json) {
if (!parsed.capabilitiesProvided && parsed.installMode == null && !parsed.dryRun && stdin.isTTY && !parsed.json) {
const answer = await askInstallerQuestion(
stdin,
options.stderr ?? process.stderr,
@@ -924,10 +966,52 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
);
capabilityIds = parseCapabilityList(answer || "all");
}
if (parsed.installMode === "later" && !parsed.browserProvider && browserChoiceRequired(capabilityIds)) {
if (parsed.json) {
stdout.write(`${JSON.stringify({ ok: true, action: "install-later", mutated: false, preview: null }, null, 2)}\n`);
} else if (!parsed.quiet) {
stdout.write("No browser provider was selected and no runtime was installed. Judgment-only skills remain usable.\n");
}
return 0;
}
capabilityIds = await mergeActiveCapabilities(home, capabilityIds, parsed.replaceCapabilities);
let browserChoice = null;
if (browserChoiceRequired(capabilityIds)) {
const configured = parsed.browserProvider
? { provider: parsed.browserProvider, executablePath: parsed.browserPath }
: (await loadConfig(home)).browser;
if (configured?.provider) {
browserChoice = await resolveBrowserChoice(configured, {
platform: options.platform,
env,
homeDir: options.homeDir,
});
} else if (stdin.isTTY && !parsed.json && !parsed.dryRun) {
browserChoice = await askBrowserChoice({
input: stdin,
output: options.stderr ?? process.stderr,
platform: options.platform,
env,
homeDir: options.homeDir,
});
if (!browserChoice) {
stdout.write("No browser provider was selected. No runtime was installed; judgment-only skills remain usable.\n");
return 0;
}
} else {
throw installError(
"Browser-backed capabilities require an explicit choice. Use `--browser managed` or `--browser installed --browser-path <absolute-executable-path>`; no browser was downloaded or selected.",
"INSTALL_BROWSER_CHOICE_REQUIRED",
);
}
assertBrowserChoiceSupportsCapabilities(browserChoice, capabilityIds);
} else if (parsed.browserProvider || parsed.browserPath) {
throw installError("Browser options require a browser-backed capability", "INSTALL_BROWSER_CHOICE_UNUSED");
}
const preview = await previewManagedRuntime({
sourceDir,
capabilityIds,
browserChoice,
bunCommand,
preparedSource: parsed.prepared,
runCommand: options.installOptions?.runCommand,
@@ -969,9 +1053,10 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
const result = await installManagedRuntime({
sourceDir,
home,
version: runtimeSlotVersion(releaseVersion, capabilityIds),
version: runtimeSlotVersion(releaseVersion, capabilityIds, { browserChoice }),
bunCommand,
capabilityIds,
browserChoice,
buildMissing: parsed.prepared ? false : undefined,
nodeCommand: env.GSTACK_NODE ?? "node",
launcherNodeCommand: env.GSTACK_NODE ?? "node",
@@ -984,7 +1069,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
stdout.write(`Installed gstack runtime ${releaseVersion}\n`);
stdout.write(`Runtime home: ${result.home}\n`);
stdout.write(`Launcher directory: ${path.join(result.home, "bin")}\n`);
stdout.write("Skills are installed separately with: npx skills add time-attack/gstack\n");
stdout.write("Skills are installed separately with: npx skills add time-attack/gstack/skills\n");
}
return 0;
} catch (error) {
@@ -1052,6 +1137,10 @@ export function runtimeReleaseComponentForPath(value) {
const relative = component.slice(browserRoot.length);
if (relative === ".links" || relative.startsWith(".links/")) return null;
const top = relative.split("/")[0];
// Playwright downloads winldd on Windows only to validate browser DLL
// dependencies during installation. It is not required to launch Chromium
// from the completed managed runtime, so keep it out of release artifacts.
if (/^winldd-\d/.test(top)) return null;
if (top.startsWith("chromium_headless_shell-") || top.startsWith("ffmpeg-")) return "browser-headless";
if (/^chromium-\d/.test(top)) return "browser-visible";
throw installError(`Unknown managed browser payload path: ${component}`, "INSTALL_BROWSER_PAYLOAD_INVALID");
@@ -1548,6 +1637,55 @@ if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error("Active capability
const managedBrowsers = path.join(root, ".gstack-runtime-browsers");
const browserStat = await fs.lstat(managedBrowsers).catch(() => null);
if (browserStat?.isSymbolicLink()) throw new Error("Managed browser directory is unsafe");
const config = await fs.readFile(path.join(home, "config.json"), "utf8")
.then(value => JSON.parse(value), () => null);
const bundle = await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8")
.then(value => JSON.parse(value), () => null);
const browserBacked = relative.startsWith("browse/") || relative.startsWith("make-pdf/");
const selectedCapabilities = Array.isArray(bundle?.selectedCapabilities) ? bundle.selectedCapabilities : [];
const runtimeComponents = Array.isArray(bundle?.runtimeComponents) ? bundle.runtimeComponents : [];
const visibleRequested = relative.startsWith("browse/") && (
args.includes("connect") ||
args.includes("handoff") ||
args.includes("--headed") ||
(args[0] === "pair-agent" && !args.includes("--headless"))
);
const slotProvider = bundle?.browserChoice?.provider ?? (
runtimeComponents.includes("browser-headless") || runtimeComponents.includes("browser-visible")
? "managed"
: runtimeComponents.includes("browser-code")
? "installed"
: null
);
let browserChoice = config?.browser ?? { provider: null, executablePath: null };
if (browserBacked) {
if (!browserChoice?.provider) {
throw new Error("No browser provider is selected; run the signed browser capability bootstrap before launching browser-backed tools");
}
if (slotProvider && browserChoice.provider !== slotProvider) {
throw new Error("The selected browser provider does not match the active runtime slot; run the signed browser capability bootstrap for the selected provider");
}
if (visibleRequested && !selectedCapabilities.includes("browser-visible")) {
if (browserChoice.provider === "installed") {
throw new Error("Visible GStack Browser requires managed Chromium; preview and approve the browser-visible capability first");
}
throw new Error("The active runtime slot does not include visible Chromium; preview and approve the browser-visible capability first");
}
if (browserChoice.provider === "installed") {
if (selectedCapabilities.includes("browser-visible")) {
throw new Error("Visible GStack Browser requires a managed Chromium runtime slot");
}
if (visibleRequested) {
throw new Error("Visible GStack Browser requires managed Chromium; preview and approve the browser-visible capability first");
}
const browserModule = await import(pathToFileURL(path.join(root, "runtime", "browser-choice.mjs")).href);
browserChoice = await browserModule.resolveBrowserChoice(browserChoice);
} else if (browserChoice.provider === "managed") {
if (!browserStat?.isDirectory()) throw new Error("Managed Chromium is missing from the active runtime slot");
} else {
throw new Error("Configured browser provider is invalid");
}
}
const managedBun = path.join(root, ${JSON.stringify(managedBunRelativePath())});
const bunStat = await fs.lstat(managedBun).catch(() => null);
const hasManagedBun = bunStat?.isFile() && !bunStat.isSymbolicLink();
@@ -1574,20 +1712,29 @@ if (/^#!.*\\bbun(?:\\s|$)/.test(header)) {
command = process.env.GSTACK_NODE || process.execPath;
commandArgs = [target, ...args];
}
const childEnv = {
...process.env,
GSTACK_HOME: process.env.GSTACK_HOME || home,
GSTACK_NODE: process.env.GSTACK_NODE || process.execPath,
GSTACK_BASH: bashCommand,
...(hasManagedBun ? {
BUN_CMD: managedBun,
PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""),
} : {}),
};
if (browserBacked) {
delete childEnv.PLAYWRIGHT_BROWSERS_PATH;
delete childEnv.GSTACK_CHROMIUM_PATH;
delete childEnv.GSTACK_BROWSER_PROVIDER;
childEnv.GSTACK_BROWSER_PROVIDER = browserChoice.provider;
if (browserChoice.provider === "installed") delete childEnv.BROWSE_EXTENSIONS_DIR;
if (browserChoice.provider === "managed") childEnv.PLAYWRIGHT_BROWSERS_PATH = managedBrowsers;
else childEnv.GSTACK_CHROMIUM_PATH = browserChoice.executablePath;
}
const child = spawn(command, commandArgs, {
stdio: "inherit",
windowsHide: true,
env: {
...process.env,
GSTACK_HOME: process.env.GSTACK_HOME || home,
GSTACK_NODE: process.env.GSTACK_NODE || process.execPath,
GSTACK_BASH: bashCommand,
...(hasManagedBun ? {
BUN_CMD: managedBun,
PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""),
} : {}),
...(browserStat?.isDirectory() ? { PLAYWRIGHT_BROWSERS_PATH: managedBrowsers } : {}),
},
env: childEnv,
});
child.once("error", error => { console.error(error.message); process.exitCode = 1; });
child.once("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });
@@ -1697,7 +1844,8 @@ function processIsAlive(pid) {
}
function validTransactionPath(value) {
if (value === "runtime-install.json" || (typeof value === "string" && /^bin\\/[A-Za-z0-9._-]+$/.test(value))) return value;
if (value === "config.json" || value === "runtime-install.json" ||
(typeof value === "string" && /^bin\\/[A-Za-z0-9._-]+$/.test(value))) return value;
throw new Error("Invalid managed runtime transaction path");
}
@@ -1876,6 +2024,7 @@ async function captureInstallSurface(paths, launcherSurface) {
const oldManifest = await readJson(manifestPath, null);
const oldLaunchers = validateInstallManifestForUninstall(oldManifest);
const relativePaths = new Set([
"config.json",
"runtime-install.json",
...oldLaunchers,
...launcherRelativePaths(launcherSurface),
@@ -2155,7 +2304,10 @@ function parseInstallerArgs(argv) {
home: null,
version: undefined,
bunCommand: undefined,
browserProvider: null,
browserPath: null,
capabilityIds: OPTIONAL_RUNTIME_CAPABILITIES,
capabilitiesProvided: false,
installMode: null,
yes: false,
dryRun: false,
@@ -2176,20 +2328,34 @@ function parseInstallerArgs(argv) {
else if (arg === "--replace-capabilities") result.replaceCapabilities = true;
else if (arg === "--install-now") result.installMode = "now";
else if (arg === "--install-later") result.installMode = "later";
else if (["--source", "--home", "--version", "--bun", "--capabilities"].includes(arg)) {
else if (["--source", "--home", "--version", "--bun", "--capabilities", "--browser", "--browser-path"].includes(arg)) {
const value = argv[index + 1];
if (!value || value.startsWith("--")) throw new TypeError(`Missing value for ${arg}`);
index += 1;
if (arg === "--capabilities") result.capabilityIds = parseCapabilityList(value);
if (arg === "--capabilities") {
result.capabilityIds = parseCapabilityList(value);
result.capabilitiesProvided = true;
}
else if (arg === "--browser") result.browserProvider = value;
else if (arg === "--browser-path") result.browserPath = value;
else {
const key = { "--source": "sourceDir", "--home": "home", "--version": "version", "--bun": "bunCommand" }[arg];
result[key] = value;
}
} else {
throw new TypeError(`Unknown setup option: ${arg}. Skill placement is delegated to: npx skills add time-attack/gstack`);
throw new TypeError(`Unknown setup option: ${arg}. Skill placement is delegated to: npx skills add time-attack/gstack/skills`);
}
}
if (result.installMode === "later" && result.yes) throw new TypeError("--install-later cannot be combined with --yes");
if (result.browserProvider != null && !["managed", "installed"].includes(result.browserProvider)) {
throw new TypeError("--browser must be `managed` or `installed`");
}
if (result.browserProvider === "managed" && result.browserPath != null) {
throw new TypeError("--browser-path is valid only with `--browser installed`");
}
if (result.browserPath != null && result.browserProvider !== "installed") {
throw new TypeError("--browser-path requires `--browser installed`");
}
if (result.prepared && result.installMode !== "now") throw new TypeError("--prepared is reserved for an explicit prepared artifact install");
if (result.dryRun && (result.installMode != null || result.yes)) throw new TypeError("--dry-run cannot be combined with install/consent flags");
return result;
@@ -2197,12 +2363,13 @@ function parseInstallerArgs(argv) {
function installerUsage() {
return `Usage: ./setup [--capabilities <list>] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]\n` +
` [--browser managed|installed [--browser-path <absolute-path>]]\n` +
` [--home <path>] [--version <version>] [--json] [--quiet]\n\n` +
`Optional capabilities: ${OPTIONAL_RUNTIME_CAPABILITIES.join(", ")}\n` +
"Without --install-now, non-interactive use previews and installs nothing.\n" +
"--dry-run and --install-later never modify the runtime, state, or host setup.\n" +
"Installs only the optional host-neutral runtime and selected local capabilities.\n" +
"Install the six skills separately with: npx skills add time-attack/gstack\n";
"Install the six skills separately with: npx skills add time-attack/gstack/skills\n";
}
function parseCapabilityList(value) {
@@ -2221,9 +2388,30 @@ async function askInstallerQuestion(input, output, prompt) {
}
}
async function askBrowserChoice({ input, output, platform, env, homeDir }) {
const installed = await detectInstalledBrowsers({ platform, env, homeDir });
output.write("\nBrowser-backed skills need one explicit browser choice:\n");
output.write(" m) Managed Chromium — isolated and reproducible; its exact download is shown before install.\n");
installed.forEach((browser, index) => {
output.write(` ${index + 1}) ${browser.name}${browser.executablePath} (isolated automation profile; no browser download).\n`);
});
output.write(" l) Later — install nothing.\n");
const answer = (await askInstallerQuestion(input, output, "Select m, a browser number, or l [l]: ")).trim().toLowerCase();
if (!answer || answer === "l" || answer === "later") return null;
if (answer === "m" || answer === "managed") return resolveBrowserChoice({ provider: "managed" });
const selected = installed[Number(answer) - 1];
if (!selected) throw installError("Invalid browser selection", "INSTALL_BROWSER_CHOICE_INVALID");
return resolveBrowserChoice({ provider: "installed", executablePath: selected.executablePath }, { platform, env, homeDir });
}
function printInstallPreview(stdout, preview) {
stdout.write("GStack optional runtime preview\n");
stdout.write(`Capabilities: ${preview.capabilities.length ? preview.capabilities.join(", ") : "core only"}\n`);
if (preview.browser?.provider === "managed") {
stdout.write("Browser: managed isolated Chromium (downloaded only after approval).\n");
} else if (preview.browser?.provider === "installed") {
stdout.write(`Browser: installed Chromium at ${preview.browser.executablePath} (launched with an isolated automation profile; no browser download).\n`);
}
stdout.write(`Projected local payload before unknown downloads: ${preview.humanSize} (${preview.files} files, ${preview.components} components)\n`);
for (const item of preview.materializations) {
if (item.kind === "managed-bun-capture") {
+1 -1
View File
@@ -319,7 +319,7 @@ function validateTransactionPath(value) {
throw managedHomeError("Invalid runtime transaction path", "RUNTIME_TRANSACTION_INVALID");
}
const normalized = value.replaceAll("\\", "/");
if (normalized === "runtime-install.json" || /^bin\/[A-Za-z0-9._-]+$/.test(normalized)) return normalized;
if (normalized === "config.json" || normalized === "runtime-install.json" || /^bin\/[A-Za-z0-9._-]+$/.test(normalized)) return normalized;
throw managedHomeError(`Invalid runtime transaction path: ${value}`, "RUNTIME_TRANSACTION_INVALID");
}
+208 -22
View File
@@ -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([
+68 -2
View File
@@ -2,14 +2,16 @@ import fs from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
import { atomicWriteJson, pathExists, readJson, renameWithRetry } from "./storage.js";
import { atomicWriteFile, atomicWriteJson, pathExists, readJson, renameWithRetry } from "./storage.js";
import {
assertManagedHome,
ensureManagedHome,
ensureManagedRuntimeDirectory,
recoverRuntimeTransactionUnlocked,
RUNTIME_TRANSACTION_FILE,
withRuntimeLifecycleLock,
} from "./managed-home.js";
import { configSetBrowserChoice } from "./config.js";
import { errorWithCode as upgradeError } from "./errors.js";
import { currentIsoTimestamp as isoNow } from "./time.js";
@@ -214,6 +216,11 @@ export async function rollbackUpgrade(home, options = {}) {
}
await assertTreeContainsNoLinks(fallbackPath);
if (options.healthCheck) await options.healthCheck(fallbackPath);
const activation = options.prepareActivation
? await options.prepareActivation(fallbackPath)
: null;
const syncBrowserChoice = activation != null &&
Object.prototype.hasOwnProperty.call(activation, "browserChoice");
const rolledBack = {
schemaVersion: 2,
status: "active",
@@ -222,11 +229,70 @@ export async function rollbackUpgrade(home, options = {}) {
rolledBackFrom: pointer.current ?? null,
rolledBackAt: isoNow(options.now),
};
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
if (!syncBrowserChoice) {
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
return rolledBack;
}
const configSnapshot = await snapshotRollbackConfig(paths.config);
const journalPath = path.join(resolved, RUNTIME_TRANSACTION_FILE);
await atomicWriteJson(journalPath, {
schemaVersion: 1,
kind: "gstack-runtime-install-transaction",
status: "prepared",
home: resolved,
version: fallbackVersion,
previousPointerExists: true,
previousPointer: pointer,
files: [configSnapshot == null
? { path: "config.json", existed: false }
: {
path: "config.json",
existed: true,
mode: configSnapshot.mode,
dataBase64: configSnapshot.data.toString("base64"),
}],
preparedAt: isoNow(options.now),
}, { mode: 0o600 });
try {
await configSetBrowserChoice(resolved, activation.browserChoice);
await atomicWriteJson(paths.versionPointer, rolledBack, { mode: 0o600 });
await fs.rm(journalPath, { force: true });
} catch (cause) {
const rollbackErrors = [];
try {
if (configSnapshot == null) await fs.rm(paths.config, { force: true });
else await atomicWriteFile(paths.config, configSnapshot.data, { mode: configSnapshot.mode });
} catch (error) {
rollbackErrors.push(error);
}
try {
await atomicWriteJson(paths.versionPointer, pointer, { mode: 0o600 });
} catch (error) {
rollbackErrors.push(error);
}
if (rollbackErrors.length === 0) await fs.rm(journalPath, { force: true });
const error = upgradeError("Rollback activation failed and the previous runtime was restored", "ROLLBACK_ACTIVATION_FAILED", cause);
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback restoration was incomplete");
throw error;
}
return rolledBack;
}, options);
}
async function snapshotRollbackConfig(configPath) {
const stat = await fs.lstat(configPath).catch((error) => {
if (error?.code === "ENOENT") return null;
throw error;
});
if (!stat) return null;
if (!stat.isFile() || stat.isSymbolicLink()) {
throw upgradeError("Refusing unsafe browser configuration during rollback", "RUNTIME_TRANSACTION_INVALID");
}
return { data: await fs.readFile(configPath), mode: stat.mode & 0o777 };
}
export async function activeVersion(home, options = {}) {
const recovered = await recoverPendingUpgrade(home, options);
return recovered.pointer;