mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 12:20:48 +02:00
fix: harden browser provider activation
This commit is contained in:
+3
-5
@@ -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
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user