fix: harden browser provider activation

This commit is contained in:
Sinabina
2026-07-21 12:13:33 -07:00
parent e3effb3fc4
commit a84a6e233d
65 changed files with 1203 additions and 171 deletions
+3 -5
View File
@@ -627,13 +627,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");
let rollbackBrowserChoice = null;
const pointer = await rollbackUpgrade(home, {
healthCheck: async (fallbackPath) => {
rollbackBrowserChoice = await resolvedBrowserChoiceForRuntimePath(fallbackPath);
},
prepareActivation: async (fallbackPath) => ({
browserChoice: await resolvedBrowserChoiceForRuntimePath(fallbackPath),
}),
});
if (rollbackBrowserChoice) await configSetBrowserChoice(home, rollbackBrowserChoice);
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`);
return 0;
}
+23 -8
View File
@@ -143,7 +143,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;
@@ -158,8 +162,11 @@ export async function runDoctor(options = {}) {
add(`capability:${capability}`, "fail", "selected but required launcher metadata is missing");
continue;
}
if (capability === "browser") {
const browser = runtimeConfig?.browser?.provider === "installed"
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",
@@ -167,7 +174,11 @@ export async function runDoctor(options = {}) {
options,
)
: runtimeConfig?.browser?.provider === "managed"
? await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node")
? 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;
@@ -245,7 +256,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([
@@ -260,11 +271,15 @@ 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}` };
}
@@ -311,7 +326,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;
+14 -7
View File
@@ -1641,6 +1641,12 @@ const bundle = await fs.readFile(path.join(root, ".gstack-bundle.json"), "utf8")
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"
@@ -1656,16 +1662,16 @@ if (browserBacked) {
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");
}
const visibleRequested = relative.startsWith("browse/") && (
args.includes("connect") ||
args.includes("handoff") ||
args.includes("--headed") ||
(args[0] === "pair-agent" && !args.includes("--headless"))
);
if (visibleRequested) {
throw new Error("Visible GStack Browser requires managed Chromium; preview and approve the browser-visible capability first");
}
@@ -1835,7 +1841,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");
}
+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");
}
+83 -4
View File
@@ -123,13 +123,36 @@ export async function main(argv = process.argv.slice(2), options = {}) {
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, browserChoice });
return await installFromSource(parsed.source, parsed, {
...options,
...io,
prepared: false,
replaceCapabilities: true,
browserChoice,
});
}
const fetch_ = options.fetch ?? globalThis.fetch;
@@ -146,7 +169,23 @@ export async function main(argv = process.argv.slice(2), options = {}) {
});
validateManifest(manifest, target);
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
const reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
const 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);
@@ -300,6 +339,24 @@ function selectedComponents(capabilities, browserChoice) {
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, browserChoice) {
const components = selectedComponents(capabilities, browserChoice);
const retained = new Set(reusable?.components ?? []);
@@ -341,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();
@@ -360,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) {
@@ -496,6 +574,7 @@ async function installFromSource(source, parsed, options) {
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;
+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;