From d7357c288ff435ddd57ea77a362e43be378b5af8 Mon Sep 17 00:00:00 2001 From: Sinabina Date: Fri, 17 Jul 2026 12:09:49 -0700 Subject: [PATCH] harden runtime packaging and verification --- .github/workflows/gstack2-gate.yml | 6 +- ARCHITECTURE.md | 6 ++ README.md | 11 +- browse/test/handoff.test.ts | 24 +++-- design/test/feedback-roundtrip.test.ts | 16 ++- docs/gstack-2/ARCHITECTURE.md | 6 +- docs/gstack-2/HOST-COMPATIBILITY.md | 26 +++-- docs/gstack-2/PRIVACY.md | 6 +- docs/gstack-2/STATUS.md | 27 ++--- docs/gstack-2/TEST-EVIDENCE.md | 14 +-- docs/gstack-2/UPGRADE-AND-ROLLBACK.md | 34 +++--- evals/runtime-bundle/darwin-arm64.json | 23 ++++ ios-qa/scripts/physical-device-smoke.ts | 46 ++++++-- runtime/cli.js | 16 +-- runtime/context.js | 46 ++++---- runtime/errors.js | 6 ++ runtime/index.js | 4 + runtime/install.js | 77 +++++++++++--- runtime/managed-home.js | 15 +-- runtime/state.js | 78 ++++++-------- runtime/storage.js | 29 +++-- runtime/time.js | 5 + runtime/upgrade.js | 19 ++-- scripts/gstack2/audit-runtime-bundle.ts | 97 +++++++++++++++++ scripts/gstack2/devcontainer-gate.sh | 46 ++++++++ scripts/gstack2/generate-skill-tree.ts | 10 +- scripts/gstack2/host-adversarial.ts | 13 ++- scripts/gstack2/semantic-parity.ts | 6 +- scripts/gstack2/test-install-matrix.ts | 17 +-- scripts/slop-diff.ts | 13 ++- scripts/test-free-shards.ts | 41 ++++--- scripts/test-free-strict.ts | 21 +--- setup | 13 +-- test/gstack2-ci-runtime-smoke.test.ts | 92 ++++++++++++++++ test/gstack2-runtime-core.test.ts | 21 ++++ test/gstack2-runtime-install.test.ts | 136 ++++++++++++++++++++++-- 36 files changed, 801 insertions(+), 265 deletions(-) create mode 100644 evals/runtime-bundle/darwin-arm64.json create mode 100644 runtime/errors.js create mode 100644 runtime/time.js create mode 100644 scripts/gstack2/audit-runtime-bundle.ts diff --git a/.github/workflows/gstack2-gate.yml b/.github/workflows/gstack2-gate.yml index d7c0d2ec6..20fc3aec3 100644 --- a/.github/workflows/gstack2-gate.yml +++ b/.github/workflows/gstack2-gate.yml @@ -70,10 +70,10 @@ jobs: - name: Run the GStack 2 gate inside the container run: >- docker run --rm - --volume "${{ github.workspace }}:/workspaces/gstack" - --workdir /workspaces/gstack + --volume "${{ github.workspace }}:/source:ro" + --workdir /source gstack2-devcontainer - /workspaces/gstack/scripts/gstack2/devcontainer-gate.sh + /source/scripts/gstack2/devcontainer-gate.sh /source - name: Exercise a clean Linux runtime install inside the container run: >- docker run --rm diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 14a054686..b58953b7c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -239,6 +239,12 @@ Page content harvested by CDP can contain lone UTF-16 surrogate halves (orphaned The Chrome sidebar agent has tools (Bash, Read, Glob, Grep, WebFetch) and reads hostile web pages, so it's the part of gstack most exposed to prompt injection. Defense is layered, not single-point. +> **GStack 2 production boundary:** the ML sidecar described below is retained +> as 1.x source and test material only. The managed runtime does not build or +> copy the sidecar, keeps `@huggingface/transformers` development-only, installs +> no ONNX runtime or model weights, and reports L4 unavailable. The generator +> preserves this historical judgment without promoting it into 2.0 setup. + 1. **L1-L3 content security (`browse/src/content-security.ts`).** Runs on every page-content command and every tool output: datamarking, hidden-element strip, ARIA regex, URL blocklist, and a trust-boundary envelope wrapper. Applied at both the server and the agent. 2. **L4 ML classifier — TestSavantAI (`browse/src/security-classifier.ts`).** A 22MB BERT-small ONNX model (int8 quantized) bundled with the agent. Runs locally, no network. Scans every user message and every Read/Glob/Grep/WebFetch tool output before Claude sees it. Opt-in 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta`. diff --git a/README.md b/README.md index 1cd547de3..e62e12c6c 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ modules, and web-context choice, then lazily reads the preserved specialist module. See the complete [old-command mapping](docs/gstack-2/SKILL-MIGRATION.md) and [architecture](docs/gstack-2/ARCHITECTURE.md). -## GStack 2 quick start — under five minutes +## GStack 2 quick start Install with the standard Agent Skills installer; it owns host detection, scope, destination paths, updates, removal, and selected-skill installation: @@ -50,9 +50,10 @@ Start with `/plan`, or invoke the skill syntax your host displays. Pure judgment modes work without a shared executable. Capability-dependent modes may offer the optional, host-neutral runtime. From a repository checkout, `./setup` installs that runtime once per user without placing host skills. Its -real default-capability lifecycle passes on macOS, and a clean Linux arm64 Dev -Container install/build/uninstall smoke also passes. Native-host Linux and -Windows remain release gates, so consult +real default-capability lifecycle and 383-file broad suite pass on macOS. The +declared Linux Dev Container passes the 136-test GStack 2 suite, and a clean +Linux arm64 install/build/browser/uninstall smoke also passes. Native-host +Linux and Windows remain release gates, so consult [`HOST-COMPATIBILITY.md`](docs/gstack-2/HOST-COMPATIBILITY.md) before relying on it. @@ -364,7 +365,7 @@ gstack works well with one sprint. It gets interesting with ten running at once. **Personal automation.** The sidebar agent isn't just for dev workflows. Example: "Browse my kid's school parent portal and add all the other parents' names, phone numbers, and photos to my Google Contacts." Two ways to get authenticated: (1) log in once in the headed browser, your session persists, or (2) click the "cookies" button in the sidebar footer to import cookies from your real Chrome. Once authenticated, Claude navigates the directory, extracts the data, and creates the contacts. -**Prompt injection defense.** Hostile web pages try to hijack your sidebar agent. gstack ships a layered defense: a 22MB ML classifier bundled with the browser scans every page and tool output locally, a Claude Haiku transcript check votes on the full conversation shape, a random canary token in the system prompt catches session exfil attempts across text, tool args, URLs, and file writes, and a verdict combiner requires two classifiers to agree before blocking (prevents single-model false positives on Stack Overflow-style instruction pages). A shield icon in the sidebar header shows status (green/amber/red). Opt in to a 721MB DeBERTa-v3 ensemble via `GSTACK_SECURITY_ENSEMBLE=deberta` for 2-of-3 agreement. Emergency kill switch: `GSTACK_SECURITY_OFF=1`. See [ARCHITECTURE.md](ARCHITECTURE.md#prompt-injection-defense-sidebar-agent) for the full stack. +**Prompt injection defense.** Hostile web pages try to hijack your sidebar agent. Deterministic content stripping, datamarking, URL checks, canary exfiltration detection, and scoped tool boundaries remain active. The retained 1.x source also documents an ML sidecar and optional ensemble, but the GStack 2 production setup does not build or install that sidecar, Hugging Face/ONNX, or model weights; it reports L4 unavailable. See [ARCHITECTURE.md](ARCHITECTURE.md#prompt-injection-defense-sidebar-agent) for the retained stack and the GStack 2 boundary. **Browser handoff when the AI gets stuck.** Hit a CAPTCHA, auth wall, or MFA prompt? `$B handoff` opens a visible Chrome at the exact same page with all your cookies and tabs intact. Solve the problem, tell Claude you're done, `$B resume` picks up right where it left off. The agent even suggests it automatically after 3 consecutive failures. diff --git a/browse/test/handoff.test.ts b/browse/test/handoff.test.ts index 546b15f3c..c40a6250e 100644 --- a/browse/test/handoff.test.ts +++ b/browse/test/handoff.test.ts @@ -35,12 +35,24 @@ beforeAll(async () => { }); afterAll(async () => { - try { await bm?.close(); } catch {} - try { testServer.server.stop(); } catch {} - if (previousChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE; - else process.env.CHROMIUM_PROFILE = previousChromiumProfile; - try { fs.rmSync(testRoot, { recursive: true, force: true }); } catch {} -}); + const cleanupFailures = (await Promise.allSettled([ + bm?.close(1000), + testServer?.server.stop(true), + ])).filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + try { + if (previousChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE; + else process.env.CHROMIUM_PROFILE = previousChromiumProfile; + } catch (error) { + cleanupFailures.push(error); + } + try { + if (testRoot) fs.rmSync(testRoot, { recursive: true, force: true }); + } catch (error) { + cleanupFailures.push(error); + } + if (cleanupFailures.length > 0) throw new AggregateError(cleanupFailures, 'Handoff test cleanup failed'); +}, 10_000); // ─── Unit Tests: Failure Tracking (no browser needed) ──────────── diff --git a/design/test/feedback-roundtrip.test.ts b/design/test/feedback-roundtrip.test.ts index 009b993e2..d315714d3 100644 --- a/design/test/feedback-roundtrip.test.ts +++ b/design/test/feedback-roundtrip.test.ts @@ -130,10 +130,18 @@ beforeAll(async () => { }); afterAll(async () => { - try { server.stop(); } catch {} - try { await bm.close(1000); } catch {} - fs.rmSync(tmpDir, { recursive: true, force: true }); -}); + const cleanupFailures = (await Promise.allSettled([ + server?.stop(true), + bm?.close(1000), + ])).filter((result): result is PromiseRejectedResult => result.status === 'rejected') + .map((result) => result.reason); + try { + if (tmpDir) fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch (error) { + cleanupFailures.push(error); + } + if (cleanupFailures.length > 0) throw new AggregateError(cleanupFailures, 'Feedback test cleanup failed'); +}, 10_000); // ─── The critical test: browser click → file on disk ───────────── diff --git a/docs/gstack-2/ARCHITECTURE.md b/docs/gstack-2/ARCHITECTURE.md index 04ef9ebdb..17dea87c1 100644 --- a/docs/gstack-2/ARCHITECTURE.md +++ b/docs/gstack-2/ARCHITECTURE.md @@ -123,8 +123,10 @@ operations are setup, doctor, config, state inspection/resume, Context.dev status/options/select/setup/smoke, cleanup, upgrade/rollback, and uninstall. -The current managed-bundle audit records 107 components, 1,830 files, -459,056,031 bytes, and 50 launchers. Setup installs frozen production-only +The deterministic clean macOS arm64 managed-bundle audit records 110 +components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. This +is a platform-specific bundle measurement, not a universal byte count; +platform-native package payloads differ. Setup installs frozen production-only dependencies; the development-only Claude Agent SDK is excluded. The Sharp/ngrok dependency closure is included. The Hugging Face sidecar is excluded and its package is development-only, so setup installs neither its diff --git a/docs/gstack-2/HOST-COMPATIBILITY.md b/docs/gstack-2/HOST-COMPATIBILITY.md index aa5efc437..f97b95a3a 100644 --- a/docs/gstack-2/HOST-COMPATIBILITY.md +++ b/docs/gstack-2/HOST-COMPATIBILITY.md @@ -138,7 +138,8 @@ checkout, the one host-neutral setup entrypoint is: ~/.gstack/bin/gstack doctor --json ``` -`./setup` resolves a symlinked checkout, installs dependencies only when absent, +`./setup` resolves a symlinked checkout, reinstalls the frozen production +dependency set on every run, builds missing allowlisted capabilities through the runtime-only build target, validates and hashes every staged file, smoke-tests the CLI, atomically activates the version, and writes stable POSIX and Windows launchers under @@ -146,15 +147,18 @@ activates the version, and writes stable POSIX and Windows launchers under Windows and includes the CoreDevice/iOS bundle only on Darwin. Add the bin directory to `PATH` if the short `gstack` command is desired. -Twenty-one focused installer tests pass with 307 assertions. They cover +Twenty-four focused installer tests pass with 336 assertions. They cover manifests, paths with spaces, source-root symlinks, internal-link/path-escape rejection, failed build/ -validation/smoke rollback, interrupted-pointer recovery, stable POSIX/Windows -launchers, runtime-only builder selection, managed uninstall, and the -host-neutral wrapper. +validation/smoke rollback including native-load rollback smoke, +interrupted-pointer recovery, stable POSIX/Windows launchers, runtime-only +builder selection, deterministic exact Sharp/ngrok platform closure, managed +uninstall, and the host-neutral wrapper. -The current managed-bundle audit records 107 components, 1,830 files, -459,056,031 bytes, and 50 launchers. Setup installs frozen production-only +The deterministic clean macOS arm64 managed-bundle audit records 110 +components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. This +is a platform-specific bundle measurement, not a universal byte count; +platform-native package payloads differ. Setup installs frozen production-only dependencies and excludes the development-only Claude Agent SDK. The Sharp/ngrok closure is included. The Hugging Face sidecar is excluded and its package is development-only, so setup installs neither its inference runtime @@ -162,10 +166,10 @@ nor model weights and reports the L4 capability unavailable. | Platform | Source-level target | Candidate evidence | |---|---|---| -| macOS | Node runtime + local browser + physical iOS where applicable | Runtime installer 21/307 and current bundle audit pass. The uninterrupted broad singleton run is green at 6,234 pass / 226 expected skips / 0 fail across 383 files. The signed-device gate remains pending. | -| Linux | Node runtime + local browser | Declared Dev Container GStack 2 suite passed at 130/0 with 1,070 assertions across 15 files. A clean Linux arm64 container used production-only install with the development SDK and model runtime absent, passed a local-browser journey and Sharp full-page screenshot, and uninstalled while preserving state. Native-host broad Linux remains pending. | -| Native Windows | Node runtime; curated free tests; browser fallback where retained | **Blocked:** native Windows CI pending. The local Windows-safe singleton lane is green at 2,813 pass / 57 expected skips / 0 fail across 213 files, but it is not native evidence. | -| Dev Container | Pure skills and optional runtime; browser only when container supports it | Declared image builds; generated freshness and the 130-test GStack 2 suite pass, as does the clean runtime install/browser smoke. Full broad Linux coverage remains pending. | +| macOS | Node runtime + local browser + physical iOS where applicable | Runtime installer 24/336 and deterministic clean macOS arm64 bundle audit pass. The uninterrupted broad singleton run is green at 6,240 pass / 226 expected skips / 0 fail across 383 files. The signed-device gate remains pending. | +| Linux | Node runtime + local browser | Declared Dev Container GStack 2 suite passed at 136/0 with 1,127 assertions across 15 files; log: `/tmp/gstack2-devcontainer-gate-candidate-final4.log`. The one-assertion difference from the focused macOS result is the platform-conditional read-only-mode check. A clean Linux arm64 container used production-only install with the development SDK and model runtime absent, passed a local-browser journey and Sharp full-page screenshot, and uninstalled while preserving state. Native-host broad Linux remains pending. | +| Native Windows | Node runtime; curated free tests; browser fallback where retained | **Blocked:** native Windows CI pending. The local Windows-safe singleton lane is green at 2,815 pass / 57 expected skips / 0 fail across 213 files, but it is not native evidence. | +| Dev Container | Pure skills and optional runtime; browser only when container supports it | Declared image builds; generated freshness and the 136-test GStack 2 suite pass, as does the clean runtime install/browser smoke. Full broad Linux coverage remains pending. | The six portable skills remain useful when runtime installation fails. A runtime failure must not remove or corrupt their standard-installer placement. diff --git a/docs/gstack-2/PRIVACY.md b/docs/gstack-2/PRIVACY.md index cc77efbfb..61e53cdd8 100644 --- a/docs/gstack-2/PRIVACY.md +++ b/docs/gstack-2/PRIVACY.md @@ -103,8 +103,10 @@ support the claim and must follow the source system's retention policy. ## Models, images, and documents -The current managed-bundle audit records 107 components, 1,830 files, -459,056,031 bytes, and 50 launchers. Setup installs frozen production-only +The deterministic clean macOS arm64 managed-bundle audit records 110 +components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. This +is a platform-specific bundle measurement, not a universal byte count; +platform-native package payloads differ. Setup installs frozen production-only dependencies. The Sharp/ngrok closure is included; the development-only Claude Agent SDK is excluded. The Hugging Face sidecar is excluded and its package is development-only, so setup installs neither its inference runtime nor model diff --git a/docs/gstack-2/STATUS.md b/docs/gstack-2/STATUS.md index de411045f..ba370069d 100644 --- a/docs/gstack-2/STATUS.md +++ b/docs/gstack-2/STATUS.md @@ -48,15 +48,18 @@ status. three retained Claude Haiku live samples are classified `REGRESSION`; they are preserved as noisy supplemental evidence, never cherry-picked as a primary gate or represented as green. -- [x] The focused GStack 2 suite is green: 130 pass / 0 fail, 1,071 - assertions across 15 files. This focused surface does not substitute for the - broad or native-platform gates. +- [x] The focused macOS GStack 2 suite is green: 136 pass / 0 fail, 1,128 + assertions across 15 files. Log: `/tmp/gstack2-test-command-candidate-final2.log`. + This focused surface does not substitute for the broad or native-platform + gates. - [x] Optional host-neutral runtime implemented with canonical paths, repo/worktree state identity, locks, atomic writes, effect claims, doctor/config/state/cleanup, migrations, upgrade/rollback, and uninstall. -- [x] Managed runtime installer coverage is green at 21 pass / 0 fail and 307 - assertions. The current managed bundle audit records 107 components, 1,830 - files, 459,056,031 bytes, and 50 launchers. Setup installs frozen +- [x] Managed runtime installer coverage is green at 24 pass / 0 fail and 336 + assertions. The deterministic clean macOS arm64 managed-bundle audit records + 110 components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. + This is a platform-specific bundle measurement, not a universal byte count; + platform-native package payloads differ. Setup installs frozen production-only dependencies; the development-only Claude Agent SDK is excluded. The Sharp/ngrok closure is included. The Hugging Face sidecar is excluded and its package is development-only, so setup installs neither its @@ -103,10 +106,10 @@ status. screenshot, status, and stop cleanup. The stop acknowledgement regression has a 2 pass / 0 fail focused test. - [x] The uninterrupted macOS broad suite is green under singleton isolation: - 6,234 pass / 226 expected skips / 0 fail and 25,392 assertions across all + 6,240 pass / 226 expected skips / 0 fail and 25,450 assertions across all 383 files. This includes the complete local-browser suite. The local - Windows-safe singleton lane is also green at 2,813 pass / 57 expected skips - / 0 fail and 8,562 assertions across all 213 curated files; it is not native + Windows-safe singleton lane is also green at 2,815 pass / 57 expected skips + / 0 fail and 8,586 assertions across all 213 curated files; it is not native Windows evidence. - [x] Architecture, privacy, Context.dev, host compatibility, upgrade/rollback, migration, and governance documentation added. @@ -120,8 +123,8 @@ status. - [ ] Prove runtime-absent judgment behavior through an actual host invocation. Runtime install failure and the real default capability lifecycle are covered. - [ ] Complete native Linux and Windows matrices. The uninterrupted macOS - broad singleton run is green at 6,234/226/0 across 383 files, and the local - Windows-safe singleton lane is green at 2,813/57/0 across 213 files. Neither + broad singleton run is green at 6,240/226/0 across 383 files, and the local + Windows-safe singleton lane is green at 2,815/57/0 across 213 files. Neither substitutes for native execution on its named platform. - [ ] Integrate the passing complete local-browser suite and live journey into final cancellation/leak evidence. The production dependency and managed @@ -158,7 +161,7 @@ status. | Host matrix | [HOST-COMPATIBILITY.md](./HOST-COMPATIBILITY.md) | 470/470 checks; six hosts Verified at installer layer; live v3/UI launch pending | | Privacy boundary | [PRIVACY.md](./PRIVACY.md) | Implemented contract; full retained-tool egress audit pending | | Physical iOS | [IOS-PHYSICAL-DEVICE.md](./IOS-PHYSICAL-DEVICE.md) | Preflight 9 pass / 1 deploy skip / 29 assertions; signing blocked; no app/pass artifact | -| Upgrade/recovery | [UPGRADE-AND-ROLLBACK.md](./UPGRADE-AND-ROLLBACK.md) | Runtime installer 21 pass / 307 assertions; current bundle audit recorded | +| Upgrade/recovery | [UPGRADE-AND-ROLLBACK.md](./UPGRADE-AND-ROLLBACK.md) | Runtime installer 24 pass / 336 assertions; deterministic clean macOS arm64 bundle audit recorded | ## Interpretation rules diff --git a/docs/gstack-2/TEST-EVIDENCE.md b/docs/gstack-2/TEST-EVIDENCE.md index 04bbef5d2..5df420e5a 100644 --- a/docs/gstack-2/TEST-EVIDENCE.md +++ b/docs/gstack-2/TEST-EVIDENCE.md @@ -9,7 +9,7 @@ pass from deterministic, offline, or filesystem-only evidence. | Command / probe | Observed result | What it proves / does not prove | |---|---|---| -| Focused `bun test test/gstack2-*.test.ts` candidate run | **Exit 0: 130 pass / 0 fail**, 1,071 assertions across 15 files. Log: `/tmp/gstack2-direct-release.log`. | The focused GStack 2 routing/runtime/privacy/installer/upgrade and deterministic parity surface is green. It is not the broad repository or native-platform gate. | +| Focused macOS `bun test test/gstack2-*.test.ts` candidate run | **Exit 0: 136 pass / 0 fail**, 1,128 assertions across 15 files. Log: `/tmp/gstack2-test-command-candidate-final2.log`. | The focused GStack 2 routing/runtime/privacy/installer/upgrade and deterministic parity surface is green. It is not the broad repository or native-platform gate. | | `bun test --timeout 30000 test/gstack2-skills.test.ts test/gstack2-skills-routing.test.ts` after regeneration | **Exit 0: 3 pass / 0 fail**, 81 assertions. | The pinned corpus/parity test and both 25-scenario structured-routing tests are green. This remains structural/fixture evidence, not specialist live execution. | | `bun run scripts/gstack2/run-parity.ts`, 2026-07-17 rerun | **Exit 0: 4,681 checks passed**; 55 modules, 16 sections, 25 scenarios, 16 regressions, 78 assets. | Current source/render/provenance/contract/asset/fixture parity is green. It is deterministic parity, not live-host behavior. | | Earlier regenerated structural parity checkpoint | **Exit 0: 2,403 checks passed** with the then-current 55/16/25/16/45 inventory. | Historical candidate checkpoint before later thin-prelude and asset coverage; superseded by the current 4,681-check rerun. | @@ -27,18 +27,18 @@ pass from deterministic, offline, or filesystem-only evidence. | Context.dev contract (`gstack2-runtime-context.test.ts`) | **22 pass / 0 fail**, 139 assertions. | Persists explicit host/local-browser/none choices without consent, rejects private/credential URLs and request material plus private DNS, proves zero lookup/fetch before mode+consent, validates documented endpoint paths and exact failure taxonomy, and makes search typed unsupported without network. | | `gstack context smoke` | **Not run:** `CONTEXT_DEV_API_KEY` is not configured and no verified account key was available. | Live provider behavior, account verification, and actual credit metadata remain unverified. | | Standard installer matrix | **PASS: 470/470 checks**, 16 install cases, two removal cases, `skills` CLI 1.5.19. | Project/global installs pass for six hosts, selected-skill and opt-in compatibility-alias cases, copies, and hashes. This remains installer/filesystem evidence. Committed artifact: [`evals/installation/install-matrix.json`](../../evals/installation/install-matrix.json). | -| `bun test test/gstack2-runtime-install.test.ts` | **Exit 0: 21 pass / 0 fail**, 307 assertions. | Managed allowlist, hashes, spaces, source/internal-link rejection, production-only frozen dependencies, capability closure, rollback/recovery, stable launchers, wrapper neutrality, and state-preserving uninstall pass. | -| Current managed runtime bundle audit | **107 components, 1,830 files, 459,056,031 bytes, 50 launchers.** | Setup includes the Sharp/ngrok closure and excludes the development-only Claude Agent SDK. The Hugging Face sidecar is outside the bundle and its package is development-only, so production setup installs neither its inference runtime nor model weights; the L4 capability reports unavailable. The standard skill installer remains Markdown-only. | -| Declared Dev Container plus `bun run test:gstack2` inside it | **Exit 0: 130 pass / 0 fail, 1,070 assertions across 15 files.** Log: `/tmp/gstack2-devcontainer-gate-release.log`. | Generated freshness, canonical-skill routing, parity, state, privacy, installer, recovery, and runtime behavior are green in the declared Linux container. The one-assertion difference from macOS is the platform-conditional read-only-mode check. This is not a native-host broad Linux run. | +| `bun test test/gstack2-runtime-install.test.ts` | **Exit 0: 24 pass / 0 fail**, 336 assertions. | Managed allowlist, hashes, spaces, source/internal-link rejection, production-only frozen dependencies, deterministic exact Sharp/ngrok platform closure, native-load rollback smoke, rollback/recovery, stable launchers, wrapper neutrality, and state-preserving uninstall pass. | +| Deterministic clean macOS arm64 managed runtime bundle audit | **110 components, 1,829 files, 450,044,315 bytes, 50 capability launchers.** | This is a platform-specific bundle measurement, not a universal byte count; platform-native package payloads differ. Setup includes the Sharp/ngrok closure and excludes the development-only Claude Agent SDK. The Hugging Face sidecar is outside the bundle and its package is development-only, so production setup installs neither its inference runtime nor model weights; the L4 capability reports unavailable. The standard skill installer remains Markdown-only. Committed artifact: [`evals/runtime-bundle/darwin-arm64.json`](../../evals/runtime-bundle/darwin-arm64.json); reproduce with `bun run scripts/gstack2/audit-runtime-bundle.ts --output evals/runtime-bundle/darwin-arm64.json`. | +| Declared Linux Dev Container plus `bun run test:gstack2` inside it | **Exit 0: 136 pass / 0 fail, 1,127 assertions across 15 files.** Log: `/tmp/gstack2-devcontainer-gate-candidate-final4.log`. | Generated freshness, canonical-skill routing, parity, state, privacy, installer, recovery, and runtime behavior are green in the declared Linux container. The one-assertion difference from macOS is the platform-conditional read-only-mode check. This is not a native-host broad Linux run. | | `scripts/gstack2/runtime-install-smoke.sh` in the clean Linux arm64 container | **Pass:** production-only frozen dependencies installed with the development Agent SDK and Hugging Face/ONNX runtime absent; the managed Anthropic SDK, Sharp, and ngrok imports passed; prebuilt capabilities rebuilt; setup/doctor/version/design/PDF passed; a local-browser journey and Sharp full-page screenshot passed; uninstall preserved state. | Proves a source copy with spaces can build and complete the managed runtime lifecycle without Git history, an executable local-model stack, or Darwin-only iOS artifacts. It is not native Windows evidence. | | Runtime lifecycle and external-effect matrix | **Pass:** real filesystem/subprocess tests cover clean install/uninstall, paths with spaces, source symlinks and internal-link rejection, macOS read-only reporting, interrupted-pointer rollback, crash-journal repair, last-known-good launcher recovery, and an actual local Git push that executes at most once across resume. | Closes the named local filesystem/recovery/idempotency gates. The aggregate live cancellation/leak gate remains open. | | `bun test design/test` | **101 pass / 0 fail**, 381 assertions. | The retained design suite is green and is also included in the later uninterrupted broad pass. | | `bun test make-pdf/test` plus combined-fixture render | **189 pass / 0 fail, 398 assertions;** four PDF pages rendered to PNG and visually inspected with no detected layout defect. | Retained strict PDF tests and a live internal render are green on this macOS host. Cross-platform visual equivalence remains a separate platform claim. | | Diagram suites | **51 pass / 0 fail / 1 skip**, 120 assertions; the opt-in paid lane recorded two skips. | Offline Mermaid/SVG/PNG/Excalidraw coverage is green. Skipped paid-provider cases are not live evidence. | | Isolated local-browser journey plus stop regression | Navigation, snapshot, screenshot, and status passed; stop returned success and left no observed process leak. `browse/test/stop-ack-before-shutdown.test.ts` is **2 pass / 0 fail**. | Provides one real local Chromium journey and a focused regression for acknowledging stop/restart before delayed shutdown. The complete browser suite is included in the later broad pass. | -| `bun test` through the strict singleton runner | **Exit 0: 6,234 pass / 226 expected skips / 0 fail**, 25,392 assertions; 383/383 shard headers and terminal single-file summaries. Log: `/tmp/gstack2-full-singleton-release2.log`. | One uninterrupted macOS broad pass after the browser lifecycle and retry-harness fixes. Expected skips are provider, credential, Poppler-environment, paid, or model-sidecar gates declared by their tests; external/live evidence remains separate. | -| Local `bun run test:windows` through singleton shards | **Exit 0: 2,813 pass / 57 expected skips / 0 fail**, 8,562 assertions; 213/213 shard headers and terminal single-file summaries. Log: `/tmp/gstack2-windows-singleton-release2.log`. | The curated Windows-safe subset is locally green under singleton isolation. This is not native Windows execution; native Windows CI remains blocked. | -| Forbidden production-scope audit | Production dependencies and the 107-component managed bundle contain no cloud-browser provider, Hugging Face/ONNX inference runtime, model weights, or alternative physical-iOS backend. Transformers remains development-only for retained tests; CoreDevice/`devicectl` is the sole physical-iOS path. | Deterministic dependency/bundle/backend evidence. It does not substitute for native-platform, signed-device, or live-provider behavior. | +| `bun test` through the strict singleton runner | **Exit 0: 6,240 pass / 226 expected skips / 0 fail**, 25,450 assertions; 383/383 shard headers and terminal single-file summaries. Log: `/tmp/gstack2-full-singleton-candidate-final.log`. | One uninterrupted macOS broad pass after the browser lifecycle and retry-harness fixes. Expected skips are provider, credential, Poppler-environment, paid, or model-sidecar gates declared by their tests; external/live evidence remains separate. | +| Local `bun run test:windows` through singleton shards | **Exit 0: 2,815 pass / 57 expected skips / 0 fail**, 8,586 assertions; 213/213 shard headers and terminal single-file summaries. Log: `/tmp/gstack2-windows-singleton-candidate-final2.log`. | The curated Windows-safe subset is locally green under singleton isolation. This is not native Windows execution; native Windows CI remains blocked. | +| Forbidden production-scope audit | Production dependencies and the 110-component managed bundle contain no cloud-browser provider, Hugging Face/ONNX inference runtime, model weights, or alternative physical-iOS backend. Transformers remains development-only for retained tests; CoreDevice/`devicectl` is the sole physical-iOS path. | Deterministic dependency/bundle/backend evidence. It does not substitute for native-platform, signed-device, or live-provider behavior. | ### Required final command ledger diff --git a/docs/gstack-2/UPGRADE-AND-ROLLBACK.md b/docs/gstack-2/UPGRADE-AND-ROLLBACK.md index 18f727b9d..63bd928e0 100644 --- a/docs/gstack-2/UPGRADE-AND-ROLLBACK.md +++ b/docs/gstack-2/UPGRADE-AND-ROLLBACK.md @@ -69,17 +69,21 @@ written under `$GSTACK_HOME/bin` (default `~/.gstack/bin`), alongside `runtime-install.json`, which distinguishes managed paths from preserved config, secrets, projects, and plans. -Twenty-one focused installer tests pass with 307 assertions. They cover paths +Twenty-four focused installer tests pass with 336 assertions. They cover paths with spaces, a symlinked source root, internal-link rejection, runtime-only -builder selection, production dependency closure, failed build/validation/ -smoke rollback, interrupted recovery, launchers, manifests, wrapper neutrality, -and state-preserving uninstall. +builder selection, deterministic exact Sharp/ngrok platform closure, failed +build/validation/smoke rollback including native-load rollback smoke, +interrupted recovery, launchers, manifests, wrapper neutrality, and +state-preserving uninstall. -The current managed-bundle audit records 107 components, 1,830 files, -459,056,031 bytes, and 50 launchers. The Sharp/ngrok closure is included. The -development-only Claude Agent SDK and Hugging Face sidecar are excluded; the -Hugging Face package is development-only. Setup therefore installs neither its -inference runtime nor model weights and reports the L4 capability unavailable. +The deterministic clean macOS arm64 managed-bundle audit records 110 +components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. This +is a platform-specific bundle measurement, not a universal byte count; +platform-native package payloads differ. The Sharp/ngrok closure is included. +The development-only Claude Agent SDK and Hugging Face sidecar are excluded; +the Hugging Face package is development-only. Setup therefore installs neither +its inference runtime nor model weights and reports the L4 capability +unavailable. A separate clean Linux arm64 container smoke copied the source through a path with spaces, installed only frozen production dependencies with the development @@ -189,9 +193,11 @@ newer-schema refusal, and non-mutating cleanup preview. The standard installer matrix separately passed 470/470 checks across six hosts, 16 installs, scopes, selections, and two removals; its artifact is [`evals/installation/install-matrix.json`](../../evals/installation/install-matrix.json). -The runtime installer is green at 21 pass / 0 fail and 307 assertions, and the -current 107-component bundle audit is recorded. The clean Linux Dev Container -install/uninstall smoke passed. Interrupted network acquisition/stage at OS -level, a passing live v3 host run, actual host UI execution, native-host Linux, -and native Windows runs remain gates. See +The runtime installer is green at 24 pass / 0 fail and 336 assertions, and the +deterministic clean macOS arm64 110-component bundle audit is recorded. Its +byte count is platform-specific because platform-native package payloads +differ. The clean Linux Dev Container install/uninstall smoke passed. +Interrupted network acquisition/stage at OS level, a passing live v3 host run, +actual host UI execution, native-host Linux, and native Windows runs remain +gates. See [TEST-EVIDENCE.md](./TEST-EVIDENCE.md). diff --git a/evals/runtime-bundle/darwin-arm64.json b/evals/runtime-bundle/darwin-arm64.json new file mode 100644 index 000000000..c6b7abf8c --- /dev/null +++ b/evals/runtime-bundle/darwin-arm64.json @@ -0,0 +1,23 @@ +{ + "schemaVersion": 1, + "platform": "darwin", + "arch": "arm64", + "sourceBundleVersion": "1.60.1.0", + "generatedAt": "2026-07-17T18:55:46.252Z", + "sourceGitCommit": "20d2840bd3f0d5b40f0b88050713eceb97f71cbb", + "sourceGitDirty": true, + "components": 110, + "files": 1829, + "bytes": 450044315, + "capabilityLaunchers": 50, + "nativeComponents": [ + "node_modules/@img/colour", + "node_modules/@img/sharp-darwin-arm64", + "node_modules/@img/sharp-libvips-darwin-arm64", + "node_modules/@ngrok/ngrok-darwin-universal", + "node_modules/@ngrok/ngrok" + ], + "forbiddenComponents": [], + "bundleManifestSha256": "8afc56f329a3b1fc3d6258e5e981cd0387a67d2c890ce761be28502bff23c341", + "reproductionCommand": "bun run scripts/gstack2/audit-runtime-bundle.ts --output evals/runtime-bundle/darwin-arm64.json" +} diff --git a/ios-qa/scripts/physical-device-smoke.ts b/ios-qa/scripts/physical-device-smoke.ts index 07f0bc0fc..2e58a0b13 100644 --- a/ios-qa/scripts/physical-device-smoke.ts +++ b/ios-qa/scripts/physical-device-smoke.ts @@ -151,6 +151,10 @@ interface JsonCommandResult { command: CommandResult; } +type ParsedJson = + | { ok: true; payload: unknown } + | { ok: false; detail: string }; + interface ApiResponse { status: number; body: Record; @@ -283,6 +287,14 @@ function commandDetail(result: CommandResult, lines = 80): string { return combined.split('\n').slice(-lines).join('\n'); } +function parseJson(raw: string): ParsedJson { + try { + return { ok: true, payload: JSON.parse(raw) }; + } catch (error) { + return { ok: false, detail: error instanceof Error ? error.message : String(error) }; + } +} + function runJsonDevicectl(args: string[], phase: string): JsonCommandResult { const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-devicectl-')); const output = join(dir, 'result.json'); @@ -309,18 +321,18 @@ function runJsonDevicectl(args: string[], phase: string): JsonCommandResult { ['Run `sudo xcodebuild -runFirstLaunch`, reconnect the iPhone, and retry.'], ); } - try { - return { payload: JSON.parse(readFileSync(output, 'utf8')), command }; - } catch (error) { + const parsed = parseJson(readFileSync(output, 'utf8')); + if (!parsed.ok) { throw new HarnessError( 'device_discovery_bad_response', 'setup_gate', phase, 'devicectl returned malformed JSON', ['Upgrade or repair Xcode, then verify `xcrun devicectl list devices --json-output ` manually.'], - error instanceof Error ? error.message : String(error), + parsed.detail, ); } + return { payload: parsed.payload, command }; } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1224,6 +1236,7 @@ async function runLiveIteration( ): Promise { let sessionId: string | undefined; let released = false; + let primaryError: unknown; try { const healthBefore = await deviceRequest(tunnel, '/healthz', { token: null }); requireStatus(healthBefore, 200, 'health before tap'); @@ -1423,13 +1436,23 @@ async function runLiveIteration( }, }, }; + } catch (error) { + primaryError = error; + throw error; } finally { if (sessionId && !released) { try { - await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId }); - } catch { - // The aggregate result remains failed. The outer cleanup performs one - // final release attempt before the tunnel keepalive is stopped. + const cleanupRelease = await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId }); + requireStatus(cleanupRelease, 200, 'failed-iteration session cleanup'); + } catch (cleanupError) { + if (primaryError) { + throw new AggregateError( + [primaryError, cleanupError], + 'Live iteration failed and its session cleanup also failed', + { cause: primaryError }, + ); + } + throw cleanupError; } } } @@ -1437,6 +1460,13 @@ async function runLiveIteration( function serializeUnknownError(error: unknown): Record { if (error instanceof HarnessError) return error.toJSON(); + if (error instanceof AggregateError) { + return { + name: error.name, + message: error.message, + errors: [...error.errors].map((nested) => serializeUnknownError(nested)), + }; + } if (error instanceof Error) return { name: error.name, message: error.message }; return { message: String(error) }; } diff --git a/runtime/cli.js b/runtime/cli.js index dfa33be68..deee852de 100644 --- a/runtime/cli.js +++ b/runtime/cli.js @@ -37,8 +37,8 @@ import { import { rollbackUpgrade } from "./upgrade.js"; import { installManagedRuntime, uninstallManagedRuntime } from "./install.js"; import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js"; - -const RUNTIME_VERSION = "2.0.0"; +import { errorWithCode as cliError } from "./errors.js"; +import { RUNTIME_VERSION } from "./index.js"; export async function main(argv = process.argv.slice(2), options = {}) { const env = options.env ?? process.env; @@ -85,7 +85,7 @@ export async function main(argv = process.argv.slice(2), options = {}) { } } catch (error) { const json = args.includes("--json"); - const safeMessage = redactSecrets(error?.message ?? String(error)); + const safeMessage = redactSensitiveText(error?.message ?? String(error)); if (json) { write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`); } else { @@ -619,22 +619,12 @@ function write(stream, value) { stream.write(value); } -function cliError(message, code) { - const error = new Error(message); - error.code = code; - return error; -} - function exitCodeFor(error) { if (error?.code === "USAGE") return 2; if (["CONTEXT_KEY_MISSING", "CONTEXT_KEY_INVALID", "CONTEXT_EMAIL_UNVERIFIED", "CONTEXT_CREDITS_EXHAUSTED", "CONTEXT_RATE_LIMITED", "CONTEXT_TIMEOUT", "CONTEXT_BLOCKED", "CONTEXT_BAD_RESPONSE"].includes(error?.code)) return 3; return 1; } -function redactSecrets(message) { - return redactSensitiveText(message); -} - function usage() { return `gstack ${RUNTIME_VERSION}\n\n` + "Usage:\n" + diff --git a/runtime/context.js b/runtime/context.js index 15aa460e3..1d807c438 100644 --- a/runtime/context.js +++ b/runtime/context.js @@ -20,6 +20,7 @@ const PREFIXED_CREDENTIAL = /(?:^|[^A-Za-z0-9])(?:AIza[0-9A-Za-z_-]{20,}|AKIA[0- // path segment is still scanned independently, avoiding false positives where // a long mixed-case documentation path looked like one credential. const OPAQUE_TOKEN_CANDIDATE = /[A-Za-z0-9._~+=-]{32,}/g; +const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i; const MAX_CREDENTIAL_DECODE_PASSES = 8; const MIN_OPAQUE_TOKEN_ENTROPY = 4.25; @@ -125,6 +126,10 @@ export class ContextError extends Error { this.unsupported = Boolean(options.unsupported); } + static fromCause(code, message, cause, options = {}) { + return new ContextError(code, message, { ...options, cause }); + } + toJSON() { return { name: this.name, @@ -171,7 +176,7 @@ export function assertPublicUrl(input) { try { url = input instanceof URL ? new URL(input.href) : new URL(String(input)); } catch (cause) { - throw new ContextError("CONTEXT_BLOCKED", "Target must be an absolute public HTTP(S) URL", { cause }); + throw ContextError.fromCause("CONTEXT_BLOCKED", "Target must be an absolute public HTTP(S) URL", cause); } if (!["http:", "https:"].includes(url.protocol)) { throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed"); @@ -245,7 +250,7 @@ export async function assertPublicUrlResolved(input, options = {}) { try { records = await lookup(hostname, { all: true, verbatim: true }); } catch (cause) { - throw new ContextError("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", { cause }); + throw ContextError.fromCause("CONTEXT_BLOCKED", "Target hostname could not be resolved publicly", cause); } const list = Array.isArray(records) ? records : [records]; if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) { @@ -440,9 +445,8 @@ export class ContextClient { try { payload = text ? JSON.parse(text) : null; } catch (cause) { - throw new ContextError("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", { + throw ContextError.fromCause("CONTEXT_BAD_RESPONSE", "Context.dev returned malformed JSON", cause, { status: response.status, - cause, secrets: [key], }); } @@ -564,7 +568,7 @@ function validateBaseUrl(input) { try { url = new URL(String(input)); } catch (cause) { - throw new ContextError("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", { cause }); + throw ContextError.fromCause("CONTEXT_BAD_RESPONSE", "Invalid Context.dev API base URL", cause); } if (url.origin !== "https://api.context.dev" || url.username || url.password || !["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) { @@ -580,9 +584,7 @@ export function redactSensitiveText(message, knownSecrets = []) { const raw = String(secret ?? ""); if (raw.length < 8) continue; exactSecrets.add(raw); - try { - exactSecrets.add(encodeURIComponent(raw)); - } catch {} + if (hasWellFormedUtf16(raw)) exactSecrets.add(encodeURIComponent(raw)); } for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]"); safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]"); @@ -644,16 +646,12 @@ function containsCredentialMaterial(value) { function looksOpaqueCredential(candidate) { const token = candidate.replace(/[.,;:!?]+$/, ""); - if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || isUuid(token) || isReadablePublicSlug(token)) return false; + if (token.length < 32 || /^[a-f0-9]{32,}$/i.test(token) || UUID.test(token) || isReadablePublicSlug(token)) return false; const categories = [/[a-z]/, /[A-Z]/, /\d/, /[._~+/=-]/] .reduce((count, pattern) => count + Number(pattern.test(token)), 0); return categories >= 3 && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY; } -function isUuid(value) { - return /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i.test(value); -} - function isReadablePublicSlug(value) { const parts = value.split("-"); if (parts.length < 3) return false; @@ -693,6 +691,20 @@ function isSensitiveFieldName(key) { /(?:credential|password|secret|signature|token)$/.test(normalized); } +function hasWellFormedUtf16(value) { + for (let index = 0; index < value.length; index += 1) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(index + 1); + if (next < 0xdc00 || next > 0xdfff) return false; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + function containsCredentialLabel(value) { let candidate = String(value ?? ""); for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) { @@ -787,12 +799,8 @@ function inIpv4Cidr(value, base, bits) { } function isPublicIpv6(address) { - let value; - try { - value = ipv6BigInt(address); - } catch { - return false; - } + // isPublicIp has already required Node's IPv6 parser to accept the address. + const value = ipv6BigInt(address); if ((value >> 32n) === 0xffffn) { const ipv4 = Number(value & 0xffffffffn); return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`); diff --git a/runtime/errors.js b/runtime/errors.js new file mode 100644 index 000000000..c24a97db4 --- /dev/null +++ b/runtime/errors.js @@ -0,0 +1,6 @@ +/** Create an operational error whose stable code can cross the CLI boundary. */ +export function errorWithCode(message, code, cause) { + const error = cause === undefined ? new Error(message) : new Error(message, { cause }); + error.code = code; + return error; +} diff --git a/runtime/index.js b/runtime/index.js index cbc48b291..7dd98939d 100644 --- a/runtime/index.js +++ b/runtime/index.js @@ -1,3 +1,7 @@ +// Public runtime metadata lives with the public runtime surface so the CLI and +// embedders report one version. +export const RUNTIME_VERSION = "2.0.0"; + export * from "./paths.js"; export * from "./managed-home.js"; export * from "./storage.js"; diff --git a/runtime/install.js b/runtime/install.js index b40c0d25b..4f212b7b7 100644 --- a/runtime/install.js +++ b/runtime/install.js @@ -4,6 +4,7 @@ import process from "node:process"; import { createHash, randomUUID } from "node:crypto"; import { spawn as nodeSpawn } from "node:child_process"; import { fileURLToPath, pathToFileURL } from "node:url"; +import { familySync as detectLibcFamilySync, GLIBC, MUSL } from "detect-libc"; import { resolveGstackHome, resolveRuntimePaths, assertPathInside } from "./paths.js"; import { atomicWriteFile, atomicWriteJson, pathExists, readJson } from "./storage.js"; import { purgeManagedHomeUnlocked, stageUpgradeUnlocked } from "./upgrade.js"; @@ -15,6 +16,8 @@ import { RUNTIME_TRANSACTION_FILE, withRuntimeLifecycleLock, } from "./managed-home.js"; +import { errorWithCode as installError } from "./errors.js"; +import { currentIsoTimestamp as isoNow } from "./time.js"; const INSTALL_SCHEMA_VERSION = 2; @@ -131,6 +134,50 @@ const RUNTIME_HELPER_TARGETS = Object.freeze([...new Set( Object.values(DEFAULT_RUNTIME_HELPERS).map((descriptor) => descriptor.target), )]); +/** + * Resolve only the native packages loaded on this host. Package managers may + * leave optional binaries for several platforms in node_modules; copying an + * entire scope would make the managed bundle depend on that incidental state. + */ +export function runtimeNativePackagePaths(options = {}) { + const platform = options.platform ?? process.platform; + const arch = options.arch ?? process.arch; + const supportedArch = ["x64", "arm64"].includes(arch); + if (!["darwin", "linux", "win32"].includes(platform) || !supportedArch) { + throw new TypeError(`Unsupported managed-runtime platform: ${platform}-${arch}`); + } + + const paths = ["node_modules/@img/colour"]; + if (platform === "darwin") { + paths.push( + `node_modules/@img/sharp-darwin-${arch}`, + `node_modules/@img/sharp-libvips-darwin-${arch}`, + // The ngrok loader tries its universal macOS binary before the + // architecture-specific fallback, so retain that single canonical copy. + "node_modules/@ngrok/ngrok-darwin-universal", + ); + } else if (platform === "win32") { + paths.push( + `node_modules/@img/sharp-win32-${arch}`, + `node_modules/@ngrok/ngrok-win32-${arch}-msvc`, + ); + } else { + const libc = options.libc ?? detectLibcFamilySync(); + if (![GLIBC, MUSL].includes(libc)) { + throw new TypeError(`Unsupported managed-runtime libc: ${String(libc)}`); + } + const sharpPlatform = libc === MUSL ? `linuxmusl-${arch}` : `linux-${arch}`; + const ngrokLibc = libc === MUSL ? "musl" : "gnu"; + paths.push( + `node_modules/@img/sharp-${sharpPlatform}`, + `node_modules/@img/sharp-libvips-${sharpPlatform}`, + `node_modules/@ngrok/ngrok-linux-${arch}-${ngrokLibc}`, + ); + } + paths.push("node_modules/@ngrok/ngrok"); + return Object.freeze(paths); +} + /** * The managed bundle is deliberately narrow. Skills remain installed by a * standards-based Agent Skills installer; this list contains only optional @@ -166,10 +213,9 @@ export const DEFAULT_RUNTIME_BUNDLE = Object.freeze([ // the compiled CLI: Sharp powers full-page screenshot resizing, while // ngrok is an explicit opt-in tunnel for pair-agent (never a cloud browser). entry("node_modules/sharp"), - entry("node_modules/@img"), + ...runtimeNativePackagePaths().map((target) => entry(target)), entry("node_modules/detect-libc"), entry("node_modules/semver"), - entry("node_modules/@ngrok"), entry("node_modules/@anthropic-ai/sdk"), entry(platformBinary("design/dist/design"), "core", true), entry("design/dist/.version", "core"), @@ -516,6 +562,23 @@ export async function smokeRuntimeBundle(directory, options = {}) { if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) { throw installError("Runtime launcher smoke test returned an unexpected response", "INSTALL_SMOKE_FAILED"); } + const nativeImports = []; + for (const packageName of ["sharp", "@ngrok/ngrok"]) { + if (await pathExists(path.join(directory, "node_modules", packageName, "package.json"))) { + nativeImports.push(packageName); + } + } + if (nativeImports.length > 0) { + try { + await run(command, [ + "--input-type=module", + "--eval", + nativeImports.map((packageName) => `await import(${JSON.stringify(packageName)});`).join(" "), + ], { cwd: directory, capture: true }); + } catch (cause) { + throw installError("Runtime native dependency smoke test failed", "INSTALL_SMOKE_FAILED", cause); + } + } } export async function runInstallerCli(argv = process.argv.slice(2), options = {}) { @@ -1250,16 +1313,6 @@ function installerUsage() { "Install the six skills separately with: npx skills add time-attack/gstack\n"; } -function installError(message, code, cause) { - const error = cause === undefined ? new Error(message) : new Error(message, { cause }); - error.code = code; - return error; -} - -function isoNow(now) { - return (now ? now() : new Date()).toISOString(); -} - const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; if (invokedPath === import.meta.url) { process.exitCode = await runInstallerCli(); diff --git a/runtime/managed-home.js b/runtime/managed-home.js index 070933044..f7b7c5df8 100644 --- a/runtime/managed-home.js +++ b/runtime/managed-home.js @@ -4,6 +4,8 @@ import path from "node:path"; import { randomUUID } from "node:crypto"; import { assertPathInside, resolveRuntimePaths } from "./paths.js"; import { atomicWriteFile, atomicWriteJson, readJson, withLock } from "./storage.js"; +import { errorWithCode as managedHomeError } from "./errors.js"; +import { currentIsoTimestamp as isoNow } from "./time.js"; export const MANAGED_HOME_SCHEMA_VERSION = 1; export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json"; @@ -142,7 +144,8 @@ async function inspectRecognizedLegacyHome(home, entries) { try { privacyMap = JSON.parse(privacyText); } catch { - return null; + // Invalid legacy metadata is not sufficient proof that this home is ours. + privacyMap = null; } const hasCanonicalPrivacyEntry = Array.isArray(privacyMap) && privacyMap.some((entry) => entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact", @@ -302,13 +305,3 @@ function isSameOrAncestor(candidate, target) { const relative = path.relative(candidate, target); return relative === "" || relative === "." || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); } - -function managedHomeError(message, code) { - const error = new Error(message); - error.code = code; - return error; -} - -function isoNow(now) { - return (now ? now() : new Date()).toISOString(); -} diff --git a/runtime/state.js b/runtime/state.js index 404928832..d6262b833 100644 --- a/runtime/state.js +++ b/runtime/state.js @@ -4,6 +4,8 @@ import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.j import { projectPaths } from "./paths.js"; import { discoverProjectIdentity } from "./identity.js"; import { RUNTIME_SCHEMA_VERSION } from "./migrations.js"; +import { errorWithCode } from "./errors.js"; +import { currentIsoTimestamp as isoNow } from "./time.js"; const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"]; export const WORKFLOW_STATE_SCHEMA_VERSION = 1; @@ -126,9 +128,7 @@ export async function inspectProject(home, identityOrId) { const paths = projectPaths(home, id); const state = await readJson(paths.state, null); if (!state) { - const error = new Error(`No state found for project ${id}`); - error.code = "STATE_NOT_FOUND"; - throw error; + throw errorWithCode(`No state found for project ${id}`, "STATE_NOT_FOUND"); } assertSupportedState(state, paths.state); return { paths, state }; @@ -143,7 +143,7 @@ export async function inspectRun(home, projectId, runId) { validateRunId(runId); const { paths, state } = await inspectProject(home, projectId); const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null; - if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`); + if (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND"); return { paths, state, @@ -173,9 +173,7 @@ export async function beginRun(home, projectId, command, options = {}) { const now = isoNow(options.now); const { state, result } = await updateWithEvent(paths, async (state) => { if (Object.hasOwn(state.runs, runId)) { - const error = new Error(`Run already exists: ${runId}`); - error.code = "RUN_EXISTS"; - throw error; + throw errorWithCode(`Run already exists: ${runId}`, "RUN_EXISTS"); } const workflow = createWorkflowState(command, options, now); state.runs[runId] = { @@ -206,14 +204,10 @@ export async function resumeRun(home, projectId, runId, options = {}) { if (selected) validateRunId(selected); const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null; if (!run) { - const error = new Error(selected ? `Run not found: ${selected}` : "No resumable run found"); - error.code = "RUN_NOT_FOUND"; - throw error; + throw errorWithCode(selected ? `Run not found: ${selected}` : "No resumable run found", "RUN_NOT_FOUND"); } if (run.status === "completed") { - const error = new Error(`Run is already complete: ${run.id}`); - error.code = "RUN_COMPLETED"; - throw error; + throw errorWithCode(`Run is already complete: ${run.id}`, "RUN_COMPLETED"); } for (const effect of Object.values(run.effects ?? {})) { if (effect.status === "in_progress") { @@ -242,14 +236,14 @@ export async function completeRun(home, projectId, runId, options = {}) { const now = isoNow(options.now); const { state, result } = await updateWithEvent(paths, async (state) => { const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null; - if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`); + if (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND"); const unresolved = Object.values(run.effects ?? {}).filter((effect) => ["ready", "in_progress", "uncertain"].includes(effect.status)); if (unresolved.length && !options.allowUncertain) { - throw codedError("EFFECTS_UNCERTAIN", "Run has unresolved external effects"); + throw errorWithCode("Run has unresolved external effects", "EFFECTS_UNCERTAIN"); } if (run.workflow.pendingApprovalGates.length) { - throw codedError("APPROVAL_GATES_PENDING", "Run has pending approval gates"); + throw errorWithCode("Run has pending approval gates", "APPROVAL_GATES_PENDING"); } run.status = "completed"; run.completedAt = now; @@ -277,10 +271,10 @@ export async function updateRunWorkflow(home, projectId, runId, transition, opti const now = isoNow(options.now); const { state, result } = await updateWithEvent(paths, async (state) => { const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null; - if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`); - if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`); + if (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND"); + if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED"); if (state.activeRunId !== runId) { - throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before updating: ${runId}`); + throw errorWithCode(`Run is not active; resume it before updating: ${runId}`, "RUN_NOT_ACTIVE"); } const changes = applyWorkflowTransition(run.workflow, transition, now); @@ -313,18 +307,18 @@ export async function runExternalEffect(home, projectId, runId, effectKey, execu const now = isoNow(options.now); const claimed = await updateWithEvent(paths, async (state) => { const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null; - if (!run) throw codedError("RUN_NOT_FOUND", `Run not found: ${runId}`); - if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`); + if (!run) throw errorWithCode(`Run not found: ${runId}`, "RUN_NOT_FOUND"); + if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED"); if (state.activeRunId !== runId) { - throw codedError("RUN_NOT_ACTIVE", `Run is not active; resume it before an external effect: ${runId}`); + throw errorWithCode(`Run is not active; resume it before an external effect: ${runId}`, "RUN_NOT_ACTIVE"); } if (run.workflow.pendingApprovalGates.length) { - throw codedError("APPROVAL_REQUIRED", "Resolve pending approval gates before external effects"); + throw errorWithCode("Resolve pending approval gates before external effects", "APPROVAL_REQUIRED"); } if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) { - throw codedError( - "MUTATION_NOT_AUTHORIZED", + throw errorWithCode( `Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`, + "MUTATION_NOT_AUTHORIZED", ); } run.effects = normalizeRecord(run.effects, validateEffectKey, "effects"); @@ -385,9 +379,9 @@ export async function completeExternalEffect(home, projectId, runId, effectKey, const now = isoNow(options.now); return updateWithEvent(paths, async (state) => { const effect = ownedEffect(state, runId, effectKey); - if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`); + if (!effect) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND"); if (effect.status !== "in_progress") { - throw codedError("EFFECT_NOT_IN_PROGRESS", `Effect is not in progress: ${effectKey}`); + throw errorWithCode(`Effect is not in progress: ${effectKey}`, "EFFECT_NOT_IN_PROGRESS"); } effect.status = "completed"; effect.completedAt = now; @@ -406,9 +400,9 @@ export async function markEffectNotApplied(home, projectId, runId, effectKey, op const paths = projectPaths(home, projectId); return updateWithEvent(paths, async (state) => { const effect = ownedEffect(state, runId, effectKey); - if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`); + if (!effect) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND"); if (effect.status !== "uncertain") { - throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as not applied: ${effectKey}`); + throw errorWithCode(`Only an uncertain effect can be reconciled as not applied: ${effectKey}`, "EFFECT_NOT_UNCERTAIN"); } effect.status = "ready"; effect.reconciledAt = isoNow(options.now); @@ -430,9 +424,9 @@ export async function markEffectApplied(home, projectId, runId, effectKey, evide const now = isoNow(options.now); return updateWithEvent(paths, async (state) => { const effect = ownedEffect(state, runId, effectKey); - if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`); + if (!effect) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND"); if (effect.status !== "uncertain") { - throw codedError("EFFECT_NOT_UNCERTAIN", `Only an uncertain effect can be reconciled as applied: ${effectKey}`); + throw errorWithCode(`Only an uncertain effect can be reconciled as applied: ${effectKey}`, "EFFECT_NOT_UNCERTAIN"); } effect.status = "completed"; effect.completedAt = now; @@ -460,7 +454,7 @@ async function markEffectUncertain(home, projectId, runId, effectKey, cause, opt const now = isoNow(options.now); return updateWithEvent(paths, async (state) => { const effect = ownedEffect(state, runId, effectKey); - if (!effect) throw codedError("EFFECT_NOT_FOUND", `Effect not found: ${effectKey}`); + if (!effect) throw errorWithCode(`Effect not found: ${effectKey}`, "EFFECT_NOT_FOUND"); effect.status = "uncertain"; effect.uncertainAt = now; effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500); @@ -507,7 +501,7 @@ function assertSupportedState(state, file) { throw new Error(`Invalid state schema in ${file}`); } if (state.schemaVersion > RUNTIME_SCHEMA_VERSION) { - throw codedError("STATE_NEWER_THAN_RUNTIME", `State schema is newer than this runtime: ${file}`); + throw errorWithCode(`State schema is newer than this runtime: ${file}`, "STATE_NEWER_THAN_RUNTIME"); } if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`); state.runs = normalizeRecord(state.runs, validateRunId, "runs"); @@ -607,7 +601,7 @@ function createWorkflowState(command, options, now) { function validateWorkflowState(workflow, label) { assertPlainRecord(workflow, label, WORKFLOW_KEYS); if (workflow.schemaVersion !== WORKFLOW_STATE_SCHEMA_VERSION) { - throw codedError("WORKFLOW_SCHEMA_UNSUPPORTED", `Unsupported workflow schema in ${label}`); + throw errorWithCode(`Unsupported workflow schema in ${label}`, "WORKFLOW_SCHEMA_UNSUPPORTED"); } validateOptionalPointer(workflow.currentPlanPointer); validateGoal(workflow.originalGoal, "original goal"); @@ -705,7 +699,7 @@ function applyWorkflowTransition(workflow, transition, now) { changes.push("detourStack.push"); } if (transition.popDetour === true) { - if (workflow.detourStack.length === 0) throw codedError("DETOUR_STACK_EMPTY", "No detour is available to pop"); + if (workflow.detourStack.length === 0) throw errorWithCode("No detour is available to pop", "DETOUR_STACK_EMPTY"); workflow.detourStack.pop(); changes.push("detourStack.pop"); } @@ -732,7 +726,7 @@ function applyWorkflowTransition(workflow, transition, now) { if (Object.hasOwn(transition, "addApprovalGate")) { const input = transition.addApprovalGate; if (workflow.pendingApprovalGates.some((gate) => gate.id === input.id)) { - throw codedError("APPROVAL_GATE_EXISTS", `Approval gate already exists: ${input.id}`); + throw errorWithCode(`Approval gate already exists: ${input.id}`, "APPROVAL_GATE_EXISTS"); } workflow.pendingApprovalGates.push({ id: input.id, @@ -744,7 +738,7 @@ function applyWorkflowTransition(workflow, transition, now) { if (Object.hasOwn(transition, "resolveApprovalGate")) { const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate); if (index === -1) { - throw codedError("APPROVAL_GATE_NOT_FOUND", `Approval gate not found: ${transition.resolveApprovalGate}`); + throw errorWithCode(`Approval gate not found: ${transition.resolveApprovalGate}`, "APPROVAL_GATE_NOT_FOUND"); } workflow.pendingApprovalGates.splice(index, 1); changes.push("pendingApprovalGates.resolve"); @@ -950,10 +944,6 @@ function stableIdempotencyKey(projectId, runId, effectKey) { return `gstack_${digest}`; } -function isoNow(now) { - return (now ? now() : new Date()).toISOString(); -} - function jsonSafe(value) { if (value === undefined) return null; try { @@ -962,9 +952,3 @@ function jsonSafe(value) { return String(value); } } - -function codedError(code, message) { - const error = new Error(message); - error.code = code; - return error; -} diff --git a/runtime/storage.js b/runtime/storage.js index 17b4392f3..5a5c2585e 100644 --- a/runtime/storage.js +++ b/runtime/storage.js @@ -119,12 +119,10 @@ export async function acquireLock(lockPath, options = {}) { if (released) return; released = true; clearInterval(heartbeat); - try { - const current = await readJson(path.join(lockPath, "owner.json"), null); - if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true }); - } catch { - // Locks are leases. A stale-lock reaper may already have removed it. - } + // Locks are leases. A stale-lock reaper may already have removed it, + // which readJson represents as null; other failures remain actionable. + const current = await readJson(path.join(lockPath, "owner.json"), null); + if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true }); }; } catch (error) { if (error?.code !== "EEXIST") throw error; @@ -168,10 +166,27 @@ function processIsAlive(pid) { export async function withLock(lockPath, callback, options = {}) { const release = await acquireLock(lockPath, options); + let callbackError; + let callbackFailed = false; try { return await callback(); + } catch (error) { + callbackFailed = true; + callbackError = error; + throw error; } finally { - await release(); + try { + await release(); + } catch (releaseError) { + if (callbackFailed) { + throw new AggregateError( + [callbackError, releaseError], + `Locked operation and lock release both failed: ${lockPath}`, + { cause: callbackError }, + ); + } + throw releaseError; + } } } diff --git a/runtime/time.js b/runtime/time.js new file mode 100644 index 000000000..8ffdf9da4 --- /dev/null +++ b/runtime/time.js @@ -0,0 +1,5 @@ +/** Resolve an injectable clock and serialize it for durable runtime state. */ +export function currentIsoTimestamp(now) { + const current = now ? now() : new Date(); + return current.toISOString(); +} diff --git a/runtime/upgrade.js b/runtime/upgrade.js index c70fd959b..931a86171 100644 --- a/runtime/upgrade.js +++ b/runtime/upgrade.js @@ -9,6 +9,8 @@ import { recoverRuntimeTransactionUnlocked, withRuntimeLifecycleLock, } from "./managed-home.js"; +import { errorWithCode as upgradeError } from "./errors.js"; +import { currentIsoTimestamp as isoNow } from "./time.js"; export async function stageUpgrade(options) { const home = path.resolve(options.home); @@ -133,6 +135,13 @@ export async function stageUpgradeUnlocked(options) { } catch (error) { rollbackErrors.push(error); } + if (staged) { + try { + await fs.rm(destination, { recursive: true, force: true }); + } catch (error) { + rollbackErrors.push(error); + } + } const error = upgradeError(`Upgrade ${version} failed health checks and was rolled back`, "UPGRADE_ROLLED_BACK", cause); if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0]; else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete"); @@ -335,13 +344,3 @@ async function isRealDirectory(directory) { function emptyPointer() { return { schemaVersion: 2, status: "inactive", current: null, lastKnownGood: null }; } - -function isoNow(now) { - return (now ? now() : new Date()).toISOString(); -} - -function upgradeError(message, code, cause) { - const error = cause === undefined ? new Error(message) : new Error(message, { cause }); - error.code = code; - return error; -} diff --git a/scripts/gstack2/audit-runtime-bundle.ts b/scripts/gstack2/audit-runtime-bundle.ts new file mode 100644 index 000000000..b7566e57a --- /dev/null +++ b/scripts/gstack2/audit-runtime-bundle.ts @@ -0,0 +1,97 @@ +#!/usr/bin/env bun +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { + DEFAULT_CAPABILITY_LAUNCHERS, + installManagedRuntime, + runtimeNativePackagePaths, +} from '../../runtime/install.js'; +import { atomicWriteJson } from '../../runtime/storage.js'; + +const REPO_ROOT = path.resolve(import.meta.dir, '../..'); +const FORBIDDEN_COMPONENT = /browserbase|browserless|huggingface|onnxruntime|claude-agent-sdk/i; + +export interface RuntimeBundleAudit { + schemaVersion: 1; + platform: string; + arch: string; + sourceBundleVersion: string; + generatedAt: string; + sourceGitCommit: string; + sourceGitDirty: boolean; + components: number; + files: number; + bytes: number; + capabilityLaunchers: number; + nativeComponents: string[]; + forbiddenComponents: string[]; + bundleManifestSha256: string; + reproductionCommand: string; +} + +export function summarizeRuntimeBundle( + manifest: Record & { + version: string; + components: string[]; + files: Array<{ path: string; size: number; mode: number; sha256: string }>; + }, +): RuntimeBundleAudit { + const nativeComponents = runtimeNativePackagePaths(); + const forbiddenComponents = manifest.components.filter((component) => FORBIDDEN_COMPONENT.test(component)); + const digestInput = JSON.stringify(manifest); + return { + schemaVersion: 1, + platform: process.platform, + arch: process.arch, + sourceBundleVersion: manifest.version, + generatedAt: new Date().toISOString(), + sourceGitCommit: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(), + sourceGitDirty: execFileSync( + 'git', + ['status', '--porcelain', '--untracked-files=all'], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ).trim().length > 0, + components: manifest.components.length, + files: manifest.files.length, + bytes: manifest.files.reduce((total, file) => total + file.size, 0), + capabilityLaunchers: Object.keys(DEFAULT_CAPABILITY_LAUNCHERS).length, + nativeComponents: [...nativeComponents], + forbiddenComponents, + bundleManifestSha256: createHash('sha256').update(digestInput).digest('hex'), + reproductionCommand: `bun run scripts/gstack2/audit-runtime-bundle.ts --output evals/runtime-bundle/${process.platform}-${process.arch}.json`, + }; +} + +async function main(argv = process.argv.slice(2)): Promise { + const outputIndex = argv.indexOf('--output'); + if (argv.length !== 0 && (outputIndex !== 0 || argv.length !== 2 || !argv[1])) { + throw new TypeError('Usage: audit-runtime-bundle.ts [--output ]'); + } + const scratch = await fs.mkdtemp(path.join(os.tmpdir(), 'gstack2-runtime-bundle-audit-')); + try { + const result = await installManagedRuntime({ + sourceDir: REPO_ROOT, + home: path.join(scratch, 'home'), + buildMissing: false, + }); + const manifest = JSON.parse(await fs.readFile(path.join(result.path, '.gstack-bundle.json'), 'utf8')); + const audit = summarizeRuntimeBundle(manifest); + if (audit.forbiddenComponents.length > 0) { + throw new Error(`Forbidden production components: ${audit.forbiddenComponents.join(', ')}`); + } + if (outputIndex === 0) { + const output = path.resolve(REPO_ROOT, argv[1]); + await atomicWriteJson(output, audit, { mode: 0o644 }); + } + process.stdout.write(`${JSON.stringify(audit, null, 2)}\n`); + } finally { + await fs.rm(scratch, { recursive: true, force: true }); + } +} + +const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null; +if (invokedPath === import.meta.url) await main(); diff --git a/scripts/gstack2/devcontainer-gate.sh b/scripts/gstack2/devcontainer-gate.sh index 611044c69..3dcba271d 100755 --- a/scripts/gstack2/devcontainer-gate.sh +++ b/scripts/gstack2/devcontainer-gate.sh @@ -1,5 +1,51 @@ #!/usr/bin/env bash set -euo pipefail +umask 077 +SOURCE="$(cd "${1:-$PWD}" && pwd -P)" +ROOT="$(mktemp -d /tmp/gstack2-devcontainer-gate.XXXXXX)" +ROOT="$(cd "$ROOT" && pwd -P)" +REPO="$ROOT/source" + +cleanup() { + rm -rf -- "$ROOT" +} +trap cleanup EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +mkdir -p "$REPO" +tar -C "$SOURCE" \ + --exclude='./.git' \ + --exclude='./node_modules' \ + -cf - . \ + | tar -C "$REPO" -xf - + +# The parity and generated-file checks need the checkout's Git history. Point +# the disposable worktree at the read-only source metadata, while leaving all +# Git commands free to discover fixture repositories normally. +REAL_GIT="${GSTACK_GATE_BASE_GIT:-$(command -v git)}" +if SOURCE_GIT_DIR="$("$REAL_GIT" -c safe.directory="$SOURCE" -C "$SOURCE" rev-parse --absolute-git-dir 2>/dev/null)"; then + printf 'gitdir: %s\n' "$SOURCE_GIT_DIR" > "$REPO/.git" + GIT_WRAPPER_DIR="$ROOT/bin" + mkdir -p "$GIT_WRAPPER_DIR" + export GSTACK_GATE_BASE_GIT="$REAL_GIT" + export GSTACK_GATE_SOURCE="$SOURCE" + export GSTACK_GATE_WORK_TREE="$REPO" + export GIT_OPTIONAL_LOCKS=0 + export PATH="$GIT_WRAPPER_DIR:$PATH" + cat > "$GIT_WRAPPER_DIR/git" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail +exec "$GSTACK_GATE_BASE_GIT" \ + -c safe.directory="$GSTACK_GATE_SOURCE" \ + -c safe.directory="$GSTACK_GATE_WORK_TREE" \ + "$@" +EOF + chmod 700 "$GIT_WRAPPER_DIR/git" +fi + +cd "$REPO" bun install --frozen-lockfile bun run test:gstack2 diff --git a/scripts/gstack2/generate-skill-tree.ts b/scripts/gstack2/generate-skill-tree.ts index b23ce9ac9..0bdd55b78 100644 --- a/scripts/gstack2/generate-skill-tree.ts +++ b/scripts/gstack2/generate-skill-tree.ts @@ -541,10 +541,6 @@ interface RenderedModuleRecord { disposition: string; } -function referencedModules(content: string): string[] { - return [...new Set([...content.matchAll(/references\/legacy\/([a-z0-9-]+)\.md/g)].map((match) => match[1]))].sort(); -} - /** * Compute the complete module graph for each independently installable public * skill. Owner modules are roots because compatibility aliases may select any @@ -570,7 +566,11 @@ function packageModuleClosure(rendered: Map): Map< for (const source of [...sources]) { const module = rendered.get(source); if (!module) throw new Error(`${tree} references unknown preserved module ${source}`); - for (const dependency of referencedModules(module.content)) { + const dependencies = new Set( + [...module.content.matchAll(/references\/legacy\/([a-z0-9-]+)\.md/g)] + .map((match) => match[1]), + ); + for (const dependency of [...dependencies].sort()) { if (!rendered.has(dependency)) throw new Error(`${source} references unknown preserved module ${dependency}`); if (!sources.has(dependency)) { sources.add(dependency); diff --git a/scripts/gstack2/host-adversarial.ts b/scripts/gstack2/host-adversarial.ts index dcdd6fb32..7d6244c86 100644 --- a/scripts/gstack2/host-adversarial.ts +++ b/scripts/gstack2/host-adversarial.ts @@ -645,10 +645,13 @@ export function validateStructuredResult(value: unknown): value is StructuredHos function parseJsonCandidate(text: string): unknown { const trimmed = text.trim(); - try { return JSON.parse(trimmed); } catch { /* try a fenced payload */ } const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]; - if (fenced) return JSON.parse(fenced); - throw new Error('final agent message was not JSON'); + try { + return JSON.parse(fenced ?? trimmed); + } catch (error) { + if (error instanceof SyntaxError) throw new Error('final agent message was not JSON'); + throw error; + } } export function parseStructuredFinal(messages: string[]): { @@ -842,7 +845,9 @@ function stageAuthentication(codexHome: string): void { if (!fs.existsSync(source)) return; const destination = path.join(codexHome, 'auth.json'); fs.copyFileSync(source, destination); - try { fs.chmodSync(destination, 0o600); } catch { /* Windows and restrictive filesystems may ignore chmod. */ } + // Windows has no POSIX owner-only mode. On POSIX, failing to secure copied + // credentials is fatal rather than silently continuing with broad access. + if (os.platform() !== 'win32') fs.chmodSync(destination, 0o600); } async function runFixture(options: { diff --git a/scripts/gstack2/semantic-parity.ts b/scripts/gstack2/semantic-parity.ts index a94c2f53e..bf7e5f946 100644 --- a/scripts/gstack2/semantic-parity.ts +++ b/scripts/gstack2/semantic-parity.ts @@ -221,9 +221,7 @@ function policyUnitTranscript() { }); } -function containsSensitiveMaterial(value: string): boolean { - return /(?:sk-[A-Za-z0-9_-]{12,}|AKIA[0-9A-Z]{16}|gh[opusr]_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/.test(value); -} +const SENSITIVE_MATERIAL = /(?:sk-[A-Za-z0-9_-]{12,}|AKIA[0-9A-Z]{16}|gh[opusr]_[A-Za-z0-9]{20,}|-----BEGIN [A-Z ]+PRIVATE KEY-----)/; function sanitizeLivePrompt(value: string): string { return value @@ -237,7 +235,7 @@ const LIVE_OUTPUT_SCHEMA = `Return one JSON object and no prose with exactly the const LIVE_JUDGE_SCHEMA = `Return one JSON object and no prose with fields verdict and dimensions. verdict must be EQUIVALENT, INTENTIONAL_IMPROVEMENT, or REGRESSION. dimensions must be an object with exactly these keys: ${SEMANTIC_DIMENSIONS.join(', ')}. Each dimension value must be an object with classification (one of the same three values) and a concise reason. Treat any loss of pressure, gates, evidence, mutation restraint, recommendation, or voice as REGRESSION. Do not call tools.`; async function runClaude(prompt: string, model: string, maxBudgetUsd: number): Promise<{ raw: string; parsed: Record }> { - if (containsSensitiveMaterial(prompt)) throw new Error('Refusing live semantic eval: prompt matched a credential pattern'); + if (SENSITIVE_MATERIAL.test(prompt)) throw new Error('Refusing live semantic eval: prompt matched a credential pattern'); const proc = Bun.spawn([ 'claude', '-p', '--bare', '--no-session-persistence', '--disable-slash-commands', '--no-chrome', '--model', model, '--max-turns', '1', '--max-budget-usd', maxBudgetUsd.toFixed(2), diff --git a/scripts/gstack2/test-install-matrix.ts b/scripts/gstack2/test-install-matrix.ts index a25ae73af..5f3a609ac 100644 --- a/scripts/gstack2/test-install-matrix.ts +++ b/scripts/gstack2/test-install-matrix.ts @@ -344,11 +344,14 @@ function listInstalledSkills(root: string): string[] { } export function stripTerminalControls(value: string): string { - return value - .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '') - .replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/\r(?=[^\n])/g, '') - .trim(); + const terminalControls = [ + /\x1B\][^\x07]*(?:\x07|\x1B\\)/g, + /\x1B\[[0-?]*[ -/]*[@-~]/g, + /\r(?=[^\n])/g, + ]; + let clean = value; + for (const control of terminalControls) clean = clean.replace(control, ''); + return clean.trim(); } function trimEvidenceOutput(value: string, maxCharacters = 16_000): string { @@ -555,9 +558,7 @@ function runRemoval(options: { }; } -export function runFastChecks(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspection { - return inspectRepository(repoRoot); -} +export const runFastChecks = inspectRepository; export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence { if (!options.outputPath) throw new Error('Full install matrix requires a caller-supplied outputPath'); diff --git a/scripts/slop-diff.ts b/scripts/slop-diff.ts index b2a5abd17..889641ab3 100644 --- a/scripts/slop-diff.ts +++ b/scripts/slop-diff.ts @@ -18,13 +18,24 @@ import * as path from "path"; const base = process.argv[2] || "main"; +// `skills/` is a committed, deterministic projection of the preserved source +// corpus. Its support files are intentionally copied into self-contained Agent +// Skills packages, so duplicate-signature findings there describe the package +// format rather than new handwritten implementation. Scan the generator and +// original source files instead; freshness/parity separately prove the output. +function isGeneratedCanonicalOutput(file: string): boolean { + return file.startsWith("skills/"); +} + // 1. Find changed files const diffResult = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], { encoding: "utf-8", timeout: 10000, }); const changedFiles = new Set( - (diffResult.stdout || "").trim().split("\n").filter(Boolean), + (diffResult.stdout || "").trim().split("\n") + .filter(Boolean) + .filter((file) => !isGeneratedCanonicalOutput(file)), ); if (changedFiles.size === 0) { console.log("No files changed vs", base, "— nothing to check."); diff --git a/scripts/test-free-shards.ts b/scripts/test-free-shards.ts index 2c67e550c..943f2ad4c 100755 --- a/scripts/test-free-shards.ts +++ b/scripts/test-free-shards.ts @@ -119,10 +119,11 @@ export const DEFAULT_SHARD_COUNT = 20; export const DEFAULT_MAX_FILES_PER_SHARD = 20; export const FREE_TEST_TIMEOUT_MS = 10_000; -const SCHEDULED_EXIT_ZERO = /\b(?:setTimeout|setInterval|setImmediate|queueMicrotask)\s*\(\s*(?:(?:async\s*)?(?:\([^)]*\)|[$\w]+)\s*=>|function(?:\s+[$\w]+)?\s*\([^)]*\)\s*\{)[\s\S]{0,256}?\bprocess\.exit\s*\(\s*0\s*\)/; +const SCHEDULED_CALLBACK_START = /\b(?:setTimeout|setInterval|setImmediate|queueMicrotask)\s*\(\s*(?:(?:async\s*)?(?:\([^)]*\)|[$\w]+)\s*=>|function(?:\s+[$\w]+)?\s*\([^)]*\)\s*\{)/g; +const PROCESS_EXIT_ZERO = /\bprocess\.exit\s*\(\s*0\s*\)/; // Deliberately require column zero. That identifies conventional module-scope // setup while avoiding process.env changes indented inside hooks and tests. -const TOP_LEVEL_PROCESS_ENV_MUTATION = /^(?:process\.env\.[A-Za-z_][A-Za-z0-9_]*[ \t]*=(?!=)|delete[ \t]+process\.env\.[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*;)?[ \t]*(?:\/\/.*)?$)/m; +const TOP_LEVEL_PROCESS_ENV_MUTATION = /^(?:process\.env\.[A-Za-z_][A-Za-z0-9_]*[ \t]*=(?!=)|delete[ \t]+process\.env\.[A-Za-z_][A-Za-z0-9_]*(?:[ \t]*;)?[ \t]*(?:\/\/.*)?$)/; export function normalizeRelativePath(filePath: string): string { return filePath.replace(/\\/g, '/'); @@ -233,27 +234,29 @@ export function assignFilesToShards(files: string[], shardCount: number): string } export function containsScheduledProcessExitZero(source: string): boolean { - return SCHEDULED_EXIT_ZERO.test(source); + for (const callback of source.matchAll(SCHEDULED_CALLBACK_START)) { + const bodyStart = (callback.index ?? 0) + callback[0].length; + const exit = PROCESS_EXIT_ZERO.exec(source.slice(bodyStart, bodyStart + 320)); + if (exit !== null && exit.index <= 256) return true; + } + return false; } export function hasScheduledProcessExitZero(absolutePath: string): boolean { - try { - return containsScheduledProcessExitZero(fs.readFileSync(absolutePath, 'utf8')); - } catch { - return false; - } + const source = fs.readFileSync(absolutePath, 'utf8'); + return containsScheduledProcessExitZero(source); } export function containsTopLevelProcessEnvMutation(source: string): boolean { - return TOP_LEVEL_PROCESS_ENV_MUTATION.test(source); + for (const line of source.split(/\r?\n/)) { + if (TOP_LEVEL_PROCESS_ENV_MUTATION.test(line)) return true; + } + return false; } export function hasTopLevelProcessEnvMutation(absolutePath: string): boolean { - try { - return containsTopLevelProcessEnvMutation(fs.readFileSync(absolutePath, 'utf8')); - } catch { - return false; - } + const source = fs.readFileSync(absolutePath, 'utf8'); + return containsTopLevelProcessEnvMutation(source); } export interface BoundedShardOptions { @@ -276,13 +279,17 @@ export function planBoundedFreeTestShards( throw new Error(`Maximum files per shard must be a positive integer. Received: ${maxFilesPerShard}`); } + const orderedFiles = [...new Set(files)].sort(); + if (maxFilesPerShard === 1) return orderedFiles.map((file) => [file]); + const normal: string[] = []; const isolated: string[] = []; - for (const file of [...new Set(files)].sort()) { + for (const file of orderedFiles) { const absolutePath = path.join(rootDir, file); + const source = fs.readFileSync(absolutePath, 'utf8'); if ( - hasScheduledProcessExitZero(absolutePath) - || hasTopLevelProcessEnvMutation(absolutePath) + containsScheduledProcessExitZero(source) + || containsTopLevelProcessEnvMutation(source) ) isolated.push(file); else normal.push(file); } diff --git a/scripts/test-free-strict.ts b/scripts/test-free-strict.ts index f7d14c812..ba3a2fbbd 100644 --- a/scripts/test-free-strict.ts +++ b/scripts/test-free-strict.ts @@ -53,17 +53,6 @@ const DEFAULT_TERMINATION_TIMER: TerminationTimerApi = { cancel: (handle) => clearTimeout(handle as ReturnType), }; -function killWithoutThrowing( - child: Pick, - signal: NodeJS.Signals, -): void { - try { - child.kill(signal); - } catch { - // The child may have exited between close detection and signal delivery. - } -} - /** * Bind one active child to the parent's termination lifecycle. SIGINT and * SIGTERM get a grace period so Bun can clean up; a repeated signal, timeout, @@ -82,19 +71,19 @@ export function installChildSignalForwarding( const forward = (signal: ForwardedTerminationSignal): void => { if (disposed) return; if (receivedSignal !== null) { - killWithoutThrowing(child, 'SIGKILL'); + child.kill('SIGKILL'); return; } receivedSignal = signal; - killWithoutThrowing(child, signal); + child.kill(signal); forceTimer = timer.schedule(() => { forceTimer = null; - killWithoutThrowing(child, 'SIGKILL'); + child.kill('SIGKILL'); }, graceMs); }; const onSigint = () => forward('SIGINT'); const onSigterm = () => forward('SIGTERM'); - const onExit = () => killWithoutThrowing(child, 'SIGKILL'); + const onExit = () => { child.kill('SIGKILL'); }; source.on('SIGINT', onSigint); source.on('SIGTERM', onSigterm); @@ -283,7 +272,7 @@ export async function runStrictTestShard(files: string[]): Promise { const forwarding = installChildSignalForwarding(child); if (!child.stdout || !child.stderr) { - killWithoutThrowing(child, 'SIGKILL'); + child.kill('SIGKILL'); forwarding.dispose(); throw new Error('Bun test output pipes were not created'); } diff --git a/setup b/setup index ff18feb8a..cdcd91efb 100755 --- a/setup +++ b/setup @@ -49,16 +49,9 @@ if ! command -v bun >/dev/null 2>&1; then exit 1 fi -if ! "$NODE_COMMAND" -e ' - const fs = require("node:fs"); - const path = require("node:path"); - const root = process.argv[1]; - const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); - const dependencies = Object.keys(pkg.dependencies || {}); - process.exit(dependencies.every((name) => fs.existsSync(path.join(root, "node_modules", name, "package.json"))) ? 0 : 1); -' "$ROOT"; then - (cd "$ROOT" && bun install --production --frozen-lockfile) -fi +# Reconcile on every setup. Presence checks cannot distinguish the lockfile's +# exact versions from stale but loadable packages copied from another checkout. +(cd "$ROOT" && bun install --production --frozen-lockfile) if [ "${#ARGS[@]}" -gt 0 ]; then exec "$NODE_COMMAND" "$ROOT/runtime/install.js" --source "$ROOT" "${ARGS[@]}" diff --git a/test/gstack2-ci-runtime-smoke.test.ts b/test/gstack2-ci-runtime-smoke.test.ts index 9f5d4d6b9..d110559c9 100644 --- a/test/gstack2-ci-runtime-smoke.test.ts +++ b/test/gstack2-ci-runtime-smoke.test.ts @@ -1,11 +1,14 @@ import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { DEFAULT_RUNTIME_BUNDLE } from "../runtime/install.js"; const root = path.resolve(import.meta.dir, ".."); const dockerfile = fs.readFileSync(path.join(root, ".devcontainer", "Dockerfile"), "utf8"); const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "gstack2-gate.yml"), "utf8"); +const devcontainerGate = fs.readFileSync(path.join(root, "scripts", "gstack2", "devcontainer-gate.sh"), "utf8"); const smoke = fs.readFileSync(path.join(root, "scripts", "gstack2", "runtime-install-smoke.sh"), "utf8"); const packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8")); const iosSources = [ @@ -27,6 +30,95 @@ describe("GStack 2 CI supply-chain and browser smoke", () => { for (const reference of actionRefs) expect(reference).toMatch(/^[0-9a-f]{40}$/); }); + test("mounts the checkout read-only for every development-container run", () => { + const workspaceMounts = [...workflow.matchAll(/--volume "\$\{\{ github\.workspace \}\}:([^"]+)"/g)] + .map((match) => match[1]); + expect(workspaceMounts).toEqual(["/source:ro", "/source:ro"]); + expect(workflow).toContain("/source/scripts/gstack2/devcontainer-gate.sh /source"); + }); + + test("installs and tests in a disposable copy without git metadata or host dependencies", () => { + expect(devcontainerGate).toContain("mktemp -d /tmp/gstack2-devcontainer-gate.XXXXXX"); + expect(devcontainerGate).toContain("--exclude='./.git'"); + expect(devcontainerGate).toContain("--exclude='./node_modules'"); + expect(devcontainerGate).toContain("trap cleanup EXIT"); + expect(devcontainerGate).toContain("printf 'gitdir: %s\\n'"); + expect(devcontainerGate).toContain('GSTACK_GATE_BASE_GIT:-$(command -v git)'); + expect(devcontainerGate).toContain('-c safe.directory="$SOURCE"'); + expect(devcontainerGate).toContain('-c safe.directory="$GSTACK_GATE_WORK_TREE"'); + if (process.platform === "win32") return; + + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), "gstack2-devcontainer-gate-test-")); + const source = path.join(fixtureRoot, "source"); + const stubBin = path.join(fixtureRoot, "commands"); + const callLog = path.join(fixtureRoot, "bun-calls.log"); + fs.mkdirSync(path.join(source, "node_modules", "host-only"), { recursive: true }); + fs.mkdirSync(stubBin, { recursive: true }); + fs.writeFileSync(path.join(source, "package.json"), "{}\n"); + const gitInit = spawnSync("git", ["init", "--quiet", "--initial-branch=main", source], { encoding: "utf8" }); + expect(gitInit.status).toBe(0); + const realGit = process.env.GSTACK_GATE_BASE_GIT ?? (process.env.PATH ?? "").split(path.delimiter) + .map((directory) => path.join(directory, "git")) + .find((candidate) => fs.existsSync(candidate)); + expect(realGit).toBeDefined(); + fs.writeFileSync(path.join(source, ".git", "gstack-sentinel"), "host git metadata\n"); + fs.writeFileSync(path.join(source, "node_modules", "host-only", "sentinel"), "host dependency\n"); + fs.writeFileSync(path.join(stubBin, "bun"), `#!/usr/bin/env bash +set -euo pipefail +printf '%s\\t%s\\n' "$PWD" "$*" >> "$GSTACK_TEST_CALL_LOG" +test -e package.json +test -f .git +test ! -e .git/gstack-sentinel +test ! -e node_modules/host-only +if [[ "\${GSTACK_TEST_FAIL:-0}" == "1" ]]; then exit 23; fi +if [[ "$*" == "run test:gstack2" ]]; then test "$(git rev-parse --show-toplevel)" == "$PWD"; fi +mkdir -p node_modules +touch node_modules/container-only +`, { mode: 0o755 }); + fs.writeFileSync(path.join(stubBin, "git"), "#!/usr/bin/env bash\nexit 97\n", { mode: 0o755 }); + + const runGate = (fail: boolean) => { + fs.writeFileSync(callLog, ""); + const result = spawnSync("bash", [path.join(root, "scripts", "gstack2", "devcontainer-gate.sh"), source], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${stubBin}${path.delimiter}${process.env.PATH ?? ""}`, + GSTACK_TEST_CALL_LOG: callLog, + GSTACK_TEST_FAIL: fail ? "1" : "0", + GSTACK_GATE_BASE_GIT: realGit, + }, + }); + const calls = fs.readFileSync(callLog, "utf8").trim().split("\n").filter(Boolean) + .map((line) => { + const [cwd, args] = line.split("\t"); + return { cwd, args }; + }); + return { calls, result }; + }; + + try { + const success = runGate(false); + expect(success.result.stderr).toBe(""); + expect(success.result.status).toBe(0); + expect(success.calls.map((call) => call.args)).toEqual(["install --frozen-lockfile", "run test:gstack2"]); + expect(new Set(success.calls.map((call) => call.cwd)).size).toBe(1); + expect(success.calls[0].cwd).not.toBe(source); + expect(fs.existsSync(success.calls[0].cwd)).toBe(false); + + const failure = runGate(true); + expect(failure.result.status).toBe(23); + expect(failure.calls).toHaveLength(1); + expect(fs.existsSync(failure.calls[0].cwd)).toBe(false); + + expect(fs.readFileSync(path.join(source, ".git", "gstack-sentinel"), "utf8")).toBe("host git metadata\n"); + expect(fs.readFileSync(path.join(source, "node_modules", "host-only", "sentinel"), "utf8")).toBe("host dependency\n"); + expect(fs.existsSync(path.join(source, "node_modules", "container-only"))).toBe(false); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }); + test("drives a loopback page through the installed browser", () => { expect(smoke).not.toContain("bun install --frozen-lockfile"); expect(smoke).toContain('test ! -e "$REPO/node_modules/@anthropic-ai/claude-agent-sdk"'); diff --git a/test/gstack2-runtime-core.test.ts b/test/gstack2-runtime-core.test.ts index 5327e6154..0938aaddc 100644 --- a/test/gstack2-runtime-core.test.ts +++ b/test/gstack2-runtime-core.test.ts @@ -19,6 +19,7 @@ import { runExternalEffect, setupRuntime, updateProjectState, + withLock, } from "../runtime/index.js"; const temporaryRoots: string[] = []; @@ -280,4 +281,24 @@ describe("gstack 2 host-neutral paths and state", () => { await fs.chmod(readOnly, 0o700); } }); + + test("lock cleanup preserves both the operation and release failures", async () => { + const root = await temporaryRoot("gstack2 lock aggregate "); + const lock = path.join(root, "state.lock"); + const operationError = null; + let caught: unknown; + try { + await withLock(lock, async () => { + await fs.writeFile(path.join(lock, "owner.json"), "{malformed\n"); + throw operationError; + }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(AggregateError); + const failures = [...(caught as AggregateError).errors]; + expect(failures[0]).toBe(operationError); + expect(failures[1]).toBeInstanceOf(SyntaxError); + expect((caught as Error & { cause?: unknown }).cause).toBe(operationError); + }); }); diff --git a/test/gstack2-runtime-install.test.ts b/test/gstack2-runtime-install.test.ts index 9ff62ce18..5dff7a178 100644 --- a/test/gstack2-runtime-install.test.ts +++ b/test/gstack2-runtime-install.test.ts @@ -3,14 +3,17 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { main as runtimeMain } from "../runtime/cli.js"; +import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle"; import { DEFAULT_CAPABILITY_LAUNCHERS, DEFAULT_RUNTIME_BUNDLE, DEFAULT_RUNTIME_HELPERS, defaultBunBuilder, installManagedRuntime, + runtimeNativePackagePaths, uninstallManagedRuntime, runCommand, + smokeRuntimeBundle, validateRuntimeBundle, } from "../runtime/install.js"; @@ -128,11 +131,12 @@ describe("GStack 2 managed runtime installer", () => { const bundlePaths = new Set(DEFAULT_RUNTIME_BUNDLE.map((item) => item.path)); for (const dependency of [ "node_modules/sharp", - "node_modules/@img", "node_modules/detect-libc", "node_modules/semver", - "node_modules/@ngrok", + ...runtimeNativePackagePaths(), ]) expect(bundlePaths.has(dependency)).toBe(true); + expect(bundlePaths.has("node_modules/@img")).toBe(false); + expect(bundlePaths.has("node_modules/@ngrok")).toBe(false); expect([...bundlePaths].some((item) => item.includes("@huggingface"))).toBe(false); for (const helper of contract.helpers) { expect(bundlePaths.has(helper.source_path)).toBe(true); @@ -180,6 +184,75 @@ describe("GStack 2 managed runtime installer", () => { }, { createDefaultSource: false }); }, 30_000); + test("selects one deterministic native dependency closure per supported host", () => { + expect(runtimeNativePackagePaths({ platform: "darwin", arch: "arm64" })).toEqual([ + "node_modules/@img/colour", + "node_modules/@img/sharp-darwin-arm64", + "node_modules/@img/sharp-libvips-darwin-arm64", + "node_modules/@ngrok/ngrok-darwin-universal", + "node_modules/@ngrok/ngrok", + ]); + expect(runtimeNativePackagePaths({ platform: "darwin", arch: "x64" })).toContain( + "node_modules/@img/sharp-libvips-darwin-x64", + ); + expect(runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "glibc" })).toEqual([ + "node_modules/@img/colour", + "node_modules/@img/sharp-linux-x64", + "node_modules/@img/sharp-libvips-linux-x64", + "node_modules/@ngrok/ngrok-linux-x64-gnu", + "node_modules/@ngrok/ngrok", + ]); + expect(runtimeNativePackagePaths({ platform: "linux", arch: "arm64", libc: "glibc" })).toContain( + "node_modules/@ngrok/ngrok-linux-arm64-gnu", + ); + expect(runtimeNativePackagePaths({ platform: "linux", arch: "arm64", libc: "musl" })).toEqual([ + "node_modules/@img/colour", + "node_modules/@img/sharp-linuxmusl-arm64", + "node_modules/@img/sharp-libvips-linuxmusl-arm64", + "node_modules/@ngrok/ngrok-linux-arm64-musl", + "node_modules/@ngrok/ngrok", + ]); + expect(runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "musl" })).toContain( + "node_modules/@img/sharp-libvips-linuxmusl-x64", + ); + expect(runtimeNativePackagePaths({ platform: "win32", arch: "x64" })).toEqual([ + "node_modules/@img/colour", + "node_modules/@img/sharp-win32-x64", + "node_modules/@ngrok/ngrok-win32-x64-msvc", + "node_modules/@ngrok/ngrok", + ]); + expect(runtimeNativePackagePaths({ platform: "win32", arch: "arm64" })).toContain( + "node_modules/@ngrok/ngrok-win32-arm64-msvc", + ); + expect(() => runtimeNativePackagePaths({ platform: "linux", arch: "x64", libc: "unknown" })) + .toThrow("Unsupported managed-runtime libc"); + expect(() => runtimeNativePackagePaths({ platform: "freebsd", arch: "x64" })) + .toThrow("Unsupported managed-runtime platform"); + }); + + test("summarizes a runtime bundle as deterministic, reproducible evidence", () => { + const audit = summarizeRuntimeBundle({ + version: "fixture-version", + components: ["runtime", ...runtimeNativePackagePaths()], + files: [ + { path: "runtime/index.js", size: 17, mode: 0o644, sha256: "a".repeat(64) }, + { path: "runtime/cli.js", size: 23, mode: 0o755, sha256: "b".repeat(64) }, + ], + }); + expect(audit).toMatchObject({ + schemaVersion: 1, + sourceBundleVersion: "fixture-version", + components: 1 + runtimeNativePackagePaths().length, + files: 2, + bytes: 40, + forbiddenComponents: [], + }); + expect(audit.nativeComponents).toEqual(runtimeNativePackagePaths()); + expect(typeof audit.sourceGitDirty).toBe("boolean"); + expect(audit.bundleManifestSha256).toMatch(/^[a-f0-9]{64}$/); + expect(audit.reproductionCommand).toContain(`evals/runtime-bundle/${process.platform}-${process.arch}.json`); + }); + test("failed validation and failed smoke checks roll back activation", async () => { await withFixture(async ({ source, home }) => { await installFixture(source, home, "1.0.0"); @@ -193,6 +266,10 @@ describe("GStack 2 managed runtime installer", () => { smokeTest: async () => { throw new Error("smoke failed"); }, })).rejects.toMatchObject({ code: "UPGRADE_ROLLED_BACK" }); expect(await activeVersion(home)).toBe("1.0.0"); + expect(await exists(path.join(home, "versions", "2.0.1"))).toBe(false); + + const repaired = await installFixture(source, home, "2.0.1"); + expect(repaired.pointer.current).toBe("2.0.1"); }); }); @@ -440,6 +517,45 @@ describe("GStack 2 managed runtime installer", () => { }); }); + test("default runtime smoke rejects an unloadable native dependency closure", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "gstack native smoke ")); + try { + await fs.mkdir(path.join(root, "bin"), { recursive: true }); + await fs.mkdir(path.join(root, "node_modules", "sharp"), { recursive: true }); + await fs.writeFile(path.join(root, "bin", "gstack"), "fixture\n"); + await fs.writeFile(path.join(root, "node_modules", "sharp", "package.json"), '{"name":"sharp"}\n'); + const calls: string[][] = []; + await expect(smokeRuntimeBundle(root, { + run: async (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" }; + if (args[0] === "--input-type=module") throw new Error("native binding unavailable"); + return { code: 0, stdout: "gstack fixture\n", stderr: "" }; + }, + })).rejects.toMatchObject({ code: "INSTALL_SMOKE_FAILED" }); + expect(calls.at(-1)?.[0]).toBe("--input-type=module"); + expect(calls.at(-1)?.at(-1)).toContain('import("sharp")'); + expect(calls.at(-1)?.at(-1)).not.toContain("@ngrok/ngrok"); + + await fs.rm(path.join(root, "node_modules", "sharp"), { recursive: true, force: true }); + await fs.mkdir(path.join(root, "node_modules", "@ngrok", "ngrok"), { recursive: true }); + await fs.writeFile(path.join(root, "node_modules", "@ngrok", "ngrok", "package.json"), '{"name":"@ngrok/ngrok"}\n'); + calls.length = 0; + await expect(smokeRuntimeBundle(root, { + run: async (_command: string, args: string[]) => { + calls.push(args); + if (args[0] === "--version") return { code: 0, stdout: "v20.18.0\n", stderr: "" }; + if (args[0] === "--input-type=module") throw new Error("native binding unavailable"); + return { code: 0, stdout: "gstack fixture\n", stderr: "" }; + }, + })).rejects.toMatchObject({ code: "INSTALL_SMOKE_FAILED" }); + expect(calls.at(-1)?.at(-1)).toContain('import("@ngrok/ngrok")'); + expect(calls.at(-1)?.at(-1)).not.toContain('import("sharp")'); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + test("public upgrade reuses managed validation and rejects arbitrary or symlinked sources", async () => { if (process.platform === "win32") return; await withFixture(async ({ root, source, home }) => { @@ -478,15 +594,18 @@ describe("GStack 2 managed runtime installer", () => { const log = path.join(root, "bun.log"); await fs.mkdir(fakeBin); await fs.mkdir(runtime); - await fs.mkdir(path.join(root, "node_modules")); + await fs.mkdir(path.join(root, "node_modules", "sharp"), { recursive: true }); await fs.copyFile(path.join(REPO_ROOT, "setup"), path.join(root, "setup")); await fs.chmod(path.join(root, "setup"), 0o755); - await fs.writeFile(path.join(root, "package.json"), '{"type":"module","dependencies":{"fixture-dependency":"1.0.0"},"devDependencies":{"test-only-sdk":"1.0.0"}}\n'); + await fs.writeFile(path.join(root, "package.json"), '{"type":"module","dependencies":{"sharp":"1.0.0"},"devDependencies":{"test-only-sdk":"1.0.0"}}\n'); + await fs.writeFile(path.join(root, "node_modules", "sharp", "package.json"), '{"name":"sharp","main":"index.js"}\n'); + await fs.writeFile(path.join(root, "node_modules", "sharp", "index.js"), 'module.exports = require("@img/sharp-fixture");\n'); await fs.writeFile(path.join(runtime, "install.js"), 'console.log(`installer=${process.release.name}`);\n'); await fs.writeFile(path.join(fakeBin, "bun"), `#!/bin/sh printf '%s\\n' "$*" >> "$BUN_LOG" -mkdir -p "$FIXTURE_ROOT/node_modules/fixture-dependency" -printf '{"name":"fixture-dependency"}\\n' > "$FIXTURE_ROOT/node_modules/fixture-dependency/package.json" +mkdir -p "$FIXTURE_ROOT/node_modules/@img/sharp-fixture" +printf '{"name":"@img/sharp-fixture","main":"index.js"}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture/package.json" +printf 'module.exports = {}\\n' > "$FIXTURE_ROOT/node_modules/@img/sharp-fixture/index.js" `, { mode: 0o755 }); const result = await runCommand(path.join(root, "setup"), [], { @@ -513,7 +632,10 @@ printf '{"name":"fixture-dependency"}\\n' > "$FIXTURE_ROOT/node_modules/fixture- }, }); const installs = (await fs.readFile(log, "utf8")).trim().split("\n"); - expect(installs).toEqual(["install --production --frozen-lockfile"]); + expect(installs).toEqual([ + "install --production --frozen-lockfile", + "install --production --frozen-lockfile", + ]); expect(await exists(path.join(root, "node_modules", "test-only-sdk"))).toBe(false); expect(second.stdout).toContain("installer=node"); } finally {