mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-20 20:00:45 +02:00
feat: require explicit browser provider consent
This commit is contained in:
@@ -70,6 +70,7 @@ jobs:
|
|||||||
--version 2.0.0 \
|
--version 2.0.0 \
|
||||||
--install-now \
|
--install-now \
|
||||||
--yes \
|
--yes \
|
||||||
|
--browser managed \
|
||||||
--capabilities "$CAPABILITIES"
|
--capabilities "$CAPABILITIES"
|
||||||
active_slot=$(node -e 'const fs=require("fs"),p=process.argv[1];const v=JSON.parse(fs.readFileSync(p,"utf8")).current;if(typeof v!=="string"||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(v))process.exit(1);process.stdout.write(v)' "$GSTACK_HOME/versions/current.json")
|
active_slot=$(node -e 'const fs=require("fs"),p=process.argv[1];const v=JSON.parse(fs.readFileSync(p,"utf8")).current;if(typeof v!=="string"||!/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/.test(v))process.exit(1);process.stdout.write(v)' "$GSTACK_HOME/versions/current.json")
|
||||||
active="$GSTACK_HOME/versions/$active_slot"
|
active="$GSTACK_HOME/versions/$active_slot"
|
||||||
@@ -199,6 +200,6 @@ jobs:
|
|||||||
--verify-tag \
|
--verify-tag \
|
||||||
$PRERELEASE_FLAG \
|
$PRERELEASE_FLAG \
|
||||||
--title "GStack runtime $GITHUB_REF_NAME" \
|
--title "GStack runtime $GITHUB_REF_NAME" \
|
||||||
--notes "Signed optional runtime artifacts for the six portable GStack skills." \
|
--notes "Signed optional runtime artifacts for the six portable GStack skills. This release adds an explicit managed-versus-installed Chromium consent gate before browser preview or installation." \
|
||||||
release-output/*
|
release-output/*
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -65,14 +65,14 @@ jobs:
|
|||||||
- name: Preview without mutating state
|
- name: Preview without mutating state
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
bash ./setup --dry-run --capabilities browser
|
bash ./setup --dry-run --capabilities browser --browser managed
|
||||||
test ! -e "$GSTACK_HOME" || (echo "dry-run mutated GSTACK_HOME" && exit 1)
|
test ! -e "$GSTACK_HOME" || (echo "dry-run mutated GSTACK_HOME" && exit 1)
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
||||||
- name: Explicitly install the browser capability
|
- name: Explicitly install the browser capability
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
bash ./setup --install-now --yes --capabilities browser
|
bash ./setup --install-now --yes --capabilities browser --browser managed
|
||||||
test -f "$GSTACK_HOME/versions/current.json"
|
test -f "$GSTACK_HOME/versions/current.json"
|
||||||
test -f "$GSTACK_HOME/bin/gstack.cmd"
|
test -f "$GSTACK_HOME/bin/gstack.cmd"
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -44,6 +44,30 @@ export function isCustomChromium(): boolean {
|
|||||||
return p.includes('GBrowser') || p.includes('gbrowser');
|
return p.includes('GBrowser') || p.includes('gbrowser');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the explicitly selected Chromium executable for both headless and
|
||||||
|
* headed launches. Keeping this opt-in preserves the managed browser fallback
|
||||||
|
* while allowing the lightweight playwright-core adapter to reuse a system or
|
||||||
|
* host-managed Chrome without downloading Playwright's browser package.
|
||||||
|
*/
|
||||||
|
export function configuredChromiumExecutable(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): string | undefined {
|
||||||
|
const value = env.GSTACK_CHROMIUM_PATH?.trim();
|
||||||
|
return value || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Installed-system Chromium is supported only for headless automation. */
|
||||||
|
export function assertHeadedBrowserProvider(
|
||||||
|
env: NodeJS.ProcessEnv = process.env,
|
||||||
|
): void {
|
||||||
|
if (env.GSTACK_BROWSER_PROVIDER === 'installed') {
|
||||||
|
throw new Error(
|
||||||
|
'Visible GStack Browser requires managed Chromium; installed Chrome-family browsers are headless-only',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decide whether Playwright should request Chromium's sandbox.
|
* Decide whether Playwright should request Chromium's sandbox.
|
||||||
*
|
*
|
||||||
@@ -361,6 +385,7 @@ export class BrowserManager {
|
|||||||
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth');
|
||||||
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()];
|
||||||
let useHeadless = true;
|
let useHeadless = true;
|
||||||
|
const executablePath = configuredChromiumExecutable();
|
||||||
|
|
||||||
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
// Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which
|
||||||
// are typically disabled in containers and are never available for the root
|
// are typically disabled in containers and are never available for the root
|
||||||
@@ -387,7 +412,11 @@ export class BrowserManager {
|
|||||||
|
|
||||||
this.browser = await chromium.launch({
|
this.browser = await chromium.launch({
|
||||||
headless: useHeadless,
|
headless: useHeadless,
|
||||||
...(useHeadless && managedHeadlessChannel() ? { channel: 'chromium' as const } : {}),
|
...(executablePath
|
||||||
|
? { executablePath }
|
||||||
|
: useHeadless && managedHeadlessChannel()
|
||||||
|
? { channel: 'chromium' as const }
|
||||||
|
: {}),
|
||||||
// On Windows, Chromium's sandbox fails when the server is spawned through
|
// On Windows, Chromium's sandbox fails when the server is spawned through
|
||||||
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
// the Bun→Node process chain (GitHub #276). Disable it — local daemon
|
||||||
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
// browsing user-specified URLs has marginal sandbox benefit. Also disabled
|
||||||
@@ -447,6 +476,7 @@ export class BrowserManager {
|
|||||||
* every action Claude takes in real time.
|
* every action Claude takes in real time.
|
||||||
*/
|
*/
|
||||||
async launchHeaded(authToken?: string): Promise<void> {
|
async launchHeaded(authToken?: string): Promise<void> {
|
||||||
|
assertHeadedBrowserProvider();
|
||||||
// Clear old state before repopulating
|
// Clear old state before repopulating
|
||||||
this.pages.clear();
|
this.pages.clear();
|
||||||
this.tabSessions.clear();
|
this.tabSessions.clear();
|
||||||
@@ -515,7 +545,7 @@ export class BrowserManager {
|
|||||||
|
|
||||||
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
|
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
|
||||||
// Used by GStack Browser.app to point at the bundled Chromium.
|
// Used by GStack Browser.app to point at the bundled Chromium.
|
||||||
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
|
const executablePath = configuredChromiumExecutable();
|
||||||
|
|
||||||
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
// Rebrand Chromium → GStack Browser in macOS menu bar / Dock / Cmd+Tab.
|
||||||
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
// Patch the Chromium .app's Info.plist so macOS shows our name.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
||||||
import { startTestServer } from './test-server';
|
import { startTestServer } from './test-server';
|
||||||
import { BrowserManager } from '../src/browser-manager';
|
import { BrowserManager, assertHeadedBrowserProvider, configuredChromiumExecutable } from '../src/browser-manager';
|
||||||
import { resolveServerScript } from '../src/cli';
|
import { resolveServerScript } from '../src/cli';
|
||||||
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
|
import { handleReadCommand as _handleReadCommand, parseOutArgs, hasOutArg, resultToString } from '../src/read-commands';
|
||||||
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
import { handleWriteCommand as _handleWriteCommand } from '../src/write-commands';
|
||||||
@@ -23,6 +23,25 @@ const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
|||||||
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) =>
|
||||||
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
_handleWriteCommand(cmd, args, b.getActiveSession(), b);
|
||||||
|
|
||||||
|
describe('configuredChromiumExecutable', () => {
|
||||||
|
test('returns and trims an explicitly selected system browser', () => {
|
||||||
|
expect(configuredChromiumExecutable({
|
||||||
|
GSTACK_CHROMIUM_PATH: ' /Applications/Google Chrome.app/Contents/MacOS/Google Chrome ',
|
||||||
|
})).toBe('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps the managed-browser path when no override is selected', () => {
|
||||||
|
expect(configuredChromiumExecutable({})).toBeUndefined();
|
||||||
|
expect(configuredChromiumExecutable({ GSTACK_CHROMIUM_PATH: ' ' })).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects headed launch when setup selected an installed system browser', () => {
|
||||||
|
expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'installed' }))
|
||||||
|
.toThrow('Visible GStack Browser requires managed Chromium');
|
||||||
|
expect(() => assertHeadedBrowserProvider({ GSTACK_BROWSER_PROVIDER: 'managed' })).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
|
// ─── Pure arg-parser + result-conversion unit tests (no browser) ───
|
||||||
describe('parseOutArgs / hasOutArg', () => {
|
describe('parseOutArgs / hasOutArg', () => {
|
||||||
test('--out <path> splits the flag from the positional', () => {
|
test('--out <path> splits the flag from the positional', () => {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"html-to-docx": "1.8.0",
|
"html-to-docx": "1.8.0",
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "npm:playwright-core@^1.58.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socks": "^2.8.8",
|
"socks": "^2.8.8",
|
||||||
"xterm": "5",
|
"xterm": "5",
|
||||||
@@ -275,8 +275,6 @@
|
|||||||
|
|
||||||
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
|
||||||
|
|
||||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
|
||||||
|
|
||||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||||
|
|
||||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||||
@@ -411,9 +409,7 @@
|
|||||||
|
|
||||||
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
"platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="],
|
||||||
|
|
||||||
"playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="],
|
"playwright": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
||||||
|
|
||||||
"playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="],
|
|
||||||
|
|
||||||
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Parity is executable, not a prose claim. Run `bun run scripts/gstack2/run-parity.ts` or the dedicated Bun tests.
|
Parity is executable, not a prose claim. Run `bun run scripts/gstack2/run-parity.ts` or the dedicated Bun tests.
|
||||||
|
|
||||||
The pinned release inventory passes **4,833 checks** across 55 specialist sources, 16 carved sections, 25 routing scenarios, 16 regression ports, and **78 assets**.
|
The pinned release inventory passes **4,836 checks** across 55 specialist sources, 16 carved sections, 25 routing scenarios, 16 regression ports, and **78 assets**.
|
||||||
|
|
||||||
The suite verifies:
|
The suite verifies:
|
||||||
|
|
||||||
|
|||||||
@@ -2798,7 +2798,7 @@
|
|||||||
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
||||||
"normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf",
|
"normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6",
|
||||||
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
@@ -2873,7 +2873,7 @@
|
|||||||
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
||||||
"normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d",
|
"normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8",
|
||||||
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
@@ -2934,7 +2934,7 @@
|
|||||||
"source_path": "pair-agent/SKILL.md.tmpl",
|
"source_path": "pair-agent/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
||||||
"normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457",
|
"normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a",
|
||||||
"target": "skills/qa/references/legacy/pair-agent.md",
|
"target": "skills/qa/references/legacy/pair-agent.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
||||||
"normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf",
|
"normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6",
|
||||||
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
679
|
679
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"source_path": "pair-agent/SKILL.md.tmpl",
|
"source_path": "pair-agent/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
||||||
"normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457",
|
"normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a",
|
||||||
"target": "skills/qa/references/legacy/pair-agent.md",
|
"target": "skills/qa/references/legacy/pair-agent.md",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
679
|
679
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
||||||
"normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d",
|
"normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8",
|
||||||
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
679
|
679
|
||||||
|
|||||||
@@ -2798,7 +2798,7 @@
|
|||||||
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
"source_path": "open-gstack-browser/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
"blob_sha": "ef91a527890a3ac3622cc7dc84bad1ff7b64443b",
|
||||||
"normalized_render_sha256": "df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf",
|
"normalized_render_sha256": "e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6",
|
||||||
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
"target": "skills/qa/references/legacy/open-gstack-browser.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
@@ -2873,7 +2873,7 @@
|
|||||||
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
"source_path": "setup-browser-cookies/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
"blob_sha": "f812d9f56f27c32fb5f102083bbe418344c1a652",
|
||||||
"normalized_render_sha256": "04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d",
|
"normalized_render_sha256": "7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8",
|
||||||
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
"target": "skills/qa/references/legacy/setup-browser-cookies.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
@@ -2934,7 +2934,7 @@
|
|||||||
"source_path": "pair-agent/SKILL.md.tmpl",
|
"source_path": "pair-agent/SKILL.md.tmpl",
|
||||||
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
"base_sha": "bb57306d98c97011b0919c6132705a15b1579781",
|
||||||
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
"blob_sha": "75ed42d590f99c46cd0883c37bb1f2f9f499211c",
|
||||||
"normalized_render_sha256": "8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457",
|
"normalized_render_sha256": "256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a",
|
||||||
"target": "skills/qa/references/legacy/pair-agent.md",
|
"target": "skills/qa/references/legacy/pair-agent.md",
|
||||||
"disposition": "BUG_FIX",
|
"disposition": "BUG_FIX",
|
||||||
"overlays": [
|
"overlays": [
|
||||||
|
|||||||
+2
-2
@@ -36,7 +36,7 @@
|
|||||||
"server": "bun run browse/src/server.ts",
|
"server": "bun run browse/src/server.ts",
|
||||||
"test": "bun run scripts/test-free-strict.ts",
|
"test": "bun run scripts/test-free-strict.ts",
|
||||||
"check:gstack2-generated": "bun run scripts/gstack2/check-generated.ts",
|
"check:gstack2-generated": "bun run scripts/gstack2/check-generated.ts",
|
||||||
"test:gstack2": "bun run gen:gstack2 && bun run check:gstack2-generated && bun test --timeout 30000 test/gstack2-*.test.ts",
|
"test:gstack2": "bun run gen:gstack2 && bun run check:gstack2-generated && bun test --timeout 60000 test/gstack2-*.test.ts",
|
||||||
"test:gstack2:install": "bun run scripts/gstack2/test-install-matrix.ts --full",
|
"test:gstack2:install": "bun run scripts/gstack2/test-install-matrix.ts --full",
|
||||||
"test:gstack2:parity": "bun run ensure:gstack2-runtime && bun run scripts/gstack2/run-parity.ts",
|
"test:gstack2:parity": "bun run ensure:gstack2-runtime && bun run scripts/gstack2/run-parity.ts",
|
||||||
"test:free": "bun run scripts/test-free-shards.ts",
|
"test:free": "bun run scripts/test-free-shards.ts",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
"diff": "^9.0.0",
|
"diff": "^9.0.0",
|
||||||
"html-to-docx": "1.8.0",
|
"html-to-docx": "1.8.0",
|
||||||
"marked": "^18.0.2",
|
"marked": "^18.0.2",
|
||||||
"playwright": "^1.58.2",
|
"playwright": "npm:playwright-core@^1.58.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
"socks": "^2.8.8",
|
"socks": "^2.8.8",
|
||||||
"xterm": "5",
|
"xterm": "5",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+99
-2
@@ -9,10 +9,12 @@ import { setupRuntime } from "./setup.js";
|
|||||||
import {
|
import {
|
||||||
configGet,
|
configGet,
|
||||||
configSet,
|
configSet,
|
||||||
|
configSetBrowserChoice,
|
||||||
configSetNetworkChoice,
|
configSetNetworkChoice,
|
||||||
parseConfigValue,
|
parseConfigValue,
|
||||||
secretSet,
|
secretSet,
|
||||||
} from "./config.js";
|
} from "./config.js";
|
||||||
|
import { resolveBrowserChoice } from "./browser-choice.mjs";
|
||||||
import { discoverProjectIdentity } from "./identity.js";
|
import { discoverProjectIdentity } from "./identity.js";
|
||||||
import {
|
import {
|
||||||
beginRun,
|
beginRun,
|
||||||
@@ -172,12 +174,88 @@ async function configCommand({ args, home, cwd, stdout }) {
|
|||||||
if (action === "set") {
|
if (action === "set") {
|
||||||
const [key, value, ...rest] = tail;
|
const [key, value, ...rest] = tail;
|
||||||
if (!key || value === undefined || rest.length) throw cliError("Usage: gstack config set <key> <value>", "USAGE");
|
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 });
|
await setupRuntime({ home, cwd });
|
||||||
const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value)));
|
const result = await withOwnedRuntimeMutation(home, () => configSet(home, key, parseConfigValue(value)));
|
||||||
write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`);
|
write(stdout, `${key} = ${typeof result === "string" ? result : JSON.stringify(result)}\n`);
|
||||||
return 0;
|
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 }) {
|
async function stateCommand({ args, home, cwd, env, stdout, stderr }) {
|
||||||
@@ -549,7 +627,13 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
|
|||||||
if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE");
|
if (parsed.positionals.length) throw cliError("Upgrade accepts only named options", "USAGE");
|
||||||
if (parsed.flags.has("--rollback")) {
|
if (parsed.flags.has("--rollback")) {
|
||||||
if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE");
|
if (parsed.values.has("--source") || parsed.values.has("--version")) throw cliError("--rollback cannot be combined with staging options", "USAGE");
|
||||||
const pointer = await rollbackUpgrade(home);
|
let rollbackBrowserChoice = null;
|
||||||
|
const pointer = await rollbackUpgrade(home, {
|
||||||
|
healthCheck: async (fallbackPath) => {
|
||||||
|
rollbackBrowserChoice = 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`);
|
write(stdout, parsed.flags.has("--json") ? `${JSON.stringify(pointer, null, 2)}\n` : `Rolled back to ${pointer.current}\n`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -558,10 +642,22 @@ async function upgradeCommand({ args, home, stdout, installOptions = {} }) {
|
|||||||
if (!sourceDir || !version) {
|
if (!sourceDir || !version) {
|
||||||
throw cliError("Usage: gstack upgrade --source <complete-gstack-package> --version <version> | --rollback", "USAGE");
|
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({
|
const result = await installManagedRuntime({
|
||||||
home,
|
home,
|
||||||
sourceDir,
|
sourceDir,
|
||||||
version,
|
version,
|
||||||
|
...(browserChoice ? { browserChoice } : {}),
|
||||||
...installOptions,
|
...installOptions,
|
||||||
buildMissing: false,
|
buildMissing: false,
|
||||||
rejectSourceRootLink: true,
|
rejectSourceRootLink: true,
|
||||||
@@ -712,6 +808,7 @@ function usage() {
|
|||||||
" gstack runtime path <bundle-relative-path>\n" +
|
" gstack runtime path <bundle-relative-path>\n" +
|
||||||
" gstack config get [key]\n" +
|
" gstack config get [key]\n" +
|
||||||
" gstack config set <key> <value>\n" +
|
" gstack config set <key> <value>\n" +
|
||||||
|
" gstack config browser managed|installed <absolute-path>|clear\n" +
|
||||||
" gstack state inspect [run-id]\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 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" +
|
" 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" +
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { atomicWriteJson, readJson, withLock } from "./storage.js";
|
import { atomicWriteJson, readJson, withLock } from "./storage.js";
|
||||||
import { resolveRuntimePaths } from "./paths.js";
|
import { resolveRuntimePaths } from "./paths.js";
|
||||||
|
import { BROWSER_PROVIDERS } from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const DEFAULT_CONFIG = Object.freeze({
|
export const DEFAULT_CONFIG = Object.freeze({
|
||||||
schemaVersion: 2,
|
schemaVersion: 2,
|
||||||
@@ -10,6 +11,7 @@ export const DEFAULT_CONFIG = Object.freeze({
|
|||||||
baseUrl: "https://api.context.dev/v1",
|
baseUrl: "https://api.context.dev/v1",
|
||||||
validation: Object.freeze({ status: "unverified", checkedAt: null }),
|
validation: Object.freeze({ status: "unverified", checkedAt: null }),
|
||||||
}),
|
}),
|
||||||
|
browser: Object.freeze({ provider: null, executablePath: null }),
|
||||||
cleanup: Object.freeze({ retentionDays: 30 }),
|
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) {
|
async function updateConfig(home, mutate) {
|
||||||
const paths = resolveRuntimePaths({ home });
|
const paths = resolveRuntimePaths({ home });
|
||||||
return withLock(path.join(paths.locks, "config.lock"), async () => {
|
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");
|
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() {
|
function cloneDefaultConfig() {
|
||||||
@@ -223,6 +262,7 @@ function mergeDefaults(stored) {
|
|||||||
...stored,
|
...stored,
|
||||||
network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) },
|
network: { ...DEFAULT_CONFIG.network, ...(stored.network ?? {}) },
|
||||||
context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) },
|
context: { ...DEFAULT_CONFIG.context, ...(stored.context ?? {}) },
|
||||||
|
browser: { ...DEFAULT_CONFIG.browser, ...(stored.browser ?? {}) },
|
||||||
cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) },
|
cleanup: { ...DEFAULT_CONFIG.cleanup, ...(stored.cleanup ?? {}) },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-5
@@ -10,6 +10,7 @@ import { RUNTIME_SCHEMA_VERSION, RUNTIME_MIGRATION_ID } from "./migrations.js";
|
|||||||
import { assertManagedHome } from "./managed-home.js";
|
import { assertManagedHome } from "./managed-home.js";
|
||||||
import { recoverPendingUpgrade } from "./upgrade.js";
|
import { recoverPendingUpgrade } from "./upgrade.js";
|
||||||
import { bashCandidates } from "./tooling.js";
|
import { bashCandidates } from "./tooling.js";
|
||||||
|
import { resolveBrowserChoice } from "./browser-choice.mjs";
|
||||||
import {
|
import {
|
||||||
OPTIONAL_RUNTIME_CAPABILITIES,
|
OPTIONAL_RUNTIME_CAPABILITIES,
|
||||||
RUNTIME_CAPABILITY_DEPENDENCIES,
|
RUNTIME_CAPABILITY_DEPENDENCIES,
|
||||||
@@ -23,6 +24,7 @@ export async function runDoctor(options = {}) {
|
|||||||
const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) });
|
const add = (id, status, message, details) => checks.push({ id, status, message, ...(details ? { details } : {}) });
|
||||||
const now = options.now ? options.now() : new Date();
|
const now = options.now ? options.now() : new Date();
|
||||||
const expectedSkillApi = options.expectedSkillApi ?? RUNTIME_COMPATIBILITY.skillApi;
|
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)) {
|
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");
|
throw new TypeError("Expected skill API must be a short version identifier");
|
||||||
}
|
}
|
||||||
@@ -51,12 +53,16 @@ export async function runDoctor(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = await readJson(paths.config);
|
runtimeConfig = await readJson(paths.config);
|
||||||
add("config", config?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
|
add("config", runtimeConfig?.schemaVersion <= RUNTIME_SCHEMA_VERSION ? "pass" : "fail",
|
||||||
`Config schema ${config?.schemaVersion ?? "unknown"}`);
|
`Config schema ${runtimeConfig?.schemaVersion ?? "unknown"}`);
|
||||||
const enabled = config?.network?.mode === "context" && config?.network?.consent === true;
|
const enabled = runtimeConfig?.network?.mode === "context" && runtimeConfig?.network?.consent === true;
|
||||||
add("network", enabled ? "pass" : "warn",
|
add("network", enabled ? "pass" : "warn",
|
||||||
enabled ? "Context.dev network mode has explicit consent" : "Network access is off (safe default)");
|
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) {
|
} catch (error) {
|
||||||
add("config", "fail", `Config cannot be read: ${error.message}`);
|
add("config", "fail", `Config cannot be read: ${error.message}`);
|
||||||
}
|
}
|
||||||
@@ -153,7 +159,16 @@ export async function runDoctor(options = {}) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (capability === "browser") {
|
if (capability === "browser") {
|
||||||
const browser = await inspectManagedChromium(activeRoot, options.nodeCommand ?? process.env.GSTACK_NODE ?? "node");
|
const browser = 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")
|
||||||
|
: { 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);
|
add(`capability:${capability}`, browser.ok ? "pass" : "fail", browser.message, browser.details);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -255,6 +270,36 @@ async function inspectManagedChromium(activeRoot, nodeCommand) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function inspectXcrun() {
|
||||||
if (process.platform !== "darwin") return { ok: false, message: "physical-iOS capability requires macOS" };
|
if (process.platform !== "darwin") return { ok: false, message: "physical-iOS capability requires macOS" };
|
||||||
try {
|
try {
|
||||||
|
|||||||
+199
-22
@@ -20,6 +20,14 @@ import {
|
|||||||
} from "./managed-home.js";
|
} from "./managed-home.js";
|
||||||
import { errorWithCode as installError } from "./errors.js";
|
import { errorWithCode as installError } from "./errors.js";
|
||||||
import { currentIsoTimestamp as isoNow } from "./time.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;
|
const INSTALL_SCHEMA_VERSION = 2;
|
||||||
export const MAX_RUNTIME_BUNDLE_BYTES = 2 * 1024 * 1024 * 1024;
|
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("browse/src"),
|
||||||
entry("extension"),
|
entry("extension"),
|
||||||
entry("node_modules/playwright"),
|
entry("node_modules/playwright"),
|
||||||
entry("node_modules/playwright-core"),
|
|
||||||
entry(managedBunRelativePath(), "managed-bun", true),
|
entry(managedBunRelativePath(), "managed-bun", true),
|
||||||
entry(".gstack-runtime-browsers", "browser"),
|
entry(".gstack-runtime-browsers", "browser"),
|
||||||
entry("node_modules/diff"),
|
entry("node_modules/diff"),
|
||||||
@@ -321,10 +328,11 @@ const CAPABILITY_PATH_PREFIXES = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Resolve the audited core plus only explicitly selected optional capabilities. */
|
/** 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 selected = normalizeCapabilitySelection(input);
|
||||||
const includesBrowserCode = selected.includes("browser") || selected.includes("browser-visible");
|
const includesBrowserCode = selected.includes("browser") || selected.includes("browser-visible");
|
||||||
const entries = DEFAULT_RUNTIME_BUNDLE.filter((item) => {
|
const entries = DEFAULT_RUNTIME_BUNDLE.filter((item) => {
|
||||||
|
if (options.browserChoice?.provider === "installed" && item.path === ".gstack-runtime-browsers") return false;
|
||||||
const owner = capabilityForPath(item.path);
|
const owner = capabilityForPath(item.path);
|
||||||
return owner == null || selected.includes(owner) || (owner === "browser" && includesBrowserCode);
|
return owner == null || selected.includes(owner) || (owner === "browser" && includesBrowserCode);
|
||||||
});
|
});
|
||||||
@@ -336,7 +344,7 @@ export function runtimeSurfaceForCapabilities(input = OPTIONAL_RUNTIME_CAPABILIT
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Expand logical runtime capabilities into the signed internal components. */
|
/** 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 capabilities = normalizeCapabilitySelection(input);
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
@@ -351,13 +359,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);
|
validateVersion(releaseVersion);
|
||||||
const selected = normalizeCapabilitySelection(capabilityIds);
|
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);
|
const prefix = String(releaseVersion).slice(0, 60);
|
||||||
return `${prefix}-caps-${digest}`;
|
return `${prefix}-caps-${digest}`;
|
||||||
}
|
}
|
||||||
@@ -365,7 +376,7 @@ export function runtimeSlotVersion(releaseVersion, capabilityIds) {
|
|||||||
export async function previewManagedRuntime(options = {}) {
|
export async function previewManagedRuntime(options = {}) {
|
||||||
if (!options.sourceDir) throw installError("sourceDir is required", "INSTALL_SOURCE_REQUIRED");
|
if (!options.sourceDir) throw installError("sourceDir is required", "INSTALL_SOURCE_REQUIRED");
|
||||||
const sourceDir = await resolvePhysicalSource(options.sourceDir);
|
const sourceDir = await resolvePhysicalSource(options.sourceDir);
|
||||||
const surface = runtimeSurfaceForCapabilities(options.capabilityIds);
|
const surface = runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice });
|
||||||
let bytes = 0;
|
let bytes = 0;
|
||||||
let files = 0;
|
let files = 0;
|
||||||
const missing = [];
|
const missing = [];
|
||||||
@@ -419,6 +430,7 @@ export async function previewManagedRuntime(options = {}) {
|
|||||||
return Object.freeze({
|
return Object.freeze({
|
||||||
sourceDir,
|
sourceDir,
|
||||||
capabilities: surface.selected,
|
capabilities: surface.selected,
|
||||||
|
browser: browserChoiceRequired(surface.selected) ? options.browserChoice ?? null : null,
|
||||||
components: surface.entries.length,
|
components: surface.entries.length,
|
||||||
files,
|
files,
|
||||||
bytes,
|
bytes,
|
||||||
@@ -460,7 +472,7 @@ export async function installManagedRuntime(options = {}) {
|
|||||||
if (options.requirePackageIdentity) validatePackageIdentity(packageMetadata, version);
|
if (options.requirePackageIdentity) validatePackageIdentity(packageMetadata, version);
|
||||||
|
|
||||||
const selectedSurface = options.entries == null
|
const selectedSurface = options.entries == null
|
||||||
? runtimeSurfaceForCapabilities(options.capabilityIds)
|
? runtimeSurfaceForCapabilities(options.capabilityIds, { browserChoice: options.browserChoice })
|
||||||
: null;
|
: null;
|
||||||
const entries = normalizeEntries(options.entries ?? selectedSurface.entries);
|
const entries = normalizeEntries(options.entries ?? selectedSurface.entries);
|
||||||
const capabilities = normalizeCapabilities(options.capabilities ?? selectedSurface.capabilities, entries);
|
const capabilities = normalizeCapabilities(options.capabilities ?? selectedSurface.capabilities, entries);
|
||||||
@@ -581,7 +593,15 @@ export async function installManagedRuntime(options = {}) {
|
|||||||
version,
|
version,
|
||||||
compatibility: RUNTIME_COMPATIBILITY,
|
compatibility: RUNTIME_COMPATIBILITY,
|
||||||
selectedCapabilities: selectedSurface?.selected ?? null,
|
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),
|
components: entries.map(({ path: component }) => component),
|
||||||
capabilities,
|
capabilities,
|
||||||
stableSourceFiles,
|
stableSourceFiles,
|
||||||
@@ -617,6 +637,7 @@ export async function installManagedRuntime(options = {}) {
|
|||||||
nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
|
nodeCommand: options.nodeCommand ?? process.env.GSTACK_NODE ?? "node",
|
||||||
run: options.runCommand ?? runCommand,
|
run: options.runCommand ?? runCommand,
|
||||||
commandTimeoutMs: options.commandTimeoutMs,
|
commandTimeoutMs: options.commandTimeoutMs,
|
||||||
|
browserChoice: selectedSurface ? options.browserChoice : null,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
beforeActivate: async ({ active, previous, previousExists, destination }) => {
|
beforeActivate: async ({ active, previous, previousExists, destination }) => {
|
||||||
@@ -629,6 +650,7 @@ export async function installManagedRuntime(options = {}) {
|
|||||||
await removeObsoleteLaunchers(paths, snapshot, launcherSurface);
|
await removeObsoleteLaunchers(paths, snapshot, launcherSurface);
|
||||||
const manifestWriter = options.manifestWriter ?? writeInstallManifest;
|
const manifestWriter = options.manifestWriter ?? writeInstallManifest;
|
||||||
installManifest = await manifestWriter(paths, active, launcherSurface, options.now);
|
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 }),
|
afterActivate: async () => fs.rm(path.join(home, RUNTIME_TRANSACTION_FILE), { force: true }),
|
||||||
onRollback: async ({ pointerRollbackError }) => {
|
onRollback: async ({ pointerRollbackError }) => {
|
||||||
@@ -895,6 +917,26 @@ export async function smokeRuntimeBundle(directory, options = {}) {
|
|||||||
cause,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -913,7 +955,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
|
|||||||
const stdout = options.stdout ?? process.stdout;
|
const stdout = options.stdout ?? process.stdout;
|
||||||
const bunCommand = parsed.bunCommand ?? env.BUN_CMD ?? "bun";
|
const bunCommand = parsed.bunCommand ?? env.BUN_CMD ?? "bun";
|
||||||
let capabilityIds = parsed.capabilityIds;
|
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(
|
const answer = await askInstallerQuestion(
|
||||||
stdin,
|
stdin,
|
||||||
options.stderr ?? process.stderr,
|
options.stderr ?? process.stderr,
|
||||||
@@ -921,10 +963,52 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
|
|||||||
);
|
);
|
||||||
capabilityIds = parseCapabilityList(answer || "all");
|
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);
|
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({
|
const preview = await previewManagedRuntime({
|
||||||
sourceDir,
|
sourceDir,
|
||||||
capabilityIds,
|
capabilityIds,
|
||||||
|
browserChoice,
|
||||||
bunCommand,
|
bunCommand,
|
||||||
preparedSource: parsed.prepared,
|
preparedSource: parsed.prepared,
|
||||||
runCommand: options.installOptions?.runCommand,
|
runCommand: options.installOptions?.runCommand,
|
||||||
@@ -966,9 +1050,10 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
|
|||||||
const result = await installManagedRuntime({
|
const result = await installManagedRuntime({
|
||||||
sourceDir,
|
sourceDir,
|
||||||
home,
|
home,
|
||||||
version: runtimeSlotVersion(releaseVersion, capabilityIds),
|
version: runtimeSlotVersion(releaseVersion, capabilityIds, { browserChoice }),
|
||||||
bunCommand,
|
bunCommand,
|
||||||
capabilityIds,
|
capabilityIds,
|
||||||
|
browserChoice,
|
||||||
buildMissing: parsed.prepared ? false : undefined,
|
buildMissing: parsed.prepared ? false : undefined,
|
||||||
nodeCommand: env.GSTACK_NODE ?? "node",
|
nodeCommand: env.GSTACK_NODE ?? "node",
|
||||||
launcherNodeCommand: env.GSTACK_NODE ?? "node",
|
launcherNodeCommand: env.GSTACK_NODE ?? "node",
|
||||||
@@ -981,7 +1066,7 @@ export async function runInstallerCli(argv = process.argv.slice(2), options = {}
|
|||||||
stdout.write(`Installed gstack runtime ${releaseVersion}\n`);
|
stdout.write(`Installed gstack runtime ${releaseVersion}\n`);
|
||||||
stdout.write(`Runtime home: ${result.home}\n`);
|
stdout.write(`Runtime home: ${result.home}\n`);
|
||||||
stdout.write(`Launcher directory: ${path.join(result.home, "bin")}\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;
|
return 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1549,6 +1634,49 @@ if (!stat?.isFile() || stat.isSymbolicLink()) throw new Error("Active capability
|
|||||||
const managedBrowsers = path.join(root, ".gstack-runtime-browsers");
|
const managedBrowsers = path.join(root, ".gstack-runtime-browsers");
|
||||||
const browserStat = await fs.lstat(managedBrowsers).catch(() => null);
|
const browserStat = await fs.lstat(managedBrowsers).catch(() => null);
|
||||||
if (browserStat?.isSymbolicLink()) throw new Error("Managed browser directory is unsafe");
|
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 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 (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");
|
||||||
|
}
|
||||||
|
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 managedBun = path.join(root, ${JSON.stringify(managedBunRelativePath())});
|
||||||
const bunStat = await fs.lstat(managedBun).catch(() => null);
|
const bunStat = await fs.lstat(managedBun).catch(() => null);
|
||||||
const hasManagedBun = bunStat?.isFile() && !bunStat.isSymbolicLink();
|
const hasManagedBun = bunStat?.isFile() && !bunStat.isSymbolicLink();
|
||||||
@@ -1575,10 +1703,7 @@ if (/^#!.*\\bbun(?:\\s|$)/.test(header)) {
|
|||||||
command = process.env.GSTACK_NODE || process.execPath;
|
command = process.env.GSTACK_NODE || process.execPath;
|
||||||
commandArgs = [target, ...args];
|
commandArgs = [target, ...args];
|
||||||
}
|
}
|
||||||
const child = spawn(command, commandArgs, {
|
const childEnv = {
|
||||||
stdio: "inherit",
|
|
||||||
windowsHide: true,
|
|
||||||
env: {
|
|
||||||
...process.env,
|
...process.env,
|
||||||
GSTACK_HOME: process.env.GSTACK_HOME || home,
|
GSTACK_HOME: process.env.GSTACK_HOME || home,
|
||||||
GSTACK_NODE: process.env.GSTACK_NODE || process.execPath,
|
GSTACK_NODE: process.env.GSTACK_NODE || process.execPath,
|
||||||
@@ -1587,8 +1712,20 @@ const child = spawn(command, commandArgs, {
|
|||||||
BUN_CMD: managedBun,
|
BUN_CMD: managedBun,
|
||||||
PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""),
|
PATH: path.dirname(managedBun) + path.delimiter + (process.env.PATH || ""),
|
||||||
} : {}),
|
} : {}),
|
||||||
...(browserStat?.isDirectory() ? { PLAYWRIGHT_BROWSERS_PATH: managedBrowsers } : {}),
|
};
|
||||||
},
|
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: childEnv,
|
||||||
});
|
});
|
||||||
child.once("error", error => { console.error(error.message); process.exitCode = 1; });
|
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; });
|
child.once("exit", (code, signal) => { if (signal) process.kill(process.pid, signal); else process.exitCode = code ?? 1; });
|
||||||
@@ -1877,6 +2014,7 @@ async function captureInstallSurface(paths, launcherSurface) {
|
|||||||
const oldManifest = await readJson(manifestPath, null);
|
const oldManifest = await readJson(manifestPath, null);
|
||||||
const oldLaunchers = validateInstallManifestForUninstall(oldManifest);
|
const oldLaunchers = validateInstallManifestForUninstall(oldManifest);
|
||||||
const relativePaths = new Set([
|
const relativePaths = new Set([
|
||||||
|
"config.json",
|
||||||
"runtime-install.json",
|
"runtime-install.json",
|
||||||
...oldLaunchers,
|
...oldLaunchers,
|
||||||
...launcherRelativePaths(launcherSurface),
|
...launcherRelativePaths(launcherSurface),
|
||||||
@@ -2156,7 +2294,10 @@ function parseInstallerArgs(argv) {
|
|||||||
home: null,
|
home: null,
|
||||||
version: undefined,
|
version: undefined,
|
||||||
bunCommand: undefined,
|
bunCommand: undefined,
|
||||||
|
browserProvider: null,
|
||||||
|
browserPath: null,
|
||||||
capabilityIds: OPTIONAL_RUNTIME_CAPABILITIES,
|
capabilityIds: OPTIONAL_RUNTIME_CAPABILITIES,
|
||||||
|
capabilitiesProvided: false,
|
||||||
installMode: null,
|
installMode: null,
|
||||||
yes: false,
|
yes: false,
|
||||||
dryRun: false,
|
dryRun: false,
|
||||||
@@ -2177,20 +2318,34 @@ function parseInstallerArgs(argv) {
|
|||||||
else if (arg === "--replace-capabilities") result.replaceCapabilities = true;
|
else if (arg === "--replace-capabilities") result.replaceCapabilities = true;
|
||||||
else if (arg === "--install-now") result.installMode = "now";
|
else if (arg === "--install-now") result.installMode = "now";
|
||||||
else if (arg === "--install-later") result.installMode = "later";
|
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];
|
const value = argv[index + 1];
|
||||||
if (!value || value.startsWith("--")) throw new TypeError(`Missing value for ${arg}`);
|
if (!value || value.startsWith("--")) throw new TypeError(`Missing value for ${arg}`);
|
||||||
index += 1;
|
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 {
|
else {
|
||||||
const key = { "--source": "sourceDir", "--home": "home", "--version": "version", "--bun": "bunCommand" }[arg];
|
const key = { "--source": "sourceDir", "--home": "home", "--version": "version", "--bun": "bunCommand" }[arg];
|
||||||
result[key] = value;
|
result[key] = value;
|
||||||
}
|
}
|
||||||
} else {
|
} 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.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.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");
|
if (result.dryRun && (result.installMode != null || result.yes)) throw new TypeError("--dry-run cannot be combined with install/consent flags");
|
||||||
return result;
|
return result;
|
||||||
@@ -2198,12 +2353,13 @@ function parseInstallerArgs(argv) {
|
|||||||
|
|
||||||
function installerUsage() {
|
function installerUsage() {
|
||||||
return `Usage: ./setup [--capabilities <list>] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]\n` +
|
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` +
|
` [--home <path>] [--version <version>] [--json] [--quiet]\n\n` +
|
||||||
`Optional capabilities: ${OPTIONAL_RUNTIME_CAPABILITIES.join(", ")}\n` +
|
`Optional capabilities: ${OPTIONAL_RUNTIME_CAPABILITIES.join(", ")}\n` +
|
||||||
"Without --install-now, non-interactive use previews and installs nothing.\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" +
|
"--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" +
|
"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) {
|
function parseCapabilityList(value) {
|
||||||
@@ -2222,9 +2378,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) {
|
function printInstallPreview(stdout, preview) {
|
||||||
stdout.write("GStack optional runtime preview\n");
|
stdout.write("GStack optional runtime preview\n");
|
||||||
stdout.write(`Capabilities: ${preview.capabilities.length ? preview.capabilities.join(", ") : "core only"}\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`);
|
stdout.write(`Projected local payload before unknown downloads: ${preview.humanSize} (${preview.files} files, ${preview.components} components)\n`);
|
||||||
for (const item of preview.materializations) {
|
for (const item of preview.materializations) {
|
||||||
if (item.kind === "managed-bun-capture") {
|
if (item.kind === "managed-bun-capture") {
|
||||||
|
|||||||
+108
-15
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -494,15 +494,17 @@ function runtimeContract(): string {
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read \`references/BROWSER-PROVIDERS.md\` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read \`references/BROWSER-PROVIDERS.md\` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: \`node references/support/runtime-bootstrap.mjs preview --capability <name>\` (repeat \`--capability\` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run \`node references/support/runtime-bootstrap.mjs options --capability <name>\`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal \`browser-visible\` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: \`node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed\` or \`node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>\` (repeat \`--capability\` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly \`browser\`, \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. \`all\` means those five and intentionally excludes visible Chromium. The internal \`browser-visible\` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly \`browser\`, \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. \`all\` means those five and intentionally excludes visible Chromium. The internal \`browser-visible\` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run \`node references/support/runtime-bootstrap.mjs install --capability <name> --yes\`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are \`core\`, \`browser-code\` (browse code and dependencies), \`browser-headless\` (Playwright headless shell and FFmpeg), \`browser-visible\` (full Chromium), \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. Logical \`browser\` expands to \`browser-code + browser-headless\`; internal \`browser-visible\` expands to \`browser-code + browser-visible\` and does not require headless. Component dependencies are \`browser-code → core\`, \`browser-headless → browser-code\`, and \`browser-visible → browser-code\`. \`diagram\` depends on logical \`browser\`; \`pdf\` depends on \`diagram\`; \`ios\` is Darwin-only. Therefore a first-time headed flow previews \`core + browser-code + browser-visible\`, while an existing verified headless runtime downloads only missing \`browser-visible\`. The manifest schema is v2 with global \`capabilityComponents\` and \`componentDependencies\`, plus \`targets[target].components[id]\` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching \`install\` command with the same capabilities and browser flags plus \`--yes\`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in \`$GSTACK_HOME/config.json\`. \`gstack config browser clear\` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are \`core\`, \`browser-code\` (adapter code and dependencies), \`browser-headless\` (managed Playwright headless shell and FFmpeg), \`browser-visible\` (managed full Chromium), \`design\`, \`diagram\`, \`pdf\`, and \`ios\`. With managed Chromium, logical \`browser\` expands to \`browser-code + browser-headless\`; with an installed browser, the same logical capability downloads \`browser-code\` only and the stable launcher injects the validated executable path. Internal \`browser-visible\` expands to \`browser-code + browser-visible\` and is managed-only. \`diagram\` depends on logical \`browser\`; \`pdf\` depends on \`diagram\`; \`ios\` is Darwin-only. The manifest schema is v2 with global \`capabilityComponents\` and \`componentDependencies\`, plus \`targets[target].components[id]\` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run \`./setup\` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run \`./setup\` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -512,7 +514,7 @@ Some retained helpers are shell scripts. \`gstack doctor\` verifies Bash and, on
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is \`schemaVersion=1\`, \`runtimeVersion=2.0.0\`, and \`skillApi=2.0\`; the machine-readable copy is \`references/support/runtime-contract.json\`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is \`schemaVersion=1\`, \`runtimeVersion=2.0.0\`, and \`skillApi=2.0\`; the machine-readable copy is \`references/support/runtime-contract.json\`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes\`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is \`node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes\`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +564,7 @@ Do not put secrets in run IDs, effect keys, or command arguments. Existing appro
|
|||||||
|
|
||||||
function writeSharedContracts(): void {
|
function writeSharedContracts(): void {
|
||||||
const bootstrap = fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'));
|
const bootstrap = fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'));
|
||||||
|
const browserChoice = fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs'));
|
||||||
const browserSmoke = fs.readFileSync(path.join(ROOT, 'runtime', 'browser-provider-smoke.mjs'));
|
const browserSmoke = fs.readFileSync(path.join(ROOT, 'runtime', 'browser-provider-smoke.mjs'));
|
||||||
for (const tree of TREE_NAMES) {
|
for (const tree of TREE_NAMES) {
|
||||||
write(path.join(ROOT, 'skills', tree, 'references', 'SHARED-JUDGMENT.md'), sharedJudgmentContract());
|
write(path.join(ROOT, 'skills', tree, 'references', 'SHARED-JUDGMENT.md'), sharedJudgmentContract());
|
||||||
@@ -570,6 +573,7 @@ function writeSharedContracts(): void {
|
|||||||
write(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), runtimeContract());
|
write(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), runtimeContract());
|
||||||
write(path.join(ROOT, 'skills', tree, 'references', 'BROWSER-PROVIDERS.md'), `${GENERATED}\n${renderBrowserProviderContract()}`);
|
write(path.join(ROOT, 'skills', tree, 'references', 'BROWSER-PROVIDERS.md'), `${GENERATED}\n${renderBrowserProviderContract()}`);
|
||||||
write(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'), bootstrap);
|
write(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'), bootstrap);
|
||||||
|
write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs'), browserChoice);
|
||||||
write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-provider-smoke.mjs'), browserSmoke);
|
write(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-provider-smoke.mjs'), browserSmoke);
|
||||||
writeJson(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), RUNTIME_SKILL_CONTRACT);
|
writeJson(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), RUNTIME_SKILL_CONTRACT);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,7 +430,7 @@ export function renderPortedLegacyBody(source: string): string {
|
|||||||
'',
|
'',
|
||||||
'This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.',
|
'This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.',
|
||||||
'',
|
'',
|
||||||
'At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.',
|
'At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.',
|
||||||
'',
|
'',
|
||||||
body,
|
body,
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const ALLOWED_DISPOSITIONS = new Set(['VERBATIM_PORT', 'MECHANICAL_PORT', 'JUDGM
|
|||||||
// checks to the previously verified 4,681-check corpus. The first update only
|
// checks to the previously verified 4,681-check corpus. The first update only
|
||||||
// accounted for the 16 lazy-section checks; the remaining 136 cover runtime
|
// accounted for the 16 lazy-section checks; the remaining 136 cover runtime
|
||||||
// contracts, retired-invocation guards, and generated package closure.
|
// contracts, retired-invocation guards, and generated package closure.
|
||||||
export const EXPECTED_PARITY_CHECKS = 4833;
|
export const EXPECTED_PARITY_CHECKS = 4836;
|
||||||
|
|
||||||
function sha256(value: string | Uint8Array): string {
|
function sha256(value: string | Uint8Array): string {
|
||||||
return createHash('sha256').update(value).digest('hex');
|
return createHash('sha256').update(value).digest('hex');
|
||||||
@@ -373,8 +373,8 @@ export function runParity(): ParityResult {
|
|||||||
const packagedBootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
const packagedBootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
||||||
check(packagedBootstrap.equals(fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'))), `${tree} packaged runtime bootstrap drifted from its source`);
|
check(packagedBootstrap.equals(fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'))), `${tree} packaged runtime bootstrap drifted from its source`);
|
||||||
check(runtimeContract.includes('preview --capability <name>') && runtimeContract.includes('It never downloads components or mutates runtime state.'), `${tree} runtime contract lacks non-mutating exact-byte preview`);
|
check(runtimeContract.includes('preview --capability <name>') && runtimeContract.includes('It never downloads components or mutates runtime state.'), `${tree} runtime contract lacks non-mutating exact-byte preview`);
|
||||||
check(runtimeContract.includes('install --capability <name> --yes'), `${tree} runtime contract lacks explicit approved install invocation`);
|
check(runtimeContract.includes('matching `install` command with the same capabilities and browser flags plus `--yes`'), `${tree} runtime contract lacks explicit approved install invocation`);
|
||||||
check(runtimeContract.includes('Logical `browser` expands to `browser-code + browser-headless`') && runtimeContract.includes('`browser-visible` expands to `browser-code + browser-visible` and does not require headless') && runtimeContract.includes('`pdf` depends on `diagram`'), `${tree} runtime contract omits component dependency closure`);
|
check(runtimeContract.includes('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`') && runtimeContract.includes('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only') && runtimeContract.includes('`pdf` depends on `diagram`'), `${tree} runtime contract omits provider-aware component dependency closure`);
|
||||||
check(runtimeContract.includes('`all` means those five and intentionally excludes visible Chromium'), `${tree} runtime contract lets eager setup install visible Chromium`);
|
check(runtimeContract.includes('`all` means those five and intentionally excludes visible Chromium'), `${tree} runtime contract lets eager setup install visible Chromium`);
|
||||||
check(runtimeContract.includes('B=$GSTACK_BIN/browse') && runtimeContract.includes('P=$GSTACK_BIN/make-pdf'), `${tree} runtime contract omits stable launcher bindings`);
|
check(runtimeContract.includes('B=$GSTACK_BIN/browse') && runtimeContract.includes('P=$GSTACK_BIN/make-pdf'), `${tree} runtime contract omits stable launcher bindings`);
|
||||||
check(runtimeContract.includes('BUN_CMD=$GSTACK_BIN/bun'), `${tree} runtime contract omits the managed Bun binding`);
|
check(runtimeContract.includes('BUN_CMD=$GSTACK_BIN/bun'), `${tree} runtime contract omits the managed Bun binding`);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ rm -f \
|
|||||||
|
|
||||||
(
|
(
|
||||||
cd "$REPO"
|
cd "$REPO"
|
||||||
./setup --home "$HOME_DIR" --json
|
./setup --home "$HOME_DIR" --browser managed --install-now --yes --json
|
||||||
)
|
)
|
||||||
|
|
||||||
# The optional runtime setup installs only its production/build closure. The
|
# The optional runtime setup installs only its production/build closure. The
|
||||||
|
|||||||
@@ -25,17 +25,18 @@ for arg in "$@"; do
|
|||||||
-h|--help)
|
-h|--help)
|
||||||
printf '%s\n' \
|
printf '%s\n' \
|
||||||
'Usage: ./setup [--capabilities <list>] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]' \
|
'Usage: ./setup [--capabilities <list>] [--replace-capabilities] [--dry-run|--install-now [--yes]|--install-later]' \
|
||||||
|
' [--browser managed|installed [--browser-path <absolute-path>]]' \
|
||||||
' [--home <path>] [--version <version>] [--json] [--quiet]' \
|
' [--home <path>] [--version <version>] [--json] [--quiet]' \
|
||||||
'' \
|
'' \
|
||||||
'Optional capabilities: browser, design, pdf, diagram, ios (iOS is macOS-only).' \
|
'Optional capabilities: browser, design, pdf, diagram, ios (iOS is macOS-only).' \
|
||||||
'Without --install-now, non-interactive use previews and installs nothing.' \
|
'Without --install-now, non-interactive use previews and installs nothing.' \
|
||||||
'--dry-run and --install-later never modify runtime state or host setup.' \
|
'--dry-run and --install-later never modify runtime state or host setup.' \
|
||||||
'Installs only the optional host-neutral runtime and selected local capabilities.' \
|
'Installs only the optional host-neutral runtime and selected local capabilities.' \
|
||||||
'Skills are installed separately with: npx skills add time-attack/gstack'
|
'Skills are installed separately with: npx skills add time-attack/gstack/skills'
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
--local|--team|--no-team)
|
--local|--team|--no-team)
|
||||||
echo "gstack setup: $arg is deprecated; skill placement is delegated to: npx skills add time-attack/gstack" >&2
|
echo "gstack setup: $arg is deprecated; skill placement is delegated to: npx skills add time-attack/gstack/skills" >&2
|
||||||
;;
|
;;
|
||||||
*) ARGS+=("$arg") ;;
|
*) ARGS+=("$arg") ;;
|
||||||
esac
|
esac
|
||||||
@@ -45,7 +46,7 @@ NODE_COMMAND="${GSTACK_NODE:-node}"
|
|||||||
if ! command -v "$NODE_COMMAND" >/dev/null 2>&1; then
|
if ! command -v "$NODE_COMMAND" >/dev/null 2>&1; then
|
||||||
echo "gstack setup: Node 18+ is required by the managed runtime launchers." >&2
|
echo "gstack setup: Node 18+ is required by the managed runtime launchers." >&2
|
||||||
echo "Install Node from https://nodejs.org, or install judgment-only skills with:" >&2
|
echo "Install Node from https://nodejs.org, or install judgment-only skills with:" >&2
|
||||||
echo " npx skills add time-attack/gstack" >&2
|
echo " npx skills add time-attack/gstack/skills" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||||
<!-- GSTACK2_PROVENANCE source=open-gstack-browser/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ef91a527890a3ac3622cc7dc84bad1ff7b64443b baseline_render_sha256=f68b483619f37175687c64510c4de5c718ad3aa2d644134f6539d28df3a9ad7c ported_render_sha256=df626d71b8cea4a02d2fb7aef3169563dd132bf17a9d6d84f287894cad84d2cf disposition=BUG_FIX -->
|
<!-- GSTACK2_PROVENANCE source=open-gstack-browser/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=ef91a527890a3ac3622cc7dc84bad1ff7b64443b baseline_render_sha256=f68b483619f37175687c64510c4de5c718ad3aa2d644134f6539d28df3a9ad7c ported_render_sha256=e6e8271ecd89761627e6e67745750b22e64596d0e51e4a2350dccd8e2ce8ebd6 disposition=BUG_FIX -->
|
||||||
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module open-gstack-browser visibility=internal depth=standard mutation=configuration web=local-browser -->
|
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module open-gstack-browser visibility=internal depth=standard mutation=configuration web=local-browser -->
|
||||||
|
|
||||||
<!-- GSTACK2_LEGACY_BODY_START source=open-gstack-browser -->
|
<!-- GSTACK2_LEGACY_BODY_START source=open-gstack-browser -->
|
||||||
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
|
|||||||
|
|
||||||
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
||||||
|
|
||||||
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
|
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
|
||||||
|
|
||||||
# $qa --mode Report --module open-gstack-browser — Launch GStack Browser
|
# $qa --mode Report --module open-gstack-browser — Launch GStack Browser
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||||
<!-- GSTACK2_PROVENANCE source=pair-agent/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75ed42d590f99c46cd0883c37bb1f2f9f499211c baseline_render_sha256=6bb659c03b5df7c36f446fad30aaec4ab6d5e0d25fb8392702573e66923b02fb ported_render_sha256=8557ca390d0b6548f956d2c0e9316f1cf137689d4dc17d40a4525d19f22bc457 disposition=BUG_FIX -->
|
<!-- GSTACK2_PROVENANCE source=pair-agent/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=75ed42d590f99c46cd0883c37bb1f2f9f499211c baseline_render_sha256=6bb659c03b5df7c36f446fad30aaec4ab6d5e0d25fb8392702573e66923b02fb ported_render_sha256=256fd576911cc286ddd2510daec8f4c68501cc5534f46edc044c1908574ac64a disposition=BUG_FIX -->
|
||||||
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module pair-agent visibility=internal depth=standard mutation=configuration web=local-browser -->
|
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module pair-agent visibility=internal depth=standard mutation=configuration web=local-browser -->
|
||||||
|
|
||||||
<!-- GSTACK2_LEGACY_BODY_START source=pair-agent -->
|
<!-- GSTACK2_LEGACY_BODY_START source=pair-agent -->
|
||||||
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
|
|||||||
|
|
||||||
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
||||||
|
|
||||||
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
|
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
|
||||||
|
|
||||||
# $qa --mode Report --module pair-agent — Share Your Browser With Another AI Agent
|
# $qa --mode Report --module pair-agent — Share Your Browser With Another AI Agent
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
<!-- GENERATED by scripts/gstack2/generate-skill-tree.ts; do not edit. -->
|
||||||
<!-- GSTACK2_PROVENANCE source=setup-browser-cookies/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f812d9f56f27c32fb5f102083bbe418344c1a652 baseline_render_sha256=22b03503fa8ba63de98866d64ab0563291f4b41e1577c8022d09add3e0bdb59c ported_render_sha256=04c161a58c1a9010efe38095b383b0e1d445a2b678e5bf931a1281d45196940d disposition=BUG_FIX -->
|
<!-- GSTACK2_PROVENANCE source=setup-browser-cookies/SKILL.md.tmpl base=bb57306d98c97011b0919c6132705a15b1579781 blob=f812d9f56f27c32fb5f102083bbe418344c1a652 baseline_render_sha256=22b03503fa8ba63de98866d64ab0563291f4b41e1577c8022d09add3e0bdb59c ported_render_sha256=7d539b2113f8cc9bf0b8b2f6e1da3dde7028176a6f71de8f47de0a98c45663e8 disposition=BUG_FIX -->
|
||||||
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module setup-browser-cookies visibility=internal depth=standard mutation=configuration web=local-browser -->
|
<!-- GSTACK2_ROUTING replacement=$qa --mode Report --module setup-browser-cookies visibility=internal depth=standard mutation=configuration web=local-browser -->
|
||||||
|
|
||||||
<!-- GSTACK2_LEGACY_BODY_START source=setup-browser-cookies -->
|
<!-- GSTACK2_LEGACY_BODY_START source=setup-browser-cookies -->
|
||||||
@@ -21,7 +21,7 @@ P="$GSTACK_BIN/make-pdf"
|
|||||||
|
|
||||||
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
This workflow may require internal `browser-visible` because it reaches a headed browser, extension, interactive cookie picker, or browser handoff. Do not offer visible Chromium during ordinary headless QA.
|
||||||
|
|
||||||
At the first actual visible-browser step, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after that approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --yes`, recheck readiness, and resume the interrupted step.
|
At the first actual visible-browser step, run the local-only `node references/support/runtime-bootstrap.mjs options --capability browser-visible`, explain that this extension-bearing flow requires managed Chromium because installed Chrome-family builds can block automation extension loading, and ask whether the user wants to check exact official sizes. Disclose that an uncached preview makes one public GitHub signed-manifest request and sends no repository/private data, then STOP. Only after approval run `node references/support/runtime-bootstrap.mjs preview --capability browser-visible --browser managed`. It expands to `core + browser-code + browser-visible` for a first install, but an existing verified headless runtime downloads only missing `browser-visible`; it never requires `browser-headless`. Show the exact missing components and summed incremental compressed bytes, then STOP again for separate install approval. Only after install approval run `node references/support/runtime-bootstrap.mjs install --capability browser-visible --browser managed --yes`, recheck readiness, and resume the interrupted step.
|
||||||
|
|
||||||
# Setup Browser Cookies
|
# Setup Browser Cookies
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -3,15 +3,17 @@
|
|||||||
|
|
||||||
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
The six Agent Skills are useful without a GStack runtime. Never install, download, build, select, update, or remove runtime capabilities merely because a skill was invoked.
|
||||||
|
|
||||||
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns provider detection, consented host setup, the common readiness journey, and the explicit GStack fallback. Skill installation never proves browser readiness.
|
Before interactive browser work, read `references/BROWSER-PROVIDERS.md` in full. It owns host-provider detection, consented host setup, and the common readiness journey. The GStack fallback uses the local Playwright adapter with one explicit engine choice; skill installation never proves browser readiness.
|
||||||
|
|
||||||
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
When an active specialist first reaches a capability it cannot use, name the exact capability and why it is needed. Offer to continue without it when the judgment-only or host-native path remains valid. Before any network preview, ask whether the user wants to check official setup options and exact sizes. Disclose that an uncached preview makes one public GitHub request for signed manifest metadata and sends no repository content, private URL, file, cookie, token, or credential; then STOP. A cached already-verified manifest may preview offline, but never silently fetch.
|
||||||
|
|
||||||
Only after the user approves that metadata check, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name>` (repeat `--capability` for additional requested capabilities). It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
For a browser-backed capability, first run `node references/support/runtime-bootstrap.mjs options --capability <name>`. This local-only command performs no network request or mutation. Show the detected installed Chromium executables plus managed Chromium, explain that either choice uses an isolated automation profile, and STOP for a choice. Never infer or silently record one. Installed Chromium avoids browser-binary downloads; managed Chromium is isolated and reproducible. Internal `browser-visible` requires managed Chromium because installed Chrome-family builds can block automation extension loading.
|
||||||
|
|
||||||
|
Only after the user approves the metadata check and, when applicable, chooses a browser engine, run the non-mutating preview from this skill root: `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser managed` or `node references/support/runtime-bootstrap.mjs preview --capability <name> --browser installed --browser-path <absolute-path>` (repeat `--capability` for additional requested capabilities). Omit browser flags for capabilities that do not depend on a browser. It dependency-expands, reports already verified local components, exact missing components, and their summed compressed bytes. It never downloads components or mutates runtime state. Preview consent is not install consent.
|
||||||
|
|
||||||
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
User-facing setup capabilities are exactly `browser`, `design`, `diagram`, `pdf`, and `ios`. `all` means those five and intentionally excludes visible Chromium. The internal `browser-visible` capability is additive and is offered only when a workflow actually reaches a headed browser, extension, or browser-handoff step. Never offer it during ordinary headless QA.
|
||||||
|
|
||||||
After showing the complete preview, STOP for explicit approval. Only after approval run `node references/support/runtime-bootstrap.mjs install --capability <name> --yes`; install must reprint the identical dependency-closed plan before downloading. Signed internal components are `core`, `browser-code` (browse code and dependencies), `browser-headless` (Playwright headless shell and FFmpeg), `browser-visible` (full Chromium), `design`, `diagram`, `pdf`, and `ios`. Logical `browser` expands to `browser-code + browser-headless`; internal `browser-visible` expands to `browser-code + browser-visible` and does not require headless. Component dependencies are `browser-code → core`, `browser-headless → browser-code`, and `browser-visible → browser-code`. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. Therefore a first-time headed flow previews `core + browser-code + browser-visible`, while an existing verified headless runtime downloads only missing `browser-visible`. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
After showing the complete preview, STOP for explicit approval. Only after approval run the matching `install` command with the same capabilities and browser flags plus `--yes`; install must reprint the identical dependency-closed plan before downloading. The approved browser choice is then persisted atomically in `$GSTACK_HOME/config.json`. `gstack config browser clear` disables browser-backed launchers; the managed/installed config commands can reselect only a provider compatible with the active slot. Switching providers requires a fresh preview and install so configuration cannot point at a runtime missing that engine. Signed internal components are `core`, `browser-code` (adapter code and dependencies), `browser-headless` (managed Playwright headless shell and FFmpeg), `browser-visible` (managed full Chromium), `design`, `diagram`, `pdf`, and `ios`. With managed Chromium, logical `browser` expands to `browser-code + browser-headless`; with an installed browser, the same logical capability downloads `browser-code` only and the stable launcher injects the validated executable path. Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only. `diagram` depends on logical `browser`; `pdf` depends on `diagram`; `ios` is Darwin-only. The manifest schema is v2 with global `capabilityComponents` and `componentDependencies`, plus `targets[target].components[id]` carrying signed exact-byte artifacts.
|
||||||
|
|
||||||
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
The dependency-free Node 18+ bootstrap accepts only official GStack GitHub Release hosts, validates manifest/runtime/skill API/platform/component metadata and SHA-256, verifies a declared Cosign bundle when Cosign is available, rejects unsafe archive paths/links, and invokes the atomic managed installer. Never run `./setup` inside a standard-installed skill directory, enroll a host, or treat skill installation as runtime consent.
|
||||||
|
|
||||||
@@ -21,4 +23,4 @@ Some retained helpers are shell scripts. `gstack doctor` verifies Bash and, on W
|
|||||||
|
|
||||||
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
The package/runtime compatibility tuple is `schemaVersion=1`, `runtimeVersion=2.0.0`, and `skillApi=2.0`; the machine-readable copy is `references/support/runtime-contract.json`. An incompatible active runtime is unavailable, not permission to upgrade it.
|
||||||
|
|
||||||
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
The developer-only fallback is `node references/support/runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name> [matching browser flags] --yes`; show its trust warning and use it only when the user explicitly selects a checkout they reviewed. If the packaged bootstrap is unavailable, stop capability setup instead of guessing a checkout-relative command. Deferring installation records no consent and must not block pure judgment.
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { constants as fsConstants } from "node:fs";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const BROWSER_PROVIDERS = Object.freeze(["managed", "installed"]);
|
||||||
|
|
||||||
|
const BROWSER_CAPABILITIES = new Set(["browser", "browser-visible", "diagram", "pdf"]);
|
||||||
|
|
||||||
|
const NAMED_CANDIDATES = Object.freeze({
|
||||||
|
darwin: Object.freeze([
|
||||||
|
["Google Chrome", "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"],
|
||||||
|
["Google Chrome Beta", "/Applications/Google Chrome Beta.app/Contents/MacOS/Google Chrome Beta"],
|
||||||
|
["Chromium", "/Applications/Chromium.app/Contents/MacOS/Chromium"],
|
||||||
|
["Microsoft Edge", "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"],
|
||||||
|
["Brave", "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser"],
|
||||||
|
]),
|
||||||
|
win32: Object.freeze([
|
||||||
|
["Google Chrome", ["LOCALAPPDATA", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Google Chrome", ["PROGRAMFILES(X86)", "Google/Chrome/Application/chrome.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES(X86)", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Microsoft Edge", ["PROGRAMFILES", "Microsoft/Edge/Application/msedge.exe"]],
|
||||||
|
["Brave", ["LOCALAPPDATA", "BraveSoftware/Brave-Browser/Application/brave.exe"]],
|
||||||
|
]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PATH_CANDIDATES = Object.freeze([
|
||||||
|
["Google Chrome", "google-chrome"],
|
||||||
|
["Google Chrome", "google-chrome-stable"],
|
||||||
|
["Chromium", "chromium"],
|
||||||
|
["Chromium", "chromium-browser"],
|
||||||
|
["Microsoft Edge", "microsoft-edge"],
|
||||||
|
["Microsoft Edge", "microsoft-edge-stable"],
|
||||||
|
["Brave", "brave-browser"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export function browserChoiceRequired(capabilities) {
|
||||||
|
return capabilities.some((capability) => BROWSER_CAPABILITIES.has(capability));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBrowserChoiceSupportsCapabilities(choice, capabilities) {
|
||||||
|
if (choice?.provider === "installed" && capabilities.includes("browser-visible")) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Visible GStack Browser requires managed Chromium because installed Chrome-family builds can block automation extension loading; choose `managed` for this capability",
|
||||||
|
"BROWSER_PROVIDER_UNSUPPORTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return choice;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyBrowserProviderToComponents(components, choice) {
|
||||||
|
if (choice?.provider !== "installed") return Object.freeze([...components].sort());
|
||||||
|
return Object.freeze(components
|
||||||
|
.filter((component) => component !== "browser-headless" && component !== "browser-visible")
|
||||||
|
.sort());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function detectInstalledBrowsers(options = {}) {
|
||||||
|
if (Array.isArray(options.candidates)) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of options.candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
|
const env = options.env ?? process.env;
|
||||||
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
|
const candidates = [];
|
||||||
|
if (platform === "darwin") {
|
||||||
|
for (const [name, executablePath] of NAMED_CANDIDATES.darwin) {
|
||||||
|
candidates.push({ name, executablePath });
|
||||||
|
candidates.push({
|
||||||
|
name,
|
||||||
|
executablePath: path.join(homeDir, executablePath.replace(/^\/Applications\//, "Applications/")),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (platform === "win32") {
|
||||||
|
for (const [name, [variable, suffix]] of NAMED_CANDIDATES.win32) {
|
||||||
|
const base = env[variable];
|
||||||
|
if (base) candidates.push({ name, executablePath: path.join(base, ...suffix.split("/")) });
|
||||||
|
}
|
||||||
|
} else if (platform === "linux") {
|
||||||
|
for (const [name, command] of PATH_CANDIDATES) {
|
||||||
|
for (const directory of String(env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
||||||
|
candidates.push({ name, executablePath: path.join(directory, command) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = [];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const browser = await inspectCandidate(candidate.name, candidate.executablePath, options);
|
||||||
|
if (browser) resolved.push(browser);
|
||||||
|
}
|
||||||
|
return deduplicate(resolved);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveBrowserChoice(choice, options = {}) {
|
||||||
|
if (!choice || !BROWSER_PROVIDERS.includes(choice.provider)) {
|
||||||
|
throw browserChoiceError(
|
||||||
|
"Choose a browser provider: `managed` downloads GStack's isolated Chromium, while `installed` uses an explicitly selected local Chromium executable",
|
||||||
|
"BROWSER_CHOICE_REQUIRED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (choice.provider === "managed") {
|
||||||
|
if (choice.executablePath != null) {
|
||||||
|
throw browserChoiceError("Managed Chromium cannot include an installed-browser path", "BROWSER_CHOICE_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "managed", executablePath: null });
|
||||||
|
}
|
||||||
|
if (typeof choice.executablePath !== "string" || !path.isAbsolute(choice.executablePath)) {
|
||||||
|
throw browserChoiceError("Installed browser setup requires an absolute executable path", "BROWSER_PATH_REQUIRED");
|
||||||
|
}
|
||||||
|
const inspected = await inspectCandidate(choice.name ?? "Installed Chromium", choice.executablePath, options);
|
||||||
|
if (!inspected) {
|
||||||
|
throw browserChoiceError(`Installed browser executable is unavailable or not executable: ${choice.executablePath}`, "BROWSER_PATH_INVALID");
|
||||||
|
}
|
||||||
|
return Object.freeze({ provider: "installed", executablePath: inspected.executablePath });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inspectCandidate(name, executablePath, options) {
|
||||||
|
if (typeof executablePath !== "string" || !path.isAbsolute(executablePath)) return null;
|
||||||
|
const fs_ = options.fs ?? fs;
|
||||||
|
try {
|
||||||
|
const invocationPath = path.resolve(executablePath);
|
||||||
|
const physical = await fs_.realpath(invocationPath);
|
||||||
|
const stat = await fs_.lstat(physical);
|
||||||
|
if (!stat.isFile() || stat.isSymbolicLink()) return null;
|
||||||
|
if ((options.platform ?? process.platform) !== "win32") await fs_.access(physical, fsConstants.X_OK);
|
||||||
|
return Object.freeze({ name, executablePath: invocationPath, physicalPath: physical });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function deduplicate(candidates) {
|
||||||
|
const seen = new Set();
|
||||||
|
return Object.freeze(candidates.flatMap((candidate) => {
|
||||||
|
const identity = candidate.physicalPath ?? candidate.executablePath;
|
||||||
|
if (seen.has(identity)) return [];
|
||||||
|
seen.add(identity);
|
||||||
|
return [Object.freeze({ name: candidate.name, executablePath: candidate.executablePath })];
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserChoiceError(message, code) {
|
||||||
|
const error = new Error(message);
|
||||||
|
error.code = code;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
@@ -10,13 +10,20 @@ import { createHash } from "node:crypto";
|
|||||||
import { constants as fsConstants, createReadStream } from "node:fs";
|
import { constants as fsConstants, createReadStream } from "node:fs";
|
||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
applyBrowserProviderToComponents,
|
||||||
|
assertBrowserChoiceSupportsCapabilities,
|
||||||
|
browserChoiceRequired,
|
||||||
|
detectInstalledBrowsers,
|
||||||
|
resolveBrowserChoice,
|
||||||
|
} from "./browser-choice.mjs";
|
||||||
|
|
||||||
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
export const BOOTSTRAP_SCHEMA_VERSION = 2;
|
||||||
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
export const BOOTSTRAP_RUNTIME_VERSION = "2.0.0";
|
||||||
// Keep the runtime compatibility version separate from the immutable release
|
// Keep the runtime compatibility version separate from the immutable release
|
||||||
// channel. Release candidates carry the 2.0.0 runtime contract while letting
|
// 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.
|
// fresh-machine production journeys run before the stable v2.0.0 tag exists.
|
||||||
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.5";
|
export const BOOTSTRAP_RELEASE_TAG = "v2.0.0-rc.6";
|
||||||
export const OFFICIAL_MANIFEST_URL =
|
export const OFFICIAL_MANIFEST_URL =
|
||||||
`https://github.com/time-attack/gstack/releases/download/${BOOTSTRAP_RELEASE_TAG}/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 CAPABILITIES = new Set(["browser", "browser-visible", "design", "pdf", "diagram", "ios"]);
|
||||||
@@ -67,14 +74,54 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
io.stdout.write(usage());
|
io.stdout.write(usage());
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (!["preview", "install"].includes(parsed.action)) {
|
if (!["options", "preview", "install"].includes(parsed.action)) {
|
||||||
throw bootstrapError("Expected `preview` or `install`", "BOOTSTRAP_USAGE");
|
throw bootstrapError("Expected `options`, `preview`, or `install`", "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
|
|
||||||
const platform = options.platform ?? process.platform;
|
const platform = options.platform ?? process.platform;
|
||||||
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
if (parsed.capabilities.includes("ios") && platform !== "darwin") {
|
||||||
throw bootstrapError("The physical-iOS capability is available only on macOS", "BOOTSTRAP_PLATFORM_UNSUPPORTED");
|
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) {
|
if (parsed.source) {
|
||||||
if (parsed.action === "preview") {
|
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");
|
io.stdout.write("Reviewed-source fallback has no signed compressed-byte manifest; the local installer can provide an on-disk preview only.\n");
|
||||||
@@ -82,7 +129,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
if (!parsed.yes) throw bootstrapError("Installation requires explicit --yes after review", "BOOTSTRAP_CONSENT_REQUIRED");
|
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");
|
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, browserChoice });
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetch_ = options.fetch ?? globalThis.fetch;
|
const fetch_ = options.fetch ?? globalThis.fetch;
|
||||||
@@ -100,7 +147,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
validateManifest(manifest, target);
|
validateManifest(manifest, target);
|
||||||
const home = path.resolve(parsed.home ?? process.env.GSTACK_HOME ?? path.join(os.homedir(), ".gstack"));
|
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 reusable = await inspectReusableRuntime(home, manifest.version).catch(() => null);
|
||||||
const plan = buildComponentPlan(manifest, target, parsed.capabilities, reusable);
|
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`);
|
if (parsed.json) io.stdout.write(`${JSON.stringify({ ok: true, action: parsed.action, ...plan }, null, 2)}\n`);
|
||||||
else printComponentPlan(io.stdout, plan);
|
else printComponentPlan(io.stdout, plan);
|
||||||
if (parsed.action === "preview") return 0;
|
if (parsed.action === "preview") return 0;
|
||||||
@@ -123,7 +170,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
await assertNoLinks(componentRoot);
|
await assertNoLinks(componentRoot);
|
||||||
await mergeComponentRoot(componentRoot, root, claimedFiles, item.component);
|
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 {
|
} finally {
|
||||||
await fs.rm(temporary, { recursive: true, force: true });
|
await fs.rm(temporary, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -134,23 +181,47 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseArgs(argv) {
|
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) {
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
const arg = argv[index];
|
const arg = argv[index];
|
||||||
if (["-h", "--help"].includes(arg)) result.help = true;
|
if (["-h", "--help"].includes(arg)) result.help = true;
|
||||||
else if (arg === "--yes") result.yes = true;
|
else if (arg === "--yes") result.yes = true;
|
||||||
else if (arg === "--json") result.json = true;
|
else if (arg === "--json") result.json = true;
|
||||||
else if (!result.action && !arg.startsWith("-")) result.action = arg;
|
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];
|
const value = argv[++index];
|
||||||
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
if (!value || value.startsWith("--")) throw bootstrapError(`${arg} requires a value`, "BOOTSTRAP_USAGE");
|
||||||
if (arg === "--capability") result.capabilities.push(value);
|
if (arg === "--capability") result.capabilities.push(value);
|
||||||
else if (arg === "--source") result.source = 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");
|
} else throw bootstrapError(`Unknown option: ${arg}`, "BOOTSTRAP_USAGE");
|
||||||
}
|
}
|
||||||
if (result.help) return result;
|
if (result.help) return result;
|
||||||
if (result.action === "preview" && result.yes) throw bootstrapError("preview cannot be combined with --yes", "BOOTSTRAP_USAGE");
|
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");
|
if (!result.capabilities.length) throw bootstrapError("At least one --capability is required", "BOOTSTRAP_USAGE");
|
||||||
result.capabilities = [...new Set(result.capabilities)].sort();
|
result.capabilities = [...new Set(result.capabilities)].sort();
|
||||||
for (const capability of result.capabilities) {
|
for (const capability of result.capabilities) {
|
||||||
@@ -212,7 +283,7 @@ function sameGraph(actual, expected) {
|
|||||||
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
return JSON.stringify(normalize(actual)) === JSON.stringify(normalize(expected));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectedComponents(capabilities) {
|
function selectedComponents(capabilities, browserChoice) {
|
||||||
const selected = new Set(["core"]);
|
const selected = new Set(["core"]);
|
||||||
for (const capability of capabilities) {
|
for (const capability of capabilities) {
|
||||||
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
for (const component of CAPABILITY_COMPONENTS[capability] ?? []) selected.add(component);
|
||||||
@@ -226,11 +297,11 @@ function selectedComponents(capabilities) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return [...selected].sort();
|
return applyBrowserProviderToComponents([...selected], browserChoice);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildComponentPlan(manifest, target, capabilities, reusable) {
|
function buildComponentPlan(manifest, target, capabilities, reusable, browserChoice) {
|
||||||
const components = selectedComponents(capabilities);
|
const components = selectedComponents(capabilities, browserChoice);
|
||||||
const retained = new Set(reusable?.components ?? []);
|
const retained = new Set(reusable?.components ?? []);
|
||||||
const downloads = components
|
const downloads = components
|
||||||
.filter((component) => !retained.has(component))
|
.filter((component) => !retained.has(component))
|
||||||
@@ -240,6 +311,7 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
target,
|
target,
|
||||||
version: manifest.version,
|
version: manifest.version,
|
||||||
capabilities,
|
capabilities,
|
||||||
|
browser: browserChoice,
|
||||||
components,
|
components,
|
||||||
reusedComponents: components.filter((component) => retained.has(component)),
|
reusedComponents: components.filter((component) => retained.has(component)),
|
||||||
downloads,
|
downloads,
|
||||||
@@ -250,6 +322,11 @@ function buildComponentPlan(manifest, target, capabilities, reusable) {
|
|||||||
function printComponentPlan(stdout, plan) {
|
function printComponentPlan(stdout, plan) {
|
||||||
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
stdout.write(`GStack optional runtime ${plan.version} for ${plan.target}\n`);
|
||||||
stdout.write(`Capabilities: ${plan.capabilities.join(", ")}\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`);
|
stdout.write(`Components: ${plan.components.join(", ")}\n`);
|
||||||
if (plan.reusedComponents.length) stdout.write(`Reusing: ${plan.reusedComponents.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`);
|
stdout.write(`Download: ${plan.downloadBytes} bytes across ${plan.downloads.length} component(s)\n`);
|
||||||
@@ -412,6 +489,10 @@ async function installFromSource(source, parsed, options) {
|
|||||||
const stat = await fs.lstat(installer).catch(() => null);
|
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");
|
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(",")];
|
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 (parsed.home) args.push("--home", path.resolve(parsed.home));
|
||||||
if (options.version) args.push("--version", options.version);
|
if (options.version) args.push("--version", options.version);
|
||||||
if (options.prepared) args.push("--prepared");
|
if (options.prepared) args.push("--prepared");
|
||||||
@@ -535,12 +616,24 @@ function formatBytes(bytes) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return "Usage: node runtime-bootstrap.mjs install --capability <name> [--capability <name>...]\n" +
|
return "Usage: node runtime-bootstrap.mjs options --capability <browser-backed-name>\n" +
|
||||||
" node runtime-bootstrap.mjs install --source <reviewed-checkout> --capability <name>\n\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" +
|
"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";
|
"--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() {
|
async function isDirectExecution() {
|
||||||
if (!process.argv[1]) return false;
|
if (!process.argv[1]) return false;
|
||||||
const [modulePath, invokedPath] = await Promise.all([
|
const [modulePath, invokedPath] = await Promise.all([
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from "node:path";
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { main as runtimeMain } from "../runtime/cli.js";
|
import { main as runtimeMain } from "../runtime/cli.js";
|
||||||
|
import { configSetBrowserChoice } from "../runtime/config.js";
|
||||||
import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
defaultBunBuilder,
|
defaultBunBuilder,
|
||||||
installManagedRuntime,
|
installManagedRuntime,
|
||||||
normalizeManagedBrowserTree,
|
normalizeManagedBrowserTree,
|
||||||
|
runInstallerCli,
|
||||||
runtimeReleaseComponentForPath,
|
runtimeReleaseComponentForPath,
|
||||||
runtimeNativePackagePaths,
|
runtimeNativePackagePaths,
|
||||||
uninstallManagedRuntime,
|
uninstallManagedRuntime,
|
||||||
@@ -28,6 +30,12 @@ const ENTRIES = [
|
|||||||
{ path: "cap/tool", build: "fixture", executable: true },
|
{ path: "cap/tool", build: "fixture", executable: true },
|
||||||
];
|
];
|
||||||
const CAPABILITIES = { "fixture-tool": "cap/tool" };
|
const CAPABILITIES = { "fixture-tool": "cap/tool" };
|
||||||
|
const BROWSER_ENTRIES = [
|
||||||
|
{ path: "runtime" },
|
||||||
|
{ path: "bin/gstack", executable: true },
|
||||||
|
{ path: "browse/dist/browse", build: "fixture", executable: true },
|
||||||
|
];
|
||||||
|
const BROWSER_CAPABILITIES = { browse: "browse/dist/browse" };
|
||||||
const REPO_ROOT = path.resolve(import.meta.dir, "..");
|
const REPO_ROOT = path.resolve(import.meta.dir, "..");
|
||||||
const FULL_RUNTIME_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 30_000;
|
const FULL_RUNTIME_TEST_TIMEOUT_MS = process.platform === "win32" ? 120_000 : 30_000;
|
||||||
|
|
||||||
@@ -113,6 +121,185 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
}, { createDefaultSource: false });
|
}, { createDefaultSource: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("stable launchers inject the persisted installed-browser choice and honor clearing it", async () => {
|
||||||
|
await withFixture(async ({ source, home }) => {
|
||||||
|
await installFixture(source, home, "browser-config-launcher", {
|
||||||
|
entries: BROWSER_ENTRIES,
|
||||||
|
capabilities: BROWSER_CAPABILITIES,
|
||||||
|
});
|
||||||
|
const executable = await fs.realpath(process.execPath);
|
||||||
|
await configSetBrowserChoice(home, { provider: "installed", executablePath: executable });
|
||||||
|
const ambient = { ...process.env, GSTACK_CHROMIUM_PATH: path.join(home, "ambient-browser-must-not-win") };
|
||||||
|
const selected = await runInstalledLauncher(home, "browse", [], { capture: true, env: ambient });
|
||||||
|
expect(selected.stdout).toBe(executable);
|
||||||
|
|
||||||
|
await configSetBrowserChoice(home, { provider: "managed", executablePath: null });
|
||||||
|
await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient }))
|
||||||
|
.rejects.toThrow("Command failed");
|
||||||
|
|
||||||
|
await configSetBrowserChoice(home, null);
|
||||||
|
await expect(runInstalledLauncher(home, "browse", [], { capture: true, env: ambient }))
|
||||||
|
.rejects.toThrow("Command failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installed-browser launchers refuse visible commands before starting the browser binary", async () => {
|
||||||
|
await withFixture(async ({ source, home }) => {
|
||||||
|
const executable = await fs.realpath(process.execPath);
|
||||||
|
await installFixture(source, home, "installed-visible-refusal", {
|
||||||
|
entries: BROWSER_ENTRIES,
|
||||||
|
capabilities: BROWSER_CAPABILITIES,
|
||||||
|
browserChoice: { provider: "installed", executablePath: executable },
|
||||||
|
});
|
||||||
|
await configSetBrowserChoice(home, { provider: "installed", executablePath: executable });
|
||||||
|
await expect(runInstalledLauncher(home, "browse", ["connect"], { capture: true }))
|
||||||
|
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") });
|
||||||
|
await expect(runInstalledLauncher(home, "browse", ["pair-agent"], { capture: true }))
|
||||||
|
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed Chromium") });
|
||||||
|
await expect(runInstalledLauncher(home, "browse", ["handoff"], { capture: true }))
|
||||||
|
.rejects.toMatchObject({ stderr: expect.stringContaining("Visible GStack Browser requires managed 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");
|
||||||
|
await fs.mkdir(path.dirname(design), { recursive: true });
|
||||||
|
await fs.writeFile(design, "#!/bin/sh\nprintf 'design ready\\n'\n", { mode: 0o755 });
|
||||||
|
await installManagedRuntime({
|
||||||
|
sourceDir: source,
|
||||||
|
home,
|
||||||
|
version: "design-without-browser",
|
||||||
|
entries: [...ENTRIES, { path: "design/dist/design", build: "fixture", executable: true }],
|
||||||
|
capabilities: { ...CAPABILITIES, "gstack-design": "design/dist/design" },
|
||||||
|
});
|
||||||
|
await configSetBrowserChoice(home, null);
|
||||||
|
expect((await runInstalledLauncher(home, "gstack-design", [], { capture: true })).stdout)
|
||||||
|
.toContain("design ready");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("browser config refuses a provider that does not match the active runtime slot", async () => {
|
||||||
|
await withFixture(async ({ source, home }) => {
|
||||||
|
const result = await installFixture(source, home, "installed-slot");
|
||||||
|
const manifestPath = path.join(result.path, ".gstack-bundle.json");
|
||||||
|
const manifest = await readJson(manifestPath);
|
||||||
|
await fs.writeFile(manifestPath, JSON.stringify({
|
||||||
|
...manifest,
|
||||||
|
selectedCapabilities: ["browser"],
|
||||||
|
runtimeComponents: ["browser-code", "core"],
|
||||||
|
browserChoice: { provider: "installed", executablePath: process.execPath },
|
||||||
|
}));
|
||||||
|
const output = captureStream();
|
||||||
|
expect(await runtimeMain(["config", "browser", "managed"], {
|
||||||
|
cwd: source,
|
||||||
|
env: { ...process.env, GSTACK_HOME: home },
|
||||||
|
stdout: output.stream,
|
||||||
|
stderr: output.stream,
|
||||||
|
})).toBe(1);
|
||||||
|
expect(output.value()).toContain("active runtime was installed for installed Chromium");
|
||||||
|
|
||||||
|
const selected = captureStream();
|
||||||
|
expect(await runtimeMain(["config", "browser", "installed", process.execPath], {
|
||||||
|
cwd: source,
|
||||||
|
env: { ...process.env, GSTACK_HOME: home },
|
||||||
|
stdout: selected.stream,
|
||||||
|
stderr: selected.stream,
|
||||||
|
})).toBe(0);
|
||||||
|
expect((await readJson(path.join(home, "config.json"))).browser.provider).toBe("installed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rollback validates a recorded installed browser before switching runtime slots", async () => {
|
||||||
|
await withFixture(async ({ root, source, home }) => {
|
||||||
|
const fallback = await installFixture(source, home, "installed-fallback");
|
||||||
|
const staleBrowser = path.join(root, "removed-chromium");
|
||||||
|
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: staleBrowser },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const current = await installFixture(source, home, "managed-current");
|
||||||
|
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(1);
|
||||||
|
expect(output.value()).toContain("unavailable or not executable");
|
||||||
|
expect((await readJson(path.join(home, "versions", "current.json"))).current).toBe("managed-current");
|
||||||
|
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||||
|
.toEqual({ provider: "managed", executablePath: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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
|
||||||
|
process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||||
|
`, { mode: 0o755 });
|
||||||
|
const executable = await fs.realpath(process.execPath);
|
||||||
|
const output = captureStream();
|
||||||
|
expect(await runInstallerCli([
|
||||||
|
"--source", source,
|
||||||
|
"--home", home,
|
||||||
|
"--capabilities", "browser",
|
||||||
|
"--browser", "installed",
|
||||||
|
"--browser-path", executable,
|
||||||
|
"--install-now",
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
], {
|
||||||
|
stdout: output.stream,
|
||||||
|
stderr: output.stream,
|
||||||
|
prepareDependencies: async () => {},
|
||||||
|
installOptions: { entries: BROWSER_ENTRIES, capabilities: BROWSER_CAPABILITIES },
|
||||||
|
})).toBe(0);
|
||||||
|
expect((await readJson(path.join(home, "config.json"))).browser)
|
||||||
|
.toEqual({ provider: "installed", executablePath: executable });
|
||||||
|
expect((await runInstalledLauncher(home, "browse", [], { capture: true })).stdout)
|
||||||
|
.toBe(executable);
|
||||||
|
|
||||||
|
const failedHome = path.join(root, "failed-home", ".gstack");
|
||||||
|
const failed = captureStream();
|
||||||
|
expect(await runInstallerCli([
|
||||||
|
"--source", source,
|
||||||
|
"--home", failedHome,
|
||||||
|
"--capabilities", "browser",
|
||||||
|
"--browser", "installed",
|
||||||
|
"--browser-path", executable,
|
||||||
|
"--install-now",
|
||||||
|
"--yes",
|
||||||
|
"--json",
|
||||||
|
], {
|
||||||
|
stdout: failed.stream,
|
||||||
|
stderr: failed.stream,
|
||||||
|
prepareDependencies: async () => {},
|
||||||
|
installOptions: {
|
||||||
|
entries: BROWSER_ENTRIES,
|
||||||
|
capabilities: BROWSER_CAPABILITIES,
|
||||||
|
smokeTest: async () => { throw new Error("fixture smoke failure"); },
|
||||||
|
},
|
||||||
|
})).toBe(1);
|
||||||
|
expect(await exists(path.join(failedHome, "config.json"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("accepts a symlink to the source root but rejects links inside the allowlist", async () => {
|
test("accepts a symlink to the source root but rejects links inside the allowlist", async () => {
|
||||||
if (process.platform === "win32") return;
|
if (process.platform === "win32") return;
|
||||||
await withFixture(async ({ root, source, home }) => {
|
await withFixture(async ({ root, source, home }) => {
|
||||||
@@ -417,6 +604,13 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
await fs.writeFile(path.join(browserRoot, "chromium-fixture", "chrome"), "fixture\n", { mode: 0o755 });
|
await fs.writeFile(path.join(browserRoot, "chromium-fixture", "chrome"), "fixture\n", { mode: 0o755 });
|
||||||
return { code: 0, stdout: "", stderr: "" };
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
}
|
}
|
||||||
|
const outfileIndex = args.indexOf("--outfile");
|
||||||
|
if (outfileIndex >= 0 && typeof args[outfileIndex + 1] === "string") {
|
||||||
|
const outfile = path.join(REPO_ROOT, args[outfileIndex + 1]);
|
||||||
|
await fs.mkdir(path.dirname(outfile), { recursive: true });
|
||||||
|
await fs.writeFile(outfile, "fixture runtime helper\n", { mode: 0o755 });
|
||||||
|
return { code: 0, stdout: "", stderr: "" };
|
||||||
|
}
|
||||||
if (args[0] === "--version" && (command === process.execPath || command.includes(".gstack-runtime-tools"))) {
|
if (args[0] === "--version" && (command === process.execPath || command.includes(".gstack-runtime-tools"))) {
|
||||||
return { code: 0, stdout: "1.3.14\n", stderr: "" };
|
return { code: 0, stdout: "1.3.14\n", stderr: "" };
|
||||||
}
|
}
|
||||||
@@ -1095,15 +1289,20 @@ async function createSource(source: string) {
|
|||||||
await fs.mkdir(path.join(source, "runtime"), { recursive: true });
|
await fs.mkdir(path.join(source, "runtime"), { recursive: true });
|
||||||
await fs.mkdir(path.join(source, "bin"), { recursive: true });
|
await fs.mkdir(path.join(source, "bin"), { recursive: true });
|
||||||
await fs.mkdir(path.join(source, "cap"), { recursive: true });
|
await fs.mkdir(path.join(source, "cap"), { recursive: true });
|
||||||
|
await fs.mkdir(path.join(source, "browse", "dist"), { recursive: true });
|
||||||
await fs.writeFile(path.join(source, "package.json"), '{"name":"gstack","version":"2.0.0","type":"module"}\n');
|
await fs.writeFile(path.join(source, "package.json"), '{"name":"gstack","version":"2.0.0","type":"module"}\n');
|
||||||
await fs.writeFile(path.join(source, "runtime", "cli.js"), fixtureCli(""));
|
await fs.writeFile(path.join(source, "runtime", "cli.js"), fixtureCli(""));
|
||||||
await fs.writeFile(path.join(source, "runtime", "tooling.js"),
|
await fs.writeFile(path.join(source, "runtime", "tooling.js"),
|
||||||
'export async function resolveBashCommand(env = process.env) { return env.GSTACK_BASH || "bash"; }\n');
|
'export async function resolveBashCommand(env = process.env) { return env.GSTACK_BASH || "bash"; }\n');
|
||||||
|
await fs.copyFile(path.join(REPO_ROOT, "runtime", "browser-choice.mjs"), path.join(source, "runtime", "browser-choice.mjs"));
|
||||||
await fs.writeFile(path.join(source, "bin", "gstack"), `#!/usr/bin/env node
|
await fs.writeFile(path.join(source, "bin", "gstack"), `#!/usr/bin/env node
|
||||||
import { main } from "../runtime/cli.js";
|
import { main } from "../runtime/cli.js";
|
||||||
process.exitCode = await main(process.argv.slice(2));
|
process.exitCode = await main(process.argv.slice(2));
|
||||||
`, { mode: 0o755 });
|
`, { mode: 0o755 });
|
||||||
await fs.writeFile(path.join(source, "cap", "tool"), "#!/bin/sh\nprintf 'fixture capability %s\\n' \"$*\"\n", { mode: 0o755 });
|
await fs.writeFile(path.join(source, "cap", "tool"), "#!/bin/sh\nprintf 'fixture capability %s\\n' \"$*\"\n", { mode: 0o755 });
|
||||||
|
await fs.writeFile(path.join(source, "browse", "dist", "browse"), `#!/usr/bin/env node
|
||||||
|
process.stdout.write(process.env.GSTACK_CHROMIUM_PATH || "unset");
|
||||||
|
`, { mode: 0o755 });
|
||||||
}
|
}
|
||||||
|
|
||||||
function fixtureCli(label: string) {
|
function fixtureCli(label: string) {
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { PassThrough, Readable } from "node:stream";
|
||||||
import { runDoctor } from "../runtime/doctor.js";
|
import { runDoctor } from "../runtime/doctor.js";
|
||||||
import { runInstallerCli, runtimeSlotVersion, runtimeSurfaceForCapabilities } from "../runtime/install.js";
|
import { runInstallerCli, runtimeSlotVersion, runtimeSurfaceForCapabilities } from "../runtime/install.js";
|
||||||
import { resolveRuntimePaths } from "../runtime/paths.js";
|
import { resolveRuntimePaths } from "../runtime/paths.js";
|
||||||
import { setupRuntime } from "../runtime/setup.js";
|
import { setupRuntime } from "../runtime/setup.js";
|
||||||
|
import { configSetBrowserChoice } from "../runtime/config.js";
|
||||||
|
import { detectInstalledBrowsers, resolveBrowserChoice } from "../runtime/browser-choice.mjs";
|
||||||
import { bashCandidates, resolveBashCommand } from "../runtime/tooling.js";
|
import { bashCandidates, resolveBashCommand } from "../runtime/tooling.js";
|
||||||
import {
|
import {
|
||||||
BOOTSTRAP_SCHEMA_VERSION,
|
BOOTSTRAP_SCHEMA_VERSION,
|
||||||
@@ -96,13 +99,13 @@ describe("GStack runtime setup UX", () => {
|
|||||||
}));
|
}));
|
||||||
const retained = capture();
|
const retained = capture();
|
||||||
expect(await runInstallerCli([
|
expect(await runInstallerCli([
|
||||||
"--source", source, "--home", home, "--capabilities", "pdf", "--dry-run", "--json",
|
"--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--dry-run", "--json",
|
||||||
], { stdout: retained.stream, stderr: retained.stream })).toBe(0);
|
], { stdout: retained.stream, stderr: retained.stream })).toBe(0);
|
||||||
expect(JSON.parse(retained.value()).preview.capabilities).toEqual(["browser", "design", "diagram", "pdf"]);
|
expect(JSON.parse(retained.value()).preview.capabilities).toEqual(["browser", "design", "diagram", "pdf"]);
|
||||||
|
|
||||||
const replaced = capture();
|
const replaced = capture();
|
||||||
expect(await runInstallerCli([
|
expect(await runInstallerCli([
|
||||||
"--source", source, "--home", home, "--capabilities", "pdf", "--replace-capabilities", "--dry-run", "--json",
|
"--source", source, "--home", home, "--capabilities", "pdf", "--browser", "managed", "--replace-capabilities", "--dry-run", "--json",
|
||||||
], { stdout: replaced.stream, stderr: replaced.stream })).toBe(0);
|
], { stdout: replaced.stream, stderr: replaced.stream })).toBe(0);
|
||||||
expect(JSON.parse(replaced.value()).preview.capabilities).toEqual(["browser", "diagram", "pdf"]);
|
expect(JSON.parse(replaced.value()).preview.capabilities).toEqual(["browser", "diagram", "pdf"]);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -146,6 +149,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
"--source", path.resolve(import.meta.dir, ".."),
|
"--source", path.resolve(import.meta.dir, ".."),
|
||||||
"--home", home,
|
"--home", home,
|
||||||
"--capabilities", "browser",
|
"--capabilities", "browser",
|
||||||
|
"--browser", "managed",
|
||||||
"--dry-run",
|
"--dry-run",
|
||||||
"--json",
|
"--json",
|
||||||
], {
|
], {
|
||||||
@@ -166,6 +170,183 @@ describe("GStack runtime setup UX", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("installed-browser preview skips every managed Chromium payload without persisting the choice", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-installed-preview-"));
|
||||||
|
const home = path.join(root, "home");
|
||||||
|
const output = capture();
|
||||||
|
try {
|
||||||
|
expect(await runInstallerCli([
|
||||||
|
"--source", path.resolve(import.meta.dir, ".."),
|
||||||
|
"--home", home,
|
||||||
|
"--capabilities", "browser",
|
||||||
|
"--browser", "installed",
|
||||||
|
"--browser-path", process.execPath,
|
||||||
|
"--dry-run",
|
||||||
|
"--json",
|
||||||
|
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||||
|
const preview = JSON.parse(output.value()).preview;
|
||||||
|
expect(preview.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) });
|
||||||
|
expect(preview.materializations.some((item) => item.kind === "playwright-chromium-download")).toBe(false);
|
||||||
|
expect(runtimeSurfaceForCapabilities(["browser"], {
|
||||||
|
browserChoice: preview.browser,
|
||||||
|
}).entries.some((entry) => entry.path === ".gstack-runtime-browsers")).toBe(false);
|
||||||
|
await expect(fs.stat(home)).rejects.toMatchObject({ code: "ENOENT" });
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("installed-browser detection preserves wrapper paths, deduplicates physical targets, and rejects invalid files", async () => {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-detect-"));
|
||||||
|
try {
|
||||||
|
const physical = path.join(root, "snap");
|
||||||
|
const chrome = path.join(root, "google-chrome");
|
||||||
|
const chromium = path.join(root, "chromium");
|
||||||
|
const invalid = path.join(root, "not-executable");
|
||||||
|
await fs.writeFile(physical, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
await fs.writeFile(invalid, "not executable\n", { mode: 0o644 });
|
||||||
|
await fs.symlink(physical, chrome);
|
||||||
|
await fs.symlink(physical, chromium);
|
||||||
|
|
||||||
|
const detected = await detectInstalledBrowsers({
|
||||||
|
platform: "linux",
|
||||||
|
env: { PATH: root },
|
||||||
|
homeDir: root,
|
||||||
|
});
|
||||||
|
expect(detected).toEqual([{ name: "Google Chrome", executablePath: chrome }]);
|
||||||
|
expect(await resolveBrowserChoice({ provider: "installed", executablePath: chrome }, { platform: "linux" }))
|
||||||
|
.toEqual({ provider: "installed", executablePath: chrome });
|
||||||
|
await expect(resolveBrowserChoice({ provider: "installed", executablePath: invalid }, { platform: "linux" }))
|
||||||
|
.rejects.toMatchObject({ code: "BROWSER_PATH_INVALID" });
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("interactive browser choice covers managed, installed, later, and invalid selections without installing", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-browser-choice-"));
|
||||||
|
const source = path.join(root, "minimal-source");
|
||||||
|
const installedBrowser = path.join(root, "google-chrome");
|
||||||
|
await fs.mkdir(source);
|
||||||
|
await fs.writeFile(installedBrowser, "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||||
|
try {
|
||||||
|
const cases = [
|
||||||
|
{ answer: "m", label: "managed isolated Chromium", code: 0 },
|
||||||
|
{ answer: "1", label: installedBrowser, code: 0 },
|
||||||
|
{ answer: "l", label: "No browser provider was selected", code: 0 },
|
||||||
|
{ answer: "9", label: "Invalid browser selection", code: 1 },
|
||||||
|
];
|
||||||
|
for (const [index, fixture] of cases.entries()) {
|
||||||
|
const home = path.join(root, `home-${index}`);
|
||||||
|
const output = new PassThrough();
|
||||||
|
const input = new PassThrough() as PassThrough & { isTTY: boolean };
|
||||||
|
input.isTTY = true;
|
||||||
|
let outputValue = "";
|
||||||
|
let answeredBrowser = false;
|
||||||
|
let answeredInstall = false;
|
||||||
|
output.on("data", (chunk) => {
|
||||||
|
outputValue += String(chunk);
|
||||||
|
if (!answeredBrowser && outputValue.includes("Select m, a browser number, or l")) {
|
||||||
|
answeredBrowser = true;
|
||||||
|
input.write(`${fixture.answer}\n`);
|
||||||
|
}
|
||||||
|
if (!answeredInstall && outputValue.includes("Install this optional local runtime now?")) {
|
||||||
|
answeredInstall = true;
|
||||||
|
input.end("later\n");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const code = await runInstallerCli([
|
||||||
|
"--source", source,
|
||||||
|
"--home", home,
|
||||||
|
"--capabilities", "browser",
|
||||||
|
], {
|
||||||
|
stdin: input,
|
||||||
|
stdout: output,
|
||||||
|
stderr: output,
|
||||||
|
platform: "linux",
|
||||||
|
env: { ...process.env, PATH: root },
|
||||||
|
homeDir: root,
|
||||||
|
});
|
||||||
|
expect(code, outputValue).toBe(fixture.code);
|
||||||
|
expect(outputValue).toContain(fixture.label);
|
||||||
|
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;
|
||||||
|
const browser = await fs.realpath(process.execPath);
|
||||||
|
expect(await bootstrapMain([
|
||||||
|
"options", "--capability", "browser", "--json",
|
||||||
|
], {
|
||||||
|
stdout: output.stream,
|
||||||
|
stderr: output.stream,
|
||||||
|
browserCandidates: [{ name: "Fixture Chromium", executablePath: browser }],
|
||||||
|
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||||
|
})).toBe(0);
|
||||||
|
expect(JSON.parse(output.value())).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
action: "options",
|
||||||
|
mutated: false,
|
||||||
|
network: false,
|
||||||
|
installed: [{ name: "Fixture Chromium", executablePath: browser }],
|
||||||
|
});
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
|
||||||
|
const missing = capture();
|
||||||
|
expect(await bootstrapMain(["preview", "--capability", "browser"], {
|
||||||
|
stdout: missing.stream,
|
||||||
|
stderr: missing.stream,
|
||||||
|
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||||
|
})).toBe(1);
|
||||||
|
expect(missing.value()).toContain("Choose a browser provider");
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("official installed-browser preview reports exact adapter bytes and omits browser binaries", async () => {
|
||||||
|
const output = capture();
|
||||||
|
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||||
|
let fetches = 0;
|
||||||
|
expect(await bootstrapMain([
|
||||||
|
"preview", "--capability", "browser", "--browser", "installed",
|
||||||
|
"--browser-path", process.execPath, "--json",
|
||||||
|
], {
|
||||||
|
stdout: output.stream,
|
||||||
|
stderr: output.stream,
|
||||||
|
libc: process.platform === "linux" ? "glibc" : undefined,
|
||||||
|
fetch: async (url: string) => {
|
||||||
|
fetches += 1;
|
||||||
|
return { ok: true, url, json: async () => officialManifestFixture(target) };
|
||||||
|
},
|
||||||
|
})).toBe(0);
|
||||||
|
const result = JSON.parse(output.value());
|
||||||
|
expect(result.browser).toEqual({ provider: "installed", executablePath: await fs.realpath(process.execPath) });
|
||||||
|
expect(result.components).toEqual(["browser-code", "core"]);
|
||||||
|
expect(result.downloads.map((item) => item.component)).toEqual(["browser-code", "core"]);
|
||||||
|
expect(result.downloadBytes).toBe(16);
|
||||||
|
expect(fetches).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("visible GStack Browser refuses installed Chrome before any network request", async () => {
|
||||||
|
const output = capture();
|
||||||
|
let fetches = 0;
|
||||||
|
expect(await bootstrapMain([
|
||||||
|
"preview", "--capability", "browser-visible", "--browser", "installed",
|
||||||
|
"--browser-path", process.execPath,
|
||||||
|
], {
|
||||||
|
stdout: output.stream,
|
||||||
|
stderr: output.stream,
|
||||||
|
fetch: async () => { fetches += 1; throw new Error("unexpected fetch"); },
|
||||||
|
})).toBe(1);
|
||||||
|
expect(output.value()).toContain("requires managed Chromium");
|
||||||
|
expect(fetches).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("Windows Bash discovery shared by doctor and launchers finds a standard Git installation", async () => {
|
test("Windows Bash discovery shared by doctor and launchers finds a standard Git installation", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-git-bash-"));
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack-git-bash-"));
|
||||||
const bash = path.join(root, "Git", "bin", "bash.exe");
|
const bash = path.join(root, "Git", "bin", "bash.exe");
|
||||||
@@ -239,6 +420,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
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 });
|
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||||
expect(report.ok).toBe(false);
|
expect(report.ok).toBe(false);
|
||||||
expect(report.checks.find((check) => check.id === "capability:pdf")).toMatchObject({ status: "fail" });
|
expect(report.checks.find((check) => check.id === "capability:pdf")).toMatchObject({ status: "fail" });
|
||||||
@@ -274,6 +456,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
await fs.writeFile(paths.versionPointer, JSON.stringify({
|
||||||
schemaVersion: 2, status: "active", current: "fixture", lastKnownGood: "fixture",
|
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 });
|
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||||
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
||||||
status: "pass",
|
status: "pass",
|
||||||
@@ -285,6 +468,43 @@ describe("GStack runtime setup UX", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
try {
|
||||||
|
await setupRuntime({ home, cwd: root });
|
||||||
|
const paths = resolveRuntimePaths({ home });
|
||||||
|
const active = path.join(paths.versions, "fixture");
|
||||||
|
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.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);
|
||||||
|
const executable = await fs.realpath(process.execPath);
|
||||||
|
await fs.writeFile(path.join(playwright, "index.mjs"),
|
||||||
|
`export const chromium = { launch: async ({ headless, executablePath }) => { if (headless !== true || executablePath !== ${JSON.stringify(executable)}) throw new Error("wrong installed-browser launch"); return { version: () => "fixture-installed", close: async () => {} }; } };\n`);
|
||||||
|
await fs.writeFile(path.join(active, ".gstack-bundle.json"), JSON.stringify({
|
||||||
|
compatibility: { skillApi: "2.0" },
|
||||||
|
selectedCapabilities: ["browser"],
|
||||||
|
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: "installed", executablePath: executable });
|
||||||
|
const report = await runDoctor({ home, cwd: root, nodeCommand: process.execPath });
|
||||||
|
expect(report.checks.find((check) => check.id === "browser-selection")).toMatchObject({ status: "pass" });
|
||||||
|
expect(report.checks.find((check) => check.id === "capability:browser")).toMatchObject({
|
||||||
|
status: "pass",
|
||||||
|
details: { provider: "installed", executablePath: executable, version: "fixture-installed" },
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("bootstrap help has no dependency or network side effects", async () => {
|
test("bootstrap help has no dependency or network side effects", async () => {
|
||||||
const output = capture();
|
const output = capture();
|
||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
@@ -303,7 +523,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
let calls = 0;
|
let calls = 0;
|
||||||
try {
|
try {
|
||||||
expect(await bootstrapMain([
|
expect(await bootstrapMain([
|
||||||
"preview", "--capability", "browser-visible", "--home", path.join(root, "home"),
|
"preview", "--capability", "browser-visible", "--browser", "managed", "--home", path.join(root, "home"),
|
||||||
], {
|
], {
|
||||||
stdout: output.stream,
|
stdout: output.stream,
|
||||||
stderr: output.stream,
|
stderr: output.stream,
|
||||||
@@ -355,7 +575,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
arrayBuffer: async () => new TextEncoder().encode("tampered").buffer,
|
arrayBuffer: async () => new TextEncoder().encode("tampered").buffer,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], {
|
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], {
|
||||||
stdout: output.stream,
|
stdout: output.stream,
|
||||||
stderr: output.stream,
|
stderr: output.stream,
|
||||||
fetch: fetch_,
|
fetch: fetch_,
|
||||||
@@ -381,7 +601,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
test("official Linux bootstrap rejects musl explicitly before any network request", async () => {
|
test("official Linux bootstrap rejects musl explicitly before any network request", async () => {
|
||||||
const output = capture();
|
const output = capture();
|
||||||
let fetches = 0;
|
let fetches = 0;
|
||||||
expect(await bootstrapMain(["install", "--capability", "browser"], {
|
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed"], {
|
||||||
platform: "linux",
|
platform: "linux",
|
||||||
arch: "x64",
|
arch: "x64",
|
||||||
libc: "musl",
|
libc: "musl",
|
||||||
@@ -397,7 +617,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
const output = capture();
|
const output = capture();
|
||||||
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
const target = `${process.platform === "win32" ? "windows" : process.platform}-${process.arch}`;
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
expect(await bootstrapMain(["install", "--capability", "browser", "--yes"], {
|
expect(await bootstrapMain(["install", "--capability", "browser", "--browser", "managed", "--yes"], {
|
||||||
stdout: output.stream,
|
stdout: output.stream,
|
||||||
stderr: output.stream,
|
stderr: output.stream,
|
||||||
fetch: async (url: string) => {
|
fetch: async (url: string) => {
|
||||||
@@ -429,7 +649,7 @@ describe("GStack runtime setup UX", () => {
|
|||||||
process.env.BOOTSTRAP_TEST_LOG = log;
|
process.env.BOOTSTRAP_TEST_LOG = log;
|
||||||
try {
|
try {
|
||||||
expect(await bootstrapMain([
|
expect(await bootstrapMain([
|
||||||
"install", "--source", root, "--capability", "pdf", "--home", path.join(root, "home"), "--yes",
|
"install", "--source", root, "--capability", "pdf", "--browser", "managed", "--home", path.join(root, "home"), "--yes",
|
||||||
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
], { stdout: output.stream, stderr: output.stream })).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG;
|
if (previous == null) delete process.env.BOOTSTRAP_TEST_LOG;
|
||||||
|
|||||||
@@ -61,16 +61,19 @@ describe('GStack 2 canonical skill UX', () => {
|
|||||||
for (const tree of TREE_NAMES) {
|
for (const tree of TREE_NAMES) {
|
||||||
const runtime = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), 'utf8');
|
const runtime = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), 'utf8');
|
||||||
const bootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
const bootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
||||||
|
const browserChoice = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'browser-choice.mjs'));
|
||||||
const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8'));
|
const contract = JSON.parse(fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-contract.json'), 'utf8'));
|
||||||
expect(bootstrap, tree).toEqual(source);
|
expect(bootstrap, tree).toEqual(source);
|
||||||
|
expect(browserChoice, tree).toEqual(fs.readFileSync(path.join(ROOT, 'runtime', 'browser-choice.mjs')));
|
||||||
expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' });
|
expect(contract, tree).toEqual({ schemaVersion: 1, runtimeVersion: '2.0.0', skillApi: '2.0' });
|
||||||
expect(runtime, tree).toContain('preview --capability <name>');
|
expect(runtime, tree).toContain('preview --capability <name>');
|
||||||
expect(runtime, tree).toContain('It never downloads components or mutates runtime state.');
|
expect(runtime, tree).toContain('It never downloads components or mutates runtime state.');
|
||||||
expect(runtime, tree).toContain('install --capability <name> --yes');
|
expect(runtime, tree).toContain('options --capability <name>');
|
||||||
|
expect(runtime, tree).toContain('gstack config browser clear');
|
||||||
expect(runtime, tree).toContain('Never run `./setup` inside a standard-installed skill directory');
|
expect(runtime, tree).toContain('Never run `./setup` inside a standard-installed skill directory');
|
||||||
expect(runtime, tree).toContain('Deferring installation records no consent');
|
expect(runtime, tree).toContain('Deferring installation records no consent');
|
||||||
expect(runtime, tree).toContain('Logical `browser` expands to `browser-code + browser-headless`');
|
expect(runtime, tree).toContain('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`');
|
||||||
expect(runtime, tree).toContain('`browser-visible` expands to `browser-code + browser-visible` and does not require headless');
|
expect(runtime, tree).toContain('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only');
|
||||||
expect(runtime, tree).toContain('`pdf` depends on `diagram`');
|
expect(runtime, tree).toContain('`pdf` depends on `diagram`');
|
||||||
expect(runtime, tree).toContain('`all` means those five and intentionally excludes visible Chromium');
|
expect(runtime, tree).toContain('`all` means those five and intentionally excludes visible Chromium');
|
||||||
expect(runtime, tree).toContain('summed compressed bytes');
|
expect(runtime, tree).toContain('summed compressed bytes');
|
||||||
@@ -85,8 +88,8 @@ describe('GStack 2 canonical skill UX', () => {
|
|||||||
for (const source of ['open-gstack-browser', 'pair-agent', 'setup-browser-cookies']) {
|
for (const source of ['open-gstack-browser', 'pair-agent', 'setup-browser-cookies']) {
|
||||||
const body = fs.readFileSync(ownerModule(source), 'utf8');
|
const body = fs.readFileSync(ownerModule(source), 'utf8');
|
||||||
expect(body, source).toContain('## Visible-browser point-of-use gate');
|
expect(body, source).toContain('## Visible-browser point-of-use gate');
|
||||||
expect(body, source).toContain('preview --capability browser-visible');
|
expect(body, source).toContain('preview --capability browser-visible --browser managed');
|
||||||
expect(body, source).toContain('install --capability browser-visible --yes');
|
expect(body, source).toContain('install --capability browser-visible --browser managed --yes');
|
||||||
expect(body, source).toContain('never requires `browser-headless`');
|
expect(body, source).toContain('never requires `browser-headless`');
|
||||||
}
|
}
|
||||||
expect(fs.readFileSync(ownerModule('browse'), 'utf8')).not.toContain('browser-visible');
|
expect(fs.readFileSync(ownerModule('browse'), 'utf8')).not.toContain('browser-visible');
|
||||||
@@ -124,7 +127,7 @@ describe('GStack 2 canonical skill UX', () => {
|
|||||||
let stdout = '';
|
let stdout = '';
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
const code = await module.main([
|
const code = await module.main([
|
||||||
'install', '--source', source, '--capability', 'browser', '--home', home, '--yes',
|
'install', '--source', source, '--capability', 'browser', '--browser', 'managed', '--home', home, '--yes',
|
||||||
], {
|
], {
|
||||||
stdout: { write: (chunk: string) => { stdout += chunk; } },
|
stdout: { write: (chunk: string) => { stdout += chunk; } },
|
||||||
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
stderr: { write: (chunk: string) => { stderr += chunk; } },
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ describe("release and CI hardening", () => {
|
|||||||
});
|
});
|
||||||
expect(pkg.files).toEqual(["bin/gstack", "runtime", "README.md", "LICENSE", "VERSION"]);
|
expect(pkg.files).toEqual(["bin/gstack", "runtime", "README.md", "LICENSE", "VERSION"]);
|
||||||
expect(pkg.dependencies["puppeteer-core"]).toBeUndefined();
|
expect(pkg.dependencies["puppeteer-core"]).toBeUndefined();
|
||||||
|
expect(pkg.dependencies.playwright).toBe("npm:playwright-core@^1.58.2");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runtime identity is aligned independently of the legacy four-slot release counter", () => {
|
test("runtime identity is aligned independently of the legacy four-slot release counter", () => {
|
||||||
@@ -53,6 +54,7 @@ describe("release and CI hardening", () => {
|
|||||||
expect(workflow).toContain("versions/current.json");
|
expect(workflow).toContain("versions/current.json");
|
||||||
expect(workflow).not.toContain('active="$GSTACK_HOME/versions/2.0.0"');
|
expect(workflow).not.toContain('active="$GSTACK_HOME/versions/2.0.0"');
|
||||||
expect(workflow).toContain(".gstack-runtime-browsers");
|
expect(workflow).toContain(".gstack-runtime-browsers");
|
||||||
|
expect(workflow).toContain("--browser managed");
|
||||||
// Exercise both the bundled browser and the explicit Chromium channel. Keep
|
// Exercise both the bundled browser and the explicit Chromium channel. Keep
|
||||||
// this semantic: the workflow intentionally loops over launch options so a
|
// this semantic: the workflow intentionally loops over launch options so a
|
||||||
// harmless refactor does not invalidate release hardening.
|
// harmless refactor does not invalidate release hardening.
|
||||||
@@ -68,6 +70,8 @@ describe("release and CI hardening", () => {
|
|||||||
expect(workflow).toContain("pathToFileURL(p).href");
|
expect(workflow).toContain("pathToFileURL(p).href");
|
||||||
expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")');
|
expect(workflow).toContain('path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html")');
|
||||||
expect(workflow).not.toContain("goto about:blank");
|
expect(workflow).not.toContain("goto about:blank");
|
||||||
|
expect(read("scripts/gstack2/runtime-install-smoke.sh"))
|
||||||
|
.toContain('./setup --home "$HOME_DIR" --browser managed --install-now --yes --json');
|
||||||
const manifest = read(".github/scripts/create-runtime-release-manifest.mjs");
|
const manifest = read(".github/scripts/create-runtime-release-manifest.mjs");
|
||||||
expect(manifest).toContain("bytes: stat.size");
|
expect(manifest).toContain("bytes: stat.size");
|
||||||
expect(manifest).toContain('certificateOidcIssuer: "https://token.actions.githubusercontent.com"');
|
expect(manifest).toContain('certificateOidcIssuer: "https://token.actions.githubusercontent.com"');
|
||||||
@@ -85,6 +89,7 @@ describe("release and CI hardening", () => {
|
|||||||
const installer = read("runtime/install.js");
|
const installer = read("runtime/install.js");
|
||||||
expect(installer).toContain('entry("runtime")');
|
expect(installer).toContain('entry("runtime")');
|
||||||
expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)');
|
expect(installer).toContain('entry(managedBunRelativePath(), "managed-bun", true)');
|
||||||
|
expect(installer).not.toContain('entry("node_modules/playwright-core")');
|
||||||
const browser = read("browse/src/cli.ts");
|
const browser = read("browse/src/cli.ts");
|
||||||
expect(browser).toContain("Every installed/compiled client must use the adjacent Node-compatible daemon");
|
expect(browser).toContain("Every installed/compiled client must use the adjacent Node-compatible daemon");
|
||||||
expect(browser).toContain("export function resolveServerLaunchTarget(");
|
expect(browser).toContain("export function resolveServerLaunchTarget(");
|
||||||
@@ -93,8 +98,8 @@ describe("release and CI hardening", () => {
|
|||||||
|
|
||||||
test("Windows setup lane installs, doctors, and uninstalls rather than only building", () => {
|
test("Windows setup lane installs, doctors, and uninstalls rather than only building", () => {
|
||||||
const workflow = read(".github/workflows/windows-setup-e2e.yml");
|
const workflow = read(".github/workflows/windows-setup-e2e.yml");
|
||||||
expect(workflow).toContain("--dry-run --capabilities browser");
|
expect(workflow).toContain("--dry-run --capabilities browser --browser managed");
|
||||||
expect(workflow).toContain("--install-now --yes --capabilities browser");
|
expect(workflow).toContain("--install-now --yes --capabilities browser --browser managed");
|
||||||
expect(workflow).toContain("doctor --json");
|
expect(workflow).toContain("doctor --json");
|
||||||
expect(workflow).toContain("runtime/cli.js uninstall");
|
expect(workflow).toContain("runtime/cli.js uninstall");
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user