mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
fix: harden browser provider activation
This commit is contained in:
@@ -161,6 +161,23 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("managed headless launchers require the separately approved visible-browser slot", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "managed-visible-refusal", {
|
||||
entries: BROWSER_ENTRIES,
|
||||
capabilities: BROWSER_CAPABILITIES,
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
await expect(runInstalledLauncher(home, "browse", ["--headed"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["connect"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
await expect(runInstalledLauncher(home, "browse", ["handoff"], { capture: true }))
|
||||
.rejects.toMatchObject({ stderr: expect.stringContaining("does not include visible Chromium") });
|
||||
});
|
||||
});
|
||||
|
||||
test("design-only launchers do not require an unrelated browser selection", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const design = path.join(source, "design", "dist", "design");
|
||||
@@ -248,6 +265,45 @@ describe("GStack 2 managed runtime installer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("rollback switches the runtime pointer and recorded browser choice in one recoverable transaction", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const fallback = await installFixture(source, home, "installed-fallback-valid");
|
||||
const fallbackManifestPath = path.join(fallback.path, ".gstack-bundle.json");
|
||||
const fallbackManifest = await readJson(fallbackManifestPath);
|
||||
await fs.writeFile(fallbackManifestPath, JSON.stringify({
|
||||
...fallbackManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: executable },
|
||||
}));
|
||||
|
||||
const current = await installFixture(source, home, "managed-current-valid");
|
||||
const currentManifestPath = path.join(current.path, ".gstack-bundle.json");
|
||||
const currentManifest = await readJson(currentManifestPath);
|
||||
await fs.writeFile(currentManifestPath, JSON.stringify({
|
||||
...currentManifest,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-headless", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
|
||||
const output = captureStream();
|
||||
expect(await runtimeMain(["upgrade", "--rollback"], {
|
||||
cwd: source,
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
})).toBe(0);
|
||||
expect(await readJson(path.join(home, "versions", "current.json")))
|
||||
.toMatchObject({ current: "installed-fallback-valid", lastKnownGood: "managed-current-valid" });
|
||||
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||
.toEqual({ provider: "installed", executablePath: executable });
|
||||
expect(await exists(path.join(home, ".gstack-runtime-transaction.json"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("an installed-browser setup persists the choice only after activation and launches through it", async () => {
|
||||
await withFixture(async ({ root, source, home }) => {
|
||||
await fs.writeFile(path.join(source, "cap", "tool"), `#!/usr/bin/env node
|
||||
@@ -958,14 +1014,17 @@ process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||
test("a launcher repairs a crash journal before resolving any runtime", async () => {
|
||||
await withFixture(async ({ source, home }) => {
|
||||
await installFixture(source, home, "1.0.0");
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
const pointer = await readJson(path.join(home, "versions", "current.json"));
|
||||
const manifest = await fs.readFile(path.join(home, "runtime-install.json"));
|
||||
const config = await fs.readFile(path.join(home, "config.json"));
|
||||
// Keep the launcher for this host executable so it can enter the shared
|
||||
// recovery path. The transaction restores the inactive host variant.
|
||||
const recoverableLauncher = process.platform === "win32" ? "gstack" : "gstack.cmd";
|
||||
const launcherPath = path.join(home, "bin", recoverableLauncher);
|
||||
const launcherBefore = await fs.readFile(launcherPath);
|
||||
await fs.writeFile(path.join(home, "runtime-install.json"), '{"activeVersion":"crashed"}\n');
|
||||
await configSetBrowserChoice(home, { provider: "installed", executablePath: process.execPath });
|
||||
await fs.writeFile(launcherPath, "candidate launcher\n");
|
||||
await fs.writeFile(path.join(home, "versions", "current.json"), `${JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
@@ -982,6 +1041,7 @@ process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||
previousPointerExists: true,
|
||||
previousPointer: pointer,
|
||||
files: [
|
||||
{ path: "config.json", existed: true, mode: 0o644, dataBase64: config.toString("base64") },
|
||||
{ path: "runtime-install.json", existed: true, mode: 0o600, dataBase64: manifest.toString("base64") },
|
||||
{ path: `bin/${recoverableLauncher}`, existed: true, mode: 0o644, dataBase64: launcherBefore.toString("base64") },
|
||||
],
|
||||
@@ -997,6 +1057,7 @@ process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||
const launched = await runInstalledLauncher(home, "gstack", ["doctor"], { capture: true });
|
||||
expect(launched.stdout).toContain("gstack fixture doctor");
|
||||
expect(await readJson(path.join(home, "versions", "current.json"))).toEqual(pointer);
|
||||
expect(await fs.readFile(path.join(home, "config.json"))).toEqual(config);
|
||||
expect(await fs.readFile(path.join(home, "runtime-install.json"))).toEqual(manifest);
|
||||
expect(await fs.readFile(launcherPath)).toEqual(launcherBefore);
|
||||
expect(await exists(path.join(home, ".gstack-runtime-transaction.json"))).toBe(false);
|
||||
|
||||
@@ -249,6 +249,25 @@ describe("one config authority", () => {
|
||||
expect(setup.stdout).toContain("optional runtime: unchanged");
|
||||
});
|
||||
|
||||
test("generic config writes cannot create an incoherent browser selection", async () => {
|
||||
const base = await root();
|
||||
const home = path.join(base, "state");
|
||||
const project = path.join(base, "project");
|
||||
await fs.mkdir(project);
|
||||
const run = (args: string[]) => spawnSync(process.execPath, [gstackBin, ...args], {
|
||||
cwd: project,
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GSTACK_HOME: home },
|
||||
});
|
||||
|
||||
for (const key of ["browser", "browser.provider", "browser.executablePath"]) {
|
||||
const result = run(["config", "set", key, "installed"]);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("gstack config browser");
|
||||
}
|
||||
expect(await fs.stat(path.join(home, "config.json")).catch(() => null)).toBeNull();
|
||||
});
|
||||
|
||||
test("legacy YAML is read-only migration input and JSON takes authority on write", async () => {
|
||||
const home = path.join(await root(), "legacy");
|
||||
await fs.mkdir(home);
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { runDoctor } from "../runtime/doctor.js";
|
||||
import { runInstallerCli, runtimeSlotVersion, runtimeSurfaceForCapabilities } from "../runtime/install.js";
|
||||
@@ -49,6 +50,36 @@ function officialManifestFixture(target: string, customize?: (component: string,
|
||||
};
|
||||
}
|
||||
|
||||
async function createActiveRuntimeFixture(home: string, options: {
|
||||
bundleVersion: string;
|
||||
selectedCapabilities: string[];
|
||||
runtimeComponents: string[];
|
||||
browserChoice: { provider: "managed" | "installed"; executablePath: string | null };
|
||||
}) {
|
||||
const root = path.join(home, "versions", "active-slot");
|
||||
const payload = Buffer.from("verified active runtime payload\n");
|
||||
await fs.mkdir(root, { recursive: true });
|
||||
await fs.writeFile(path.join(root, "payload.txt"), payload);
|
||||
await fs.writeFile(path.join(root, ".gstack-bundle.json"), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
version: options.bundleVersion,
|
||||
selectedCapabilities: options.selectedCapabilities,
|
||||
runtimeComponents: options.runtimeComponents,
|
||||
browserChoice: options.browserChoice,
|
||||
files: [{
|
||||
path: "payload.txt",
|
||||
size: payload.byteLength,
|
||||
sha256: createHash("sha256").update(payload).digest("hex"),
|
||||
}],
|
||||
}));
|
||||
await fs.writeFile(path.join(home, "versions", "current.json"), JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
status: "active",
|
||||
current: "active-slot",
|
||||
lastKnownGood: null,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("GStack runtime setup UX", () => {
|
||||
test("capability selection keeps the core and excludes unselected heavyweight surfaces", () => {
|
||||
const surface = runtimeSurfaceForCapabilities(["browser"]);
|
||||
@@ -277,6 +308,32 @@ describe("GStack runtime setup UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("explicit install-later needs no browser selection and does not prompt or mutate", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-later-"));
|
||||
try {
|
||||
const home = path.join(root, "home");
|
||||
const output = capture();
|
||||
const input = Readable.from([]) as Readable & { isTTY: boolean };
|
||||
input.isTTY = false;
|
||||
expect(await runInstallerCli([
|
||||
"--source", path.resolve(import.meta.dir, ".."),
|
||||
"--home", home,
|
||||
"--capabilities", "browser",
|
||||
"--install-later",
|
||||
"--json",
|
||||
], { stdin: input, stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
expect(JSON.parse(output.value())).toMatchObject({
|
||||
ok: true,
|
||||
action: "install-later",
|
||||
mutated: false,
|
||||
preview: null,
|
||||
});
|
||||
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("browser bootstrap options are local-only and browser preview requires an explicit choice", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
@@ -332,6 +389,76 @@ describe("GStack runtime setup UX", () => {
|
||||
expect(fetches).toBe(1);
|
||||
});
|
||||
|
||||
test("official previews retain an active installed-browser choice across same- and cross-release additions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-retain-browser-"));
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
try {
|
||||
for (const [name, bundleVersion, expectsReuse] of [
|
||||
["same", `${BOOTSTRAP_RUNTIME_VERSION}-caps-installed`, true],
|
||||
["cross", "1.9.0-caps-installed", false],
|
||||
] as const) {
|
||||
const home = path.join(root, name);
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion,
|
||||
selectedCapabilities: ["browser"],
|
||||
runtimeComponents: ["browser-code", "core"],
|
||||
browserChoice: { provider: "installed", executablePath: executable },
|
||||
});
|
||||
const output = capture();
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "design", "--home", home, "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||
fetch: async (url: string) => ({ ok: true, url, json: async () => officialManifestFixture(target) }),
|
||||
})).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result.capabilities).toEqual(["browser", "design"]);
|
||||
expect(result.browser).toEqual({ provider: "installed", executablePath: executable });
|
||||
expect(result.components).toEqual(["browser-code", "core", "design"]);
|
||||
expect(result.components).not.toContain("browser-headless");
|
||||
expect(result.reusedComponents.length > 0).toBe(expectsReuse);
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("switching a reusable managed visible slot to installed drops visible payload from the exact plan", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-provider-switch-"));
|
||||
const home = path.join(root, "home");
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||
try {
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion: `${BOOTSTRAP_RUNTIME_VERSION}-caps-managed-visible`,
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
runtimeComponents: ["browser-code", "browser-visible", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
const output = capture();
|
||||
expect(await bootstrapMain([
|
||||
"preview", "--capability", "browser", "--browser", "installed",
|
||||
"--browser-path", executable, "--home", home, "--json",
|
||||
], {
|
||||
stdout: output.stream,
|
||||
stderr: output.stream,
|
||||
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||
fetch: async (url: string) => ({ ok: true, url, json: async () => officialManifestFixture(target) }),
|
||||
})).toBe(0);
|
||||
const result = JSON.parse(output.value());
|
||||
expect(result.capabilities).toEqual(["browser"]);
|
||||
expect(result.browser.provider).toBe("installed");
|
||||
expect(result.components).toEqual(["browser-code", "core"]);
|
||||
expect(result.components).not.toContain("browser-visible");
|
||||
expect(result.components).not.toContain("browser-headless");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("visible GStack Browser refuses installed Chrome before any network request", async () => {
|
||||
const output = capture();
|
||||
let fetches = 0;
|
||||
@@ -468,6 +595,43 @@ describe("GStack runtime setup UX", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor reports and launches an internal managed visible-browser slot", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-visible-browser-"));
|
||||
const home = path.join(root, "home");
|
||||
try {
|
||||
await setupRuntime({ home, cwd: root });
|
||||
const paths = resolveRuntimePaths({ home });
|
||||
const active = path.join(paths.versions, "fixture");
|
||||
const browserRoot = path.join(active, ".gstack-runtime-browsers");
|
||||
const managedBun = path.join(active, ".gstack-runtime-tools", process.platform === "win32" ? "bun.exe" : "bun");
|
||||
const playwright = path.join(active, "node_modules", "playwright");
|
||||
await fs.mkdir(path.join(browserRoot, "chromium-fixture"), { recursive: true });
|
||||
await fs.mkdir(path.dirname(managedBun), { recursive: true });
|
||||
await fs.mkdir(playwright, { recursive: true });
|
||||
await fs.copyFile(process.execPath, managedBun);
|
||||
if (process.platform !== "win32") await fs.chmod(managedBun, 0o755);
|
||||
await fs.writeFile(path.join(playwright, "index.mjs"),
|
||||
`export const chromium = { launch: async ({ headless, channel }) => { if (headless !== true || channel !== "chromium") throw new Error("expected full Chromium channel"); return { version: () => "fixture-visible", close: async () => {} }; } };\n`);
|
||||
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||
compatibility: { skillApi: "2.0" },
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
capabilities: { browse: "browse/dist/browse" },
|
||||
tools: { bun: { path: path.relative(active, managedBun).split(path.sep).join("/"), version: "1.3.14" } },
|
||||
}));
|
||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
||||
}));
|
||||
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||
expect(report.checks.find((check) => check.id === "capability:browser-visible")).toMatchObject({
|
||||
status: "pass",
|
||||
details: { browserRoot, version: "fixture-visible" },
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("doctor launches the explicitly selected installed browser through the same Playwright adapter", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-doctor-installed-browser-"));
|
||||
const home = path.join(root, "home");
|
||||
@@ -660,9 +824,50 @@ describe("GStack runtime setup UX", () => {
|
||||
expect(args).toContain("--yes");
|
||||
expect(args).toContain("browser,diagram,pdf");
|
||||
expect(args).not.toContain("--prepared");
|
||||
expect(args).toContain("--replace-capabilities");
|
||||
expect(output.value()).toContain("Developer-only source install");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("developer source fallback can switch a retained managed visible slot to installed", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-bootstrap-source-switch-"));
|
||||
const source = path.join(root, "source");
|
||||
const runtime = path.join(source, "runtime");
|
||||
const home = path.join(root, "home");
|
||||
const log = path.join(root, "args.json");
|
||||
const executable = await fs.realpath(process.execPath);
|
||||
const output = capture();
|
||||
try {
|
||||
await fs.mkdir(runtime, { recursive: true });
|
||||
await fs.writeFile(path.join(runtime, "install.js"),
|
||||
`import fs from "node:fs"; fs.writeFileSync(process.env.BOOTSTRAP_TEST_LOG, JSON.stringify(process.argv.slice(2)));\n`);
|
||||
await createActiveRuntimeFixture(home, {
|
||||
bundleVersion: `${BOOTSTRAP_RUNTIME_VERSION}-caps-managed-visible`,
|
||||
selectedCapabilities: ["browser-visible"],
|
||||
runtimeComponents: ["browser-code", "browser-visible", "core"],
|
||||
browserChoice: { provider: "managed", executablePath: null },
|
||||
});
|
||||
const previous = process.env.BOOTSTRAP_TEST_LOG;
|
||||
process.env.BOOTSTRAP_TEST_LOG = log;
|
||||
try {
|
||||
expect(await bootstrapMain([
|
||||
"install", "--source", source, "--capability", "browser", "--browser", "installed",
|
||||
"--browser-path", executable, "--home", home, "--yes",
|
||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||
} finally {
|
||||
if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG;
|
||||
else process.env.BOOTSTRAP_TEST_LOG = previous;
|
||||
}
|
||||
const args = JSON.parse(await fs.readFile(log, "utf8"));
|
||||
expect(args).toContain("browser");
|
||||
expect(args).toContain("installed");
|
||||
expect(args).toContain(executable);
|
||||
expect(args).toContain("--replace-capabilities");
|
||||
expect(args).not.toContain("browser-visible");
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user