diff --git a/.github/docker/Dockerfile.ci b/.github/docker/Dockerfile.ci index ebf4a4d13..2be739d4d 100644 --- a/.github/docker/Dockerfile.ci +++ b/.github/docker/Dockerfile.ci @@ -70,11 +70,11 @@ RUN curl --retry 5 --retry-delay 5 --retry-connrefused -fsSL https://bun.sh/inst RUN npm i -g @anthropic-ai/claude-code # Playwright system deps (Chromium) — needed for browse E2E tests -RUN npx playwright install-deps chromium +RUN npx playwright-core install-deps chromium # Linux has neither Helvetica nor Arial. make-pdf's print CSS stacks fall back # to Liberation Sans (metric-compatible Arial clone, SIL OFL 1.1) so PDFs don't -# render in DejaVu Sans. playwright install-deps happens to pull this in today, +# render in DejaVu Sans. playwright-core install-deps happens to pull this in today, # but the dep is implicit and could change — install explicitly so upgrades # can't silently regress rendering. # @@ -100,12 +100,12 @@ RUN bun install --frozen-lockfile && rm -rf /tmp/* # Install Playwright Chromium to a shared location accessible by all users ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers -RUN npx playwright install chromium \ +RUN npx playwright-core install chromium \ && chmod -R a+rX /opt/playwright-browsers # Verify everything works RUN bun --version && node --version && claude --version && jq --version && gh --version \ - && npx playwright --version \ + && npx playwright-core --version \ && fc-match "Liberation Sans" | grep -qi "Liberation" \ || (echo "ERROR: fonts-liberation not installed — make-pdf PDFs will render in DejaVu Sans" && exit 1) diff --git a/.github/scripts/create-runtime-release-manifest.mjs b/.github/scripts/create-runtime-release-manifest.mjs index 4fd487bac..198a48286 100644 --- a/.github/scripts/create-runtime-release-manifest.mjs +++ b/.github/scripts/create-runtime-release-manifest.mjs @@ -2,11 +2,19 @@ import fs from "node:fs/promises"; import path from "node:path"; -const [directory, repository = process.env.GITHUB_REPOSITORY, version = "2.0.0"] = process.argv.slice(2); +const [ + directory, + repository = process.env.GITHUB_REPOSITORY, + version = "2.0.0", + releaseTag = `v${version}`, +] = process.argv.slice(2); if (!directory || !repository) { - console.error("Usage: create-runtime-release-manifest.mjs [version]"); + console.error("Usage: create-runtime-release-manifest.mjs [version] [release-tag]"); process.exit(2); } +if (!/^v\d+\.\d+\.\d+(?:-rc\.\d+)?$/.test(releaseTag)) { + throw new Error(`Invalid runtime release tag: ${releaseTag}`); +} const targets = [ "darwin-arm64", @@ -35,8 +43,8 @@ const capabilityComponents = { ios: ["ios"], }; const commonComponents = ["core", "browser-code", "browser-headless", "browser-visible", "design", "diagram", "pdf"]; -const release = `https://github.com/${repository}/releases/download/v${version}`; -const certificateIdentity = `https://github.com/${repository}/.github/workflows/release-artifacts.yml@refs/tags/v${version}`; +const release = `https://github.com/${repository}/releases/download/${releaseTag}`; +const certificateIdentity = `https://github.com/${repository}/.github/workflows/release-artifacts.yml@refs/tags/${releaseTag}`; const targetRecords = {}; for (const target of targets) { diff --git a/.github/workflows/make-pdf-gate.yml b/.github/workflows/make-pdf-gate.yml index 9a3d13538..69c4d7eb1 100644 --- a/.github/workflows/make-pdf-gate.yml +++ b/.github/workflows/make-pdf-gate.yml @@ -66,7 +66,7 @@ jobs: fc-match -f '%{family[0]}\t%{color}\n' ':lang=und-zsye:charset=1F600' || true - name: Install Playwright Chromium - run: bunx playwright install chromium + run: bunx playwright-core install chromium - name: Build binaries run: bun run build diff --git a/.github/workflows/release-artifacts.yml b/.github/workflows/release-artifacts.yml index 4ef821ebd..f45017559 100644 --- a/.github/workflows/release-artifacts.yml +++ b/.github/workflows/release-artifacts.yml @@ -2,7 +2,9 @@ name: Release runtime artifacts on: push: - tags: [v2.0.0] + tags: + - v2.0.0 + - v2.0.0-rc.* workflow_dispatch: permissions: @@ -51,8 +53,6 @@ jobs: - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22.23.1 - - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 - - name: Install frozen dependencies run: bun install --frozen-lockfile --ignore-scripts shell: bash @@ -70,6 +70,7 @@ jobs: --version 2.0.0 \ --install-now \ --yes \ + --browser managed \ --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="$GSTACK_HOME/versions/$active_slot" @@ -112,32 +113,28 @@ jobs: "$GSTACK_HOME/bin/browse" stop >/dev/null 2>&1 || true } trap browser_cleanup EXIT + smoke_url=$(node -e 'const fs=require("fs"),p=require("path").join(process.env.GITHUB_WORKSPACE,".gstack-runtime-smoke.html");fs.writeFileSync(p,"GStack runtime smoke\n");process.stdout.write(require("url").pathToFileURL(p).href)') PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ - "$GSTACK_HOME/bin/browse" goto about:blank + "$GSTACK_HOME/bin/browse" goto "$smoke_url" PATH="$clean_path" GSTACK_NODE="$node_command" BROWSE_PARENT_PID=0 \ "$GSTACK_HOME/bin/browse" status browser_cleanup trap - EXIT stage="$RUNNER_TEMP/runtime-components" - mkdir -p "$stage" "$GITHUB_WORKSPACE/release-output" + # GNU tar treats a Windows drive colon in an archive path as a + # remote-host separator. Keep archive output in Git Bash's POSIX view. + release_dir="$(pwd -P)/release-output" + mkdir -p "$stage" "$release_dir" node .github/scripts/stage-runtime-components.mjs "$active" "$stage" for component_dir in "$stage"/*; do test -d "$component_dir" || continue component=$(basename "$component_dir") - archive="$GITHUB_WORKSPACE/release-output/gstack-runtime-2.0.0-$TARGET-$component.tar.gz" + archive="$release_dir/gstack-runtime-2.0.0-$TARGET-$component.tar.gz" tar -czf "$archive" -C "$component_dir" gstack node -e 'const fs=require("fs"),c=require("crypto"),p=process.argv[1];const b=fs.readFileSync(p);fs.writeFileSync(p+".sha256",c.createHash("sha256").update(b).digest("hex")+" "+require("path").basename(p)+"\n")' "$archive" done shell: bash - - name: Keyless-sign component archives - run: | - set -euo pipefail - for archive in release-output/*.tar.gz; do - cosign sign-blob --yes --bundle "$archive.sigstore.json" "$archive" - done - shell: bash - - name: Attest component archive provenance uses: actions/attest-build-provenance@e8998f949152b193b063cb0ec769d69d929409be # v2 with: @@ -169,8 +166,16 @@ jobs: merge-multiple: true - uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0 + - name: Keyless-sign component archives + run: | + set -euo pipefail + for archive in release-output/*.tar.gz; do + cosign sign-blob --yes --bundle "$archive.sigstore.json" "$archive" + done + shell: bash + - name: Create strict six-target manifest - run: node .github/scripts/create-runtime-release-manifest.mjs release-output "$GITHUB_REPOSITORY" 2.0.0 + run: node .github/scripts/create-runtime-release-manifest.mjs release-output "$GITHUB_REPOSITORY" 2.0.0 "$GITHUB_REF_NAME" - name: Checksum and keyless-sign manifest run: | @@ -188,11 +193,13 @@ jobs: - name: Publish immutable release assets env: GH_TOKEN: ${{ github.token }} + PRERELEASE_FLAG: ${{ contains(github.ref_name, '-rc.') && '--prerelease' || '' }} run: | set -euo pipefail gh release create "$GITHUB_REF_NAME" \ --verify-tag \ - --title "GStack runtime 2.0.0" \ - --notes "Signed optional runtime artifacts for the six portable GStack skills." \ + $PRERELEASE_FLAG \ + --title "GStack runtime $GITHUB_REF_NAME" \ + --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/* shell: bash diff --git a/.github/workflows/windows-setup-e2e.yml b/.github/workflows/windows-setup-e2e.yml index 53fae3b00..310566ce3 100644 --- a/.github/workflows/windows-setup-e2e.yml +++ b/.github/workflows/windows-setup-e2e.yml @@ -65,14 +65,14 @@ jobs: - name: Preview without mutating state run: | 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) shell: bash - name: Explicitly install the browser capability run: | 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/bin/gstack.cmd" shell: bash diff --git a/AGENTS.md b/AGENTS.md index 09bfc9be5..9f53c3b90 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,7 +60,7 @@ they contain no copied judgment. Representative mappings: Canonical installation is standards-based: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` Delegate host detection, placement, project/global scope, selected-skill diff --git a/CLAUDE.md b/CLAUDE.md index 1e1a9c033..56eeb62ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ mutation boundaries, recommendations, or voice. Compatibility files under Specification, and Full chain. Do not expose its internal aliases as more top-level modes. -Canonical install is `npx skills add time-attack/gstack`. Standard installers +Canonical install is `npx skills add time-attack/gstack/skills`. Standard installers own host placement and scope. Do not add host-specific install logic to the 2.0 path. Pure judgment must work without the optional `bin/gstack` runtime. The host-neutral `./setup` installs only that managed runtime/capability bundle; diff --git a/README.md b/README.md index dc368a644..a6d573736 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,7 @@ Install with the standard Agent Skills installer; it owns host detection, scope, destination paths, updates, removal, and selected-skill installation: ```bash -npx skills add time-attack/gstack +npx skills add time-attack/gstack/skills ``` That installs the six judgment skills. Install a subset with the installer's @@ -87,7 +87,7 @@ extension, connector, MCP, settings entry, profile, alternate host, or browser runtime is configured without consent, and `./setup` is never a GStack 2 browser setup command. -Install through `npx skills add time-attack/gstack`; do not clone the repository +Install through `npx skills add time-attack/gstack/skills`; do not clone the repository under `.agents/skills/gstack`. A standards installation exposes exactly one canonical QA skill at `.agents/skills/qa/SKILL.md`. The cloned compatibility tree contains legacy GStack 1 entry points and is not the GStack 2 install @@ -96,7 +96,7 @@ surface. The npm package is deliberately not the skill installer and does not contain the six skill tree or compiled browser/design/PDF payloads. It is the small host-neutral runtime control/bootstrap surface used by release tooling. New -users should install skills with `npx skills add time-attack/gstack`; optional +users should install skills with `npx skills add time-attack/gstack/skills`; optional capabilities are downloaded by a skill after consent. Public web research is optional. Context.dev is the only new external service, @@ -161,7 +161,7 @@ archive. It is not the GStack 2 installation or first-run path. ## Legacy 1.x host-specific install (compatibility only) Do not use this section for a new GStack 2 install. Use -`npx skills add time-attack/gstack` above. These instructions remain while old +`npx skills add time-attack/gstack/skills` above. These instructions remain while old commands are documented as opt-in aliases. They describe a 1.x release/tag: the current branch's `./setup` is runtime-only and will not perform the host-specific actions shown below. diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index 8774d9b17..5cdfbb8ec 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -44,6 +44,30 @@ export function isCustomChromium(): boolean { 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. * @@ -358,9 +382,11 @@ export class BrowserManager { // BROWSE_EXTENSIONS_DIR points to an unpacked Chrome extension directory. // Extensions only work in headed mode, so we use an off-screen window. const extensionsDir = process.env.BROWSE_EXTENSIONS_DIR; + if (extensionsDir) assertHeadedBrowserProvider(); const { STEALTH_LAUNCH_ARGS, buildGStackLaunchArgs } = await import('./stealth'); const launchArgs: string[] = [...STEALTH_LAUNCH_ARGS, ...buildGStackLaunchArgs()]; let useHeadless = true; + const executablePath = configuredChromiumExecutable(); // Docker/CI/root: Chromium sandbox requires unprivileged user namespaces which // are typically disabled in containers and are never available for the root @@ -387,7 +413,11 @@ export class BrowserManager { this.browser = await chromium.launch({ 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 // the Bun→Node process chain (GitHub #276). Disable it — local daemon // browsing user-specified URLs has marginal sandbox benefit. Also disabled @@ -447,6 +477,7 @@ export class BrowserManager { * every action Claude takes in real time. */ async launchHeaded(authToken?: string): Promise { + assertHeadedBrowserProvider(); // Clear old state before repopulating this.pages.clear(); this.tabSessions.clear(); @@ -515,7 +546,7 @@ export class BrowserManager { // Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var. // 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. // Patch the Chromium .app's Info.plist so macOS shows our name. @@ -1557,6 +1588,7 @@ export class BrowserManager { * If step 2 fails → return error, headless browser untouched */ async handoff(message: string): Promise { + assertHeadedBrowserProvider(); if (this.connectionMode === 'headed' || this.isHeaded) { return `HANDOFF: Already in headed mode at ${this.getCurrentUrl()}`; } diff --git a/browse/src/cli.ts b/browse/src/cli.ts index dee7b642f..93e4f17da 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -118,7 +118,7 @@ interface ServerState { serverPath: string; binaryVersion?: string; mode?: 'launched' | 'headed'; - /** Hash of (proxyUrl + headed flag), used by D2 daemon-mismatch check. */ + /** Hash of proxy, headed mode, and browser-provider intent, used by daemon-mismatch checks. */ configHash?: string; /** Xvfb child PID for cleanup on disconnect. */ xvfbPid?: number; @@ -431,8 +431,8 @@ async function ensureServer(flags?: GlobalFlags): Promise { // hint. No silent restart — that would drop tab state, cookies, and // logged-in sessions without warning. if (desiredHash && state.configHash && state.configHash !== desiredHash) { - console.error(`[browse] existing daemon has different config (proxy/headed mismatch).`); - console.error(`[browse] run 'browse disconnect' first to apply --proxy/--headed.`); + console.error(`[browse] existing daemon has different config (browser provider, proxy, or headed mode).`); + console.error(`[browse] run 'browse disconnect' first to apply the selected browser configuration.`); process.exit(1); } // Same path: existing daemon is plain (no flags) but caller passes @@ -782,7 +782,7 @@ export interface GlobalFlags { proxyUrl: string | null; /** Whether --headed was passed. */ headed: boolean; - /** Hash of (proxy + headed) for daemon-mismatch check. */ + /** Hash of proxy, headed mode, and browser-provider intent for daemon-mismatch checks. */ configHash: string; /** Redacted form of proxyUrl, safe for logs. */ redactedProxyUrl: string; @@ -842,7 +842,12 @@ export function extractGlobalFlags(rawArgs: string[], env: NodeJS.ProcessEnv): G args: out, proxyUrl: canonicalProxyUrl, headed, - configHash: computeConfigHash({ proxyUrl: canonicalProxyUrl, headed }), + configHash: computeConfigHash({ + proxyUrl: canonicalProxyUrl, + headed, + browserProvider: env.GSTACK_BROWSER_PROVIDER, + browserExecutable: env.GSTACK_CHROMIUM_PATH, + }), redactedProxyUrl: redactProxyUrl(canonicalProxyUrl), }; } diff --git a/browse/src/proxy-config.ts b/browse/src/proxy-config.ts index 161475825..e03f0f318 100644 --- a/browse/src/proxy-config.ts +++ b/browse/src/proxy-config.ts @@ -125,7 +125,7 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { } /** - * Compute a stable hash of (proxyUrl + headed flag) for daemon-mismatch + * Compute a stable hash of proxy, headed mode, and browser-provider intent for daemon-mismatch * detection (D2). The hash is deterministic across CLI invocations on the * same machine and survives daemon restarts via the state file. * @@ -135,9 +135,18 @@ export function toUpstreamConfig(cfg: ParsedProxyConfig): UpstreamConfig { export function computeConfigHash(opts: { proxyUrl: string | null | undefined; headed: boolean; + browserProvider?: string | null; + browserExecutable?: string | null; }): string { const proxyKey = canonicalizeProxyUrl(opts.proxyUrl); - const input = JSON.stringify({ proxy: proxyKey, headed: opts.headed }); + const browserProvider = opts.browserProvider || null; + const browserExecutable = browserProvider === "installed" ? opts.browserExecutable || null : null; + const input = JSON.stringify({ + proxy: proxyKey, + headed: opts.headed, + browserProvider, + browserExecutable, + }); return createHash('sha256').update(input).digest('hex').slice(0, 16); } diff --git a/browse/src/write-commands.ts b/browse/src/write-commands.ts index 4a847141d..626ba8794 100644 --- a/browse/src/write-commands.ts +++ b/browse/src/write-commands.ts @@ -355,11 +355,18 @@ export async function handleWriteCommand( } } catch (err: any) { // Enhanced error guidance: clicking