mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
harden runtime packaging and verification
This commit is contained in:
@@ -70,10 +70,10 @@ jobs:
|
|||||||
- name: Run the GStack 2 gate inside the container
|
- name: Run the GStack 2 gate inside the container
|
||||||
run: >-
|
run: >-
|
||||||
docker run --rm
|
docker run --rm
|
||||||
--volume "${{ github.workspace }}:/workspaces/gstack"
|
--volume "${{ github.workspace }}:/source:ro"
|
||||||
--workdir /workspaces/gstack
|
--workdir /source
|
||||||
gstack2-devcontainer
|
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
|
- name: Exercise a clean Linux runtime install inside the container
|
||||||
run: >-
|
run: >-
|
||||||
docker run --rm
|
docker run --rm
|
||||||
|
|||||||
@@ -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.
|
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.
|
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`.
|
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`.
|
||||||
|
|||||||
@@ -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)
|
module. See the complete [old-command mapping](docs/gstack-2/SKILL-MIGRATION.md)
|
||||||
and [architecture](docs/gstack-2/ARCHITECTURE.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,
|
Install with the standard Agent Skills installer; it owns host detection,
|
||||||
scope, destination paths, updates, removal, and selected-skill installation:
|
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
|
judgment modes work without a shared executable. Capability-dependent modes
|
||||||
may offer the optional, host-neutral runtime. From a repository checkout,
|
may offer the optional, host-neutral runtime. From a repository checkout,
|
||||||
`./setup` installs that runtime once per user without placing host skills. Its
|
`./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
|
real default-capability lifecycle and 383-file broad suite pass on macOS. The
|
||||||
Container install/build/uninstall smoke also passes. Native-host Linux and
|
declared Linux Dev Container passes the 136-test GStack 2 suite, and a clean
|
||||||
Windows remain release gates, so consult
|
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
|
[`HOST-COMPATIBILITY.md`](docs/gstack-2/HOST-COMPATIBILITY.md) before relying on
|
||||||
it.
|
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.
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -35,12 +35,24 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
try { await bm?.close(); } catch {}
|
const cleanupFailures = (await Promise.allSettled([
|
||||||
try { testServer.server.stop(); } catch {}
|
bm?.close(1000),
|
||||||
if (previousChromiumProfile === undefined) delete process.env.CHROMIUM_PROFILE;
|
testServer?.server.stop(true),
|
||||||
else process.env.CHROMIUM_PROFILE = previousChromiumProfile;
|
])).filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||||
try { fs.rmSync(testRoot, { recursive: true, force: true }); } catch {}
|
.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) ────────────
|
// ─── Unit Tests: Failure Tracking (no browser needed) ────────────
|
||||||
|
|
||||||
|
|||||||
@@ -130,10 +130,18 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
try { server.stop(); } catch {}
|
const cleanupFailures = (await Promise.allSettled([
|
||||||
try { await bm.close(1000); } catch {}
|
server?.stop(true),
|
||||||
fs.rmSync(tmpDir, { recursive: true, force: 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 ─────────────
|
// ─── The critical test: browser click → file on disk ─────────────
|
||||||
|
|
||||||
|
|||||||
@@ -123,8 +123,10 @@ operations are setup, doctor, config, state
|
|||||||
inspection/resume, Context.dev status/options/select/setup/smoke, cleanup,
|
inspection/resume, Context.dev status/options/select/setup/smoke, cleanup,
|
||||||
upgrade/rollback, and uninstall.
|
upgrade/rollback, and uninstall.
|
||||||
|
|
||||||
The current managed-bundle audit records 107 components, 1,830 files,
|
The deterministic clean macOS arm64 managed-bundle audit records 110
|
||||||
459,056,031 bytes, and 50 launchers. Setup installs frozen production-only
|
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
|
dependencies; the development-only Claude Agent SDK is excluded. The
|
||||||
Sharp/ngrok dependency closure is included. The Hugging Face sidecar is
|
Sharp/ngrok dependency closure is included. The Hugging Face sidecar is
|
||||||
excluded and its package is development-only, so setup installs neither its
|
excluded and its package is development-only, so setup installs neither its
|
||||||
|
|||||||
@@ -138,7 +138,8 @@ checkout, the one host-neutral setup entrypoint is:
|
|||||||
~/.gstack/bin/gstack doctor --json
|
~/.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,
|
builds missing allowlisted capabilities through the runtime-only build target,
|
||||||
validates and hashes every staged file, smoke-tests the CLI, atomically
|
validates and hashes every staged file, smoke-tests the CLI, atomically
|
||||||
activates the version, and writes stable POSIX and Windows launchers under
|
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
|
Windows and includes the CoreDevice/iOS bundle only on Darwin. Add the bin
|
||||||
directory to `PATH` if the short `gstack` command is desired.
|
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,
|
manifests, paths with spaces,
|
||||||
source-root symlinks, internal-link/path-escape rejection, failed build/
|
source-root symlinks, internal-link/path-escape rejection, failed build/
|
||||||
validation/smoke rollback, interrupted-pointer recovery, stable POSIX/Windows
|
validation/smoke rollback including native-load rollback smoke,
|
||||||
launchers, runtime-only builder selection, managed uninstall, and the
|
interrupted-pointer recovery, stable POSIX/Windows launchers, runtime-only
|
||||||
host-neutral wrapper.
|
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,
|
The deterministic clean macOS arm64 managed-bundle audit records 110
|
||||||
459,056,031 bytes, and 50 launchers. Setup installs frozen production-only
|
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
|
dependencies and excludes the development-only Claude Agent SDK. The
|
||||||
Sharp/ngrok closure is included. The Hugging Face sidecar is excluded and its
|
Sharp/ngrok closure is included. The Hugging Face sidecar is excluded and its
|
||||||
package is development-only, so setup installs neither its inference runtime
|
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 |
|
| 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. |
|
| 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 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. |
|
| 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,813 pass / 57 expected skips / 0 fail across 213 files, but it is not native evidence. |
|
| 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 130-test GStack 2 suite pass, as does the clean runtime install/browser smoke. Full broad Linux coverage remains pending. |
|
| 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
|
The six portable skills remain useful when runtime installation fails. A
|
||||||
runtime failure must not remove or corrupt their standard-installer placement.
|
runtime failure must not remove or corrupt their standard-installer placement.
|
||||||
|
|||||||
@@ -103,8 +103,10 @@ support the claim and must follow the source system's retention policy.
|
|||||||
|
|
||||||
## Models, images, and documents
|
## Models, images, and documents
|
||||||
|
|
||||||
The current managed-bundle audit records 107 components, 1,830 files,
|
The deterministic clean macOS arm64 managed-bundle audit records 110
|
||||||
459,056,031 bytes, and 50 launchers. Setup installs frozen production-only
|
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
|
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
|
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
|
development-only, so setup installs neither its inference runtime nor model
|
||||||
|
|||||||
+15
-12
@@ -48,15 +48,18 @@ status.
|
|||||||
three retained Claude Haiku live samples are classified `REGRESSION`; they
|
three retained Claude Haiku live samples are classified `REGRESSION`; they
|
||||||
are preserved as noisy supplemental evidence, never cherry-picked as a
|
are preserved as noisy supplemental evidence, never cherry-picked as a
|
||||||
primary gate or represented as green.
|
primary gate or represented as green.
|
||||||
- [x] The focused GStack 2 suite is green: 130 pass / 0 fail, 1,071
|
- [x] The focused macOS GStack 2 suite is green: 136 pass / 0 fail, 1,128
|
||||||
assertions across 15 files. This focused surface does not substitute for the
|
assertions across 15 files. Log: `/tmp/gstack2-test-command-candidate-final2.log`.
|
||||||
broad or native-platform gates.
|
This focused surface does not substitute for the broad or native-platform
|
||||||
|
gates.
|
||||||
- [x] Optional host-neutral runtime implemented with canonical paths,
|
- [x] Optional host-neutral runtime implemented with canonical paths,
|
||||||
repo/worktree state identity, locks, atomic writes, effect claims,
|
repo/worktree state identity, locks, atomic writes, effect claims,
|
||||||
doctor/config/state/cleanup, migrations, upgrade/rollback, and uninstall.
|
doctor/config/state/cleanup, migrations, upgrade/rollback, and uninstall.
|
||||||
- [x] Managed runtime installer coverage is green at 21 pass / 0 fail and 307
|
- [x] Managed runtime installer coverage is green at 24 pass / 0 fail and 336
|
||||||
assertions. The current managed bundle audit records 107 components, 1,830
|
assertions. The deterministic clean macOS arm64 managed-bundle audit records
|
||||||
files, 459,056,031 bytes, and 50 launchers. Setup installs frozen
|
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
|
production-only dependencies; the development-only Claude Agent SDK is
|
||||||
excluded. The Sharp/ngrok closure is included. The Hugging Face sidecar 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
|
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
|
screenshot, status, and stop cleanup. The stop acknowledgement regression
|
||||||
has a 2 pass / 0 fail focused test.
|
has a 2 pass / 0 fail focused test.
|
||||||
- [x] The uninterrupted macOS broad suite is green under singleton isolation:
|
- [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
|
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
|
Windows-safe singleton lane is also green at 2,815 pass / 57 expected skips
|
||||||
/ 0 fail and 8,562 assertions across all 213 curated files; it is not native
|
/ 0 fail and 8,586 assertions across all 213 curated files; it is not native
|
||||||
Windows evidence.
|
Windows evidence.
|
||||||
- [x] Architecture, privacy, Context.dev, host compatibility, upgrade/rollback,
|
- [x] Architecture, privacy, Context.dev, host compatibility, upgrade/rollback,
|
||||||
migration, and governance documentation added.
|
migration, and governance documentation added.
|
||||||
@@ -120,8 +123,8 @@ status.
|
|||||||
- [ ] Prove runtime-absent judgment behavior through an actual host invocation.
|
- [ ] Prove runtime-absent judgment behavior through an actual host invocation.
|
||||||
Runtime install failure and the real default capability lifecycle are covered.
|
Runtime install failure and the real default capability lifecycle are covered.
|
||||||
- [ ] Complete native Linux and Windows matrices. The uninterrupted macOS
|
- [ ] 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
|
broad singleton run is green at 6,240/226/0 across 383 files, and the local
|
||||||
Windows-safe singleton lane is green at 2,813/57/0 across 213 files. Neither
|
Windows-safe singleton lane is green at 2,815/57/0 across 213 files. Neither
|
||||||
substitutes for native execution on its named platform.
|
substitutes for native execution on its named platform.
|
||||||
- [ ] Integrate the passing complete local-browser suite and live journey into
|
- [ ] Integrate the passing complete local-browser suite and live journey into
|
||||||
final cancellation/leak evidence. The production dependency and managed
|
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 |
|
| 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 |
|
| 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 |
|
| 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
|
## Interpretation rules
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ pass from deterministic, offline, or filesystem-only evidence.
|
|||||||
|
|
||||||
| Command / probe | Observed result | What it proves / does not prove |
|
| 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 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. |
|
| `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. |
|
| 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. |
|
| 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. |
|
| `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). |
|
| 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. |
|
| `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. |
|
||||||
| 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. |
|
| 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 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. |
|
| 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. |
|
| `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. |
|
| 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 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. |
|
| `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. |
|
| 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. |
|
| 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. |
|
| `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,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. |
|
| 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 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. |
|
| 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
|
### Required final command ledger
|
||||||
|
|
||||||
|
|||||||
@@ -69,17 +69,21 @@ written under `$GSTACK_HOME/bin` (default `~/.gstack/bin`), alongside
|
|||||||
`runtime-install.json`, which distinguishes managed paths from preserved
|
`runtime-install.json`, which distinguishes managed paths from preserved
|
||||||
config, secrets, projects, and plans.
|
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
|
with spaces, a symlinked source root, internal-link rejection, runtime-only
|
||||||
builder selection, production dependency closure, failed build/validation/
|
builder selection, deterministic exact Sharp/ngrok platform closure, failed
|
||||||
smoke rollback, interrupted recovery, launchers, manifests, wrapper neutrality,
|
build/validation/smoke rollback including native-load rollback smoke,
|
||||||
and state-preserving uninstall.
|
interrupted recovery, launchers, manifests, wrapper neutrality, and
|
||||||
|
state-preserving uninstall.
|
||||||
|
|
||||||
The current managed-bundle audit records 107 components, 1,830 files,
|
The deterministic clean macOS arm64 managed-bundle audit records 110
|
||||||
459,056,031 bytes, and 50 launchers. The Sharp/ngrok closure is included. The
|
components, 1,829 files, 450,044,315 bytes, and 50 capability launchers. This
|
||||||
development-only Claude Agent SDK and Hugging Face sidecar are excluded; the
|
is a platform-specific bundle measurement, not a universal byte count;
|
||||||
Hugging Face package is development-only. Setup therefore installs neither its
|
platform-native package payloads differ. The Sharp/ngrok closure is included.
|
||||||
inference runtime nor model weights and reports the L4 capability unavailable.
|
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
|
A separate clean Linux arm64 container smoke copied the source through a path
|
||||||
with spaces, installed only frozen production dependencies with the development
|
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,
|
matrix separately passed 470/470 checks across six hosts, 16 installs, scopes,
|
||||||
selections, and two removals; its artifact is
|
selections, and two removals; its artifact is
|
||||||
[`evals/installation/install-matrix.json`](../../evals/installation/install-matrix.json).
|
[`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
|
The runtime installer is green at 24 pass / 0 fail and 336 assertions, and the
|
||||||
current 107-component bundle audit is recorded. The clean Linux Dev Container
|
deterministic clean macOS arm64 110-component bundle audit is recorded. Its
|
||||||
install/uninstall smoke passed. Interrupted network acquisition/stage at OS
|
byte count is platform-specific because platform-native package payloads
|
||||||
level, a passing live v3 host run, actual host UI execution, native-host Linux,
|
differ. The clean Linux Dev Container install/uninstall smoke passed.
|
||||||
and native Windows runs remain gates. See
|
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).
|
[TEST-EVIDENCE.md](./TEST-EVIDENCE.md).
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -151,6 +151,10 @@ interface JsonCommandResult {
|
|||||||
command: CommandResult;
|
command: CommandResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ParsedJson =
|
||||||
|
| { ok: true; payload: unknown }
|
||||||
|
| { ok: false; detail: string };
|
||||||
|
|
||||||
interface ApiResponse {
|
interface ApiResponse {
|
||||||
status: number;
|
status: number;
|
||||||
body: Record<string, unknown>;
|
body: Record<string, unknown>;
|
||||||
@@ -283,6 +287,14 @@ function commandDetail(result: CommandResult, lines = 80): string {
|
|||||||
return combined.split('\n').slice(-lines).join('\n');
|
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 {
|
function runJsonDevicectl(args: string[], phase: string): JsonCommandResult {
|
||||||
const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-devicectl-'));
|
const dir = mkdtempSync(join(tmpdir(), 'gstack-ios-devicectl-'));
|
||||||
const output = join(dir, 'result.json');
|
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.'],
|
['Run `sudo xcodebuild -runFirstLaunch`, reconnect the iPhone, and retry.'],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
const parsed = parseJson(readFileSync(output, 'utf8'));
|
||||||
return { payload: JSON.parse(readFileSync(output, 'utf8')), command };
|
if (!parsed.ok) {
|
||||||
} catch (error) {
|
|
||||||
throw new HarnessError(
|
throw new HarnessError(
|
||||||
'device_discovery_bad_response',
|
'device_discovery_bad_response',
|
||||||
'setup_gate',
|
'setup_gate',
|
||||||
phase,
|
phase,
|
||||||
'devicectl returned malformed JSON',
|
'devicectl returned malformed JSON',
|
||||||
['Upgrade or repair Xcode, then verify `xcrun devicectl list devices --json-output <path>` manually.'],
|
['Upgrade or repair Xcode, then verify `xcrun devicectl list devices --json-output <path>` manually.'],
|
||||||
error instanceof Error ? error.message : String(error),
|
parsed.detail,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return { payload: parsed.payload, command };
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(dir, { recursive: true, force: true });
|
rmSync(dir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@@ -1224,6 +1236,7 @@ async function runLiveIteration(
|
|||||||
): Promise<LiveIterationResult> {
|
): Promise<LiveIterationResult> {
|
||||||
let sessionId: string | undefined;
|
let sessionId: string | undefined;
|
||||||
let released = false;
|
let released = false;
|
||||||
|
let primaryError: unknown;
|
||||||
try {
|
try {
|
||||||
const healthBefore = await deviceRequest(tunnel, '/healthz', { token: null });
|
const healthBefore = await deviceRequest(tunnel, '/healthz', { token: null });
|
||||||
requireStatus(healthBefore, 200, 'health before tap');
|
requireStatus(healthBefore, 200, 'health before tap');
|
||||||
@@ -1423,13 +1436,23 @@ async function runLiveIteration(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
} catch (error) {
|
||||||
|
primaryError = error;
|
||||||
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
if (sessionId && !released) {
|
if (sessionId && !released) {
|
||||||
try {
|
try {
|
||||||
await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId });
|
const cleanupRelease = await deviceRequest(tunnel, '/session/release', { method: 'POST', sessionId });
|
||||||
} catch {
|
requireStatus(cleanupRelease, 200, 'failed-iteration session cleanup');
|
||||||
// The aggregate result remains failed. The outer cleanup performs one
|
} catch (cleanupError) {
|
||||||
// final release attempt before the tunnel keepalive is stopped.
|
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<string, unknown> {
|
function serializeUnknownError(error: unknown): Record<string, unknown> {
|
||||||
if (error instanceof HarnessError) return error.toJSON();
|
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 };
|
if (error instanceof Error) return { name: error.name, message: error.message };
|
||||||
return { message: String(error) };
|
return { message: String(error) };
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-13
@@ -37,8 +37,8 @@ import {
|
|||||||
import { rollbackUpgrade } from "./upgrade.js";
|
import { rollbackUpgrade } from "./upgrade.js";
|
||||||
import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
|
import { installManagedRuntime, uninstallManagedRuntime } from "./install.js";
|
||||||
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
|
import { assertManagedHome, withRuntimeLifecycleLock } from "./managed-home.js";
|
||||||
|
import { errorWithCode as cliError } from "./errors.js";
|
||||||
const RUNTIME_VERSION = "2.0.0";
|
import { RUNTIME_VERSION } from "./index.js";
|
||||||
|
|
||||||
export async function main(argv = process.argv.slice(2), options = {}) {
|
export async function main(argv = process.argv.slice(2), options = {}) {
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
@@ -85,7 +85,7 @@ export async function main(argv = process.argv.slice(2), options = {}) {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const json = args.includes("--json");
|
const json = args.includes("--json");
|
||||||
const safeMessage = redactSecrets(error?.message ?? String(error));
|
const safeMessage = redactSensitiveText(error?.message ?? String(error));
|
||||||
if (json) {
|
if (json) {
|
||||||
write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`);
|
write(stderr, `${JSON.stringify({ ok: false, error: error?.code ?? "ERROR", message: safeMessage })}\n`);
|
||||||
} else {
|
} else {
|
||||||
@@ -619,22 +619,12 @@ function write(stream, value) {
|
|||||||
stream.write(value);
|
stream.write(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cliError(message, code) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.code = code;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|
||||||
function exitCodeFor(error) {
|
function exitCodeFor(error) {
|
||||||
if (error?.code === "USAGE") return 2;
|
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;
|
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;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function redactSecrets(message) {
|
|
||||||
return redactSensitiveText(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
function usage() {
|
function usage() {
|
||||||
return `gstack ${RUNTIME_VERSION}\n\n` +
|
return `gstack ${RUNTIME_VERSION}\n\n` +
|
||||||
"Usage:\n" +
|
"Usage:\n" +
|
||||||
|
|||||||
+27
-19
@@ -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
|
// path segment is still scanned independently, avoiding false positives where
|
||||||
// a long mixed-case documentation path looked like one credential.
|
// a long mixed-case documentation path looked like one credential.
|
||||||
const OPAQUE_TOKEN_CANDIDATE = /[A-Za-z0-9._~+=-]{32,}/g;
|
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 MAX_CREDENTIAL_DECODE_PASSES = 8;
|
||||||
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
|
const MIN_OPAQUE_TOKEN_ENTROPY = 4.25;
|
||||||
|
|
||||||
@@ -125,6 +126,10 @@ export class ContextError extends Error {
|
|||||||
this.unsupported = Boolean(options.unsupported);
|
this.unsupported = Boolean(options.unsupported);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static fromCause(code, message, cause, options = {}) {
|
||||||
|
return new ContextError(code, message, { ...options, cause });
|
||||||
|
}
|
||||||
|
|
||||||
toJSON() {
|
toJSON() {
|
||||||
return {
|
return {
|
||||||
name: this.name,
|
name: this.name,
|
||||||
@@ -171,7 +176,7 @@ export function assertPublicUrl(input) {
|
|||||||
try {
|
try {
|
||||||
url = input instanceof URL ? new URL(input.href) : new URL(String(input));
|
url = input instanceof URL ? new URL(input.href) : new URL(String(input));
|
||||||
} catch (cause) {
|
} 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)) {
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed");
|
throw new ContextError("CONTEXT_BLOCKED", "Only HTTP and HTTPS target URLs are allowed");
|
||||||
@@ -245,7 +250,7 @@ export async function assertPublicUrlResolved(input, options = {}) {
|
|||||||
try {
|
try {
|
||||||
records = await lookup(hostname, { all: true, verbatim: true });
|
records = await lookup(hostname, { all: true, verbatim: true });
|
||||||
} catch (cause) {
|
} 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];
|
const list = Array.isArray(records) ? records : [records];
|
||||||
if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) {
|
if (!list.length || list.some((record) => !isPublicIp(record?.address ?? record))) {
|
||||||
@@ -440,9 +445,8 @@ export class ContextClient {
|
|||||||
try {
|
try {
|
||||||
payload = text ? JSON.parse(text) : null;
|
payload = text ? JSON.parse(text) : null;
|
||||||
} catch (cause) {
|
} 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,
|
status: response.status,
|
||||||
cause,
|
|
||||||
secrets: [key],
|
secrets: [key],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -564,7 +568,7 @@ function validateBaseUrl(input) {
|
|||||||
try {
|
try {
|
||||||
url = new URL(String(input));
|
url = new URL(String(input));
|
||||||
} catch (cause) {
|
} 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 ||
|
if (url.origin !== "https://api.context.dev" || url.username || url.password ||
|
||||||
!["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) {
|
!["/v1", "/v1/"].includes(url.pathname) || url.search || url.hash) {
|
||||||
@@ -580,9 +584,7 @@ export function redactSensitiveText(message, knownSecrets = []) {
|
|||||||
const raw = String(secret ?? "");
|
const raw = String(secret ?? "");
|
||||||
if (raw.length < 8) continue;
|
if (raw.length < 8) continue;
|
||||||
exactSecrets.add(raw);
|
exactSecrets.add(raw);
|
||||||
try {
|
if (hasWellFormedUtf16(raw)) exactSecrets.add(encodeURIComponent(raw));
|
||||||
exactSecrets.add(encodeURIComponent(raw));
|
|
||||||
} catch {}
|
|
||||||
}
|
}
|
||||||
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
|
for (const secret of exactSecrets) safe = safe.split(secret).join("[REDACTED]");
|
||||||
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
|
safe = safe.replace(/(\bAuthorization\s*[:=]\s*)[^\r\n,}]+/gi, "$1[REDACTED]");
|
||||||
@@ -644,16 +646,12 @@ function containsCredentialMaterial(value) {
|
|||||||
|
|
||||||
function looksOpaqueCredential(candidate) {
|
function looksOpaqueCredential(candidate) {
|
||||||
const token = candidate.replace(/[.,;:!?]+$/, "");
|
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/, /[._~+/=-]/]
|
const categories = [/[a-z]/, /[A-Z]/, /\d/, /[._~+/=-]/]
|
||||||
.reduce((count, pattern) => count + Number(pattern.test(token)), 0);
|
.reduce((count, pattern) => count + Number(pattern.test(token)), 0);
|
||||||
return categories >= 3 && shannonEntropy(token) >= MIN_OPAQUE_TOKEN_ENTROPY;
|
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) {
|
function isReadablePublicSlug(value) {
|
||||||
const parts = value.split("-");
|
const parts = value.split("-");
|
||||||
if (parts.length < 3) return false;
|
if (parts.length < 3) return false;
|
||||||
@@ -693,6 +691,20 @@ function isSensitiveFieldName(key) {
|
|||||||
/(?:credential|password|secret|signature|token)$/.test(normalized);
|
/(?: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) {
|
function containsCredentialLabel(value) {
|
||||||
let candidate = String(value ?? "");
|
let candidate = String(value ?? "");
|
||||||
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
for (let pass = 0; pass < MAX_CREDENTIAL_DECODE_PASSES; pass += 1) {
|
||||||
@@ -787,12 +799,8 @@ function inIpv4Cidr(value, base, bits) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPublicIpv6(address) {
|
function isPublicIpv6(address) {
|
||||||
let value;
|
// isPublicIp has already required Node's IPv6 parser to accept the address.
|
||||||
try {
|
const value = ipv6BigInt(address);
|
||||||
value = ipv6BigInt(address);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if ((value >> 32n) === 0xffffn) {
|
if ((value >> 32n) === 0xffffn) {
|
||||||
const ipv4 = Number(value & 0xffffffffn);
|
const ipv4 = Number(value & 0xffffffffn);
|
||||||
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
|
return isPublicIpv4(`${ipv4 >>> 24}.${(ipv4 >>> 16) & 255}.${(ipv4 >>> 8) & 255}.${ipv4 & 255}`);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 "./paths.js";
|
||||||
export * from "./managed-home.js";
|
export * from "./managed-home.js";
|
||||||
export * from "./storage.js";
|
export * from "./storage.js";
|
||||||
|
|||||||
+65
-12
@@ -4,6 +4,7 @@ import process from "node:process";
|
|||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { familySync as detectLibcFamilySync, GLIBC, MUSL } from "detect-libc";
|
||||||
import { resolveGstackHome, resolveRuntimePaths, assertPathInside } from "./paths.js";
|
import { resolveGstackHome, resolveRuntimePaths, assertPathInside } from "./paths.js";
|
||||||
import { atomicWriteFile, atomicWriteJson, pathExists, readJson } from "./storage.js";
|
import { atomicWriteFile, atomicWriteJson, pathExists, readJson } from "./storage.js";
|
||||||
import { purgeManagedHomeUnlocked, stageUpgradeUnlocked } from "./upgrade.js";
|
import { purgeManagedHomeUnlocked, stageUpgradeUnlocked } from "./upgrade.js";
|
||||||
@@ -15,6 +16,8 @@ import {
|
|||||||
RUNTIME_TRANSACTION_FILE,
|
RUNTIME_TRANSACTION_FILE,
|
||||||
withRuntimeLifecycleLock,
|
withRuntimeLifecycleLock,
|
||||||
} from "./managed-home.js";
|
} from "./managed-home.js";
|
||||||
|
import { errorWithCode as installError } from "./errors.js";
|
||||||
|
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||||
|
|
||||||
const INSTALL_SCHEMA_VERSION = 2;
|
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),
|
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
|
* The managed bundle is deliberately narrow. Skills remain installed by a
|
||||||
* standards-based Agent Skills installer; this list contains only optional
|
* 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
|
// the compiled CLI: Sharp powers full-page screenshot resizing, while
|
||||||
// ngrok is an explicit opt-in tunnel for pair-agent (never a cloud browser).
|
// ngrok is an explicit opt-in tunnel for pair-agent (never a cloud browser).
|
||||||
entry("node_modules/sharp"),
|
entry("node_modules/sharp"),
|
||||||
entry("node_modules/@img"),
|
...runtimeNativePackagePaths().map((target) => entry(target)),
|
||||||
entry("node_modules/detect-libc"),
|
entry("node_modules/detect-libc"),
|
||||||
entry("node_modules/semver"),
|
entry("node_modules/semver"),
|
||||||
entry("node_modules/@ngrok"),
|
|
||||||
entry("node_modules/@anthropic-ai/sdk"),
|
entry("node_modules/@anthropic-ai/sdk"),
|
||||||
entry(platformBinary("design/dist/design"), "core", true),
|
entry(platformBinary("design/dist/design"), "core", true),
|
||||||
entry("design/dist/.version", "core"),
|
entry("design/dist/.version", "core"),
|
||||||
@@ -516,6 +562,23 @@ export async function smokeRuntimeBundle(directory, options = {}) {
|
|||||||
if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) {
|
if (!/gstack/i.test(`${result?.stdout ?? ""}${result?.stderr ?? ""}`)) {
|
||||||
throw installError("Runtime launcher smoke test returned an unexpected response", "INSTALL_SMOKE_FAILED");
|
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 = {}) {
|
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";
|
"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;
|
const invokedPath = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : null;
|
||||||
if (invokedPath === import.meta.url) {
|
if (invokedPath === import.meta.url) {
|
||||||
process.exitCode = await runInstallerCli();
|
process.exitCode = await runInstallerCli();
|
||||||
|
|||||||
+4
-11
@@ -4,6 +4,8 @@ import path from "node:path";
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
import { assertPathInside, resolveRuntimePaths } from "./paths.js";
|
||||||
import { atomicWriteFile, atomicWriteJson, readJson, withLock } from "./storage.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_SCHEMA_VERSION = 1;
|
||||||
export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json";
|
export const MANAGED_HOME_SENTINEL = ".gstack-managed-home.json";
|
||||||
@@ -142,7 +144,8 @@ async function inspectRecognizedLegacyHome(home, entries) {
|
|||||||
try {
|
try {
|
||||||
privacyMap = JSON.parse(privacyText);
|
privacyMap = JSON.parse(privacyText);
|
||||||
} catch {
|
} 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) =>
|
const hasCanonicalPrivacyEntry = Array.isArray(privacyMap) && privacyMap.some((entry) =>
|
||||||
entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact",
|
entry?.pattern === "projects/*/learnings.jsonl" && entry?.class === "artifact",
|
||||||
@@ -302,13 +305,3 @@ function isSameOrAncestor(candidate, target) {
|
|||||||
const relative = path.relative(candidate, target);
|
const relative = path.relative(candidate, target);
|
||||||
return relative === "" || relative === "." || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
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();
|
|
||||||
}
|
|
||||||
|
|||||||
+31
-47
@@ -4,6 +4,8 @@ import { appendJsonLine, atomicWriteJson, readJson, withLock } from "./storage.j
|
|||||||
import { projectPaths } from "./paths.js";
|
import { projectPaths } from "./paths.js";
|
||||||
import { discoverProjectIdentity } from "./identity.js";
|
import { discoverProjectIdentity } from "./identity.js";
|
||||||
import { RUNTIME_SCHEMA_VERSION } from "./migrations.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"];
|
const PROJECT_DIRECTORIES = ["evidence", "artifacts", "reviews", "checkpoints"];
|
||||||
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
|
export const WORKFLOW_STATE_SCHEMA_VERSION = 1;
|
||||||
@@ -126,9 +128,7 @@ export async function inspectProject(home, identityOrId) {
|
|||||||
const paths = projectPaths(home, id);
|
const paths = projectPaths(home, id);
|
||||||
const state = await readJson(paths.state, null);
|
const state = await readJson(paths.state, null);
|
||||||
if (!state) {
|
if (!state) {
|
||||||
const error = new Error(`No state found for project ${id}`);
|
throw errorWithCode(`No state found for project ${id}`, "STATE_NOT_FOUND");
|
||||||
error.code = "STATE_NOT_FOUND";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
assertSupportedState(state, paths.state);
|
assertSupportedState(state, paths.state);
|
||||||
return { paths, state };
|
return { paths, state };
|
||||||
@@ -143,7 +143,7 @@ export async function inspectRun(home, projectId, runId) {
|
|||||||
validateRunId(runId);
|
validateRunId(runId);
|
||||||
const { paths, state } = await inspectProject(home, projectId);
|
const { paths, state } = await inspectProject(home, projectId);
|
||||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
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 {
|
return {
|
||||||
paths,
|
paths,
|
||||||
state,
|
state,
|
||||||
@@ -173,9 +173,7 @@ export async function beginRun(home, projectId, command, options = {}) {
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||||
if (Object.hasOwn(state.runs, runId)) {
|
if (Object.hasOwn(state.runs, runId)) {
|
||||||
const error = new Error(`Run already exists: ${runId}`);
|
throw errorWithCode(`Run already exists: ${runId}`, "RUN_EXISTS");
|
||||||
error.code = "RUN_EXISTS";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
const workflow = createWorkflowState(command, options, now);
|
const workflow = createWorkflowState(command, options, now);
|
||||||
state.runs[runId] = {
|
state.runs[runId] = {
|
||||||
@@ -206,14 +204,10 @@ export async function resumeRun(home, projectId, runId, options = {}) {
|
|||||||
if (selected) validateRunId(selected);
|
if (selected) validateRunId(selected);
|
||||||
const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null;
|
const run = selected && Object.hasOwn(state.runs, selected) ? state.runs[selected] : null;
|
||||||
if (!run) {
|
if (!run) {
|
||||||
const error = new Error(selected ? `Run not found: ${selected}` : "No resumable run found");
|
throw errorWithCode(selected ? `Run not found: ${selected}` : "No resumable run found", "RUN_NOT_FOUND");
|
||||||
error.code = "RUN_NOT_FOUND";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
if (run.status === "completed") {
|
if (run.status === "completed") {
|
||||||
const error = new Error(`Run is already complete: ${run.id}`);
|
throw errorWithCode(`Run is already complete: ${run.id}`, "RUN_COMPLETED");
|
||||||
error.code = "RUN_COMPLETED";
|
|
||||||
throw error;
|
|
||||||
}
|
}
|
||||||
for (const effect of Object.values(run.effects ?? {})) {
|
for (const effect of Object.values(run.effects ?? {})) {
|
||||||
if (effect.status === "in_progress") {
|
if (effect.status === "in_progress") {
|
||||||
@@ -242,14 +236,14 @@ export async function completeRun(home, projectId, runId, options = {}) {
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
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) =>
|
const unresolved = Object.values(run.effects ?? {}).filter((effect) =>
|
||||||
["ready", "in_progress", "uncertain"].includes(effect.status));
|
["ready", "in_progress", "uncertain"].includes(effect.status));
|
||||||
if (unresolved.length && !options.allowUncertain) {
|
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) {
|
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.status = "completed";
|
||||||
run.completedAt = now;
|
run.completedAt = now;
|
||||||
@@ -277,10 +271,10 @@ export async function updateRunWorkflow(home, projectId, runId, transition, opti
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
const { state, result } = await updateWithEvent(paths, async (state) => {
|
const { state, result } = await updateWithEvent(paths, async (state) => {
|
||||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
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");
|
||||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED");
|
||||||
if (state.activeRunId !== runId) {
|
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);
|
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 now = isoNow(options.now);
|
||||||
const claimed = await updateWithEvent(paths, async (state) => {
|
const claimed = await updateWithEvent(paths, async (state) => {
|
||||||
const run = Object.hasOwn(state.runs, runId) ? state.runs[runId] : null;
|
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");
|
||||||
if (run.status === "completed") throw codedError("RUN_COMPLETED", `Run is already complete: ${runId}`);
|
if (run.status === "completed") throw errorWithCode(`Run is already complete: ${runId}`, "RUN_COMPLETED");
|
||||||
if (state.activeRunId !== runId) {
|
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) {
|
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)) {
|
if (!EXTERNAL_EFFECT_AUTHORITIES.has(run.workflow.mutationAuthority)) {
|
||||||
throw codedError(
|
throw errorWithCode(
|
||||||
"MUTATION_NOT_AUTHORIZED",
|
|
||||||
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
|
`Mutation authority ${run.workflow.mutationAuthority} does not permit external effects`,
|
||||||
|
"MUTATION_NOT_AUTHORIZED",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
run.effects = normalizeRecord(run.effects, validateEffectKey, "effects");
|
||||||
@@ -385,9 +379,9 @@ export async function completeExternalEffect(home, projectId, runId, effectKey,
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
return updateWithEvent(paths, async (state) => {
|
return updateWithEvent(paths, async (state) => {
|
||||||
const effect = ownedEffect(state, runId, effectKey);
|
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") {
|
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.status = "completed";
|
||||||
effect.completedAt = now;
|
effect.completedAt = now;
|
||||||
@@ -406,9 +400,9 @@ export async function markEffectNotApplied(home, projectId, runId, effectKey, op
|
|||||||
const paths = projectPaths(home, projectId);
|
const paths = projectPaths(home, projectId);
|
||||||
return updateWithEvent(paths, async (state) => {
|
return updateWithEvent(paths, async (state) => {
|
||||||
const effect = ownedEffect(state, runId, effectKey);
|
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") {
|
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.status = "ready";
|
||||||
effect.reconciledAt = isoNow(options.now);
|
effect.reconciledAt = isoNow(options.now);
|
||||||
@@ -430,9 +424,9 @@ export async function markEffectApplied(home, projectId, runId, effectKey, evide
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
return updateWithEvent(paths, async (state) => {
|
return updateWithEvent(paths, async (state) => {
|
||||||
const effect = ownedEffect(state, runId, effectKey);
|
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") {
|
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.status = "completed";
|
||||||
effect.completedAt = now;
|
effect.completedAt = now;
|
||||||
@@ -460,7 +454,7 @@ async function markEffectUncertain(home, projectId, runId, effectKey, cause, opt
|
|||||||
const now = isoNow(options.now);
|
const now = isoNow(options.now);
|
||||||
return updateWithEvent(paths, async (state) => {
|
return updateWithEvent(paths, async (state) => {
|
||||||
const effect = ownedEffect(state, runId, effectKey);
|
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.status = "uncertain";
|
||||||
effect.uncertainAt = now;
|
effect.uncertainAt = now;
|
||||||
effect.reason = String(cause?.message ?? cause ?? "unknown external error").slice(0, 500);
|
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}`);
|
throw new Error(`Invalid state schema in ${file}`);
|
||||||
}
|
}
|
||||||
if (state.schemaVersion > RUNTIME_SCHEMA_VERSION) {
|
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}`);
|
if (!Number.isInteger(state.revision) || state.revision < 0) throw new Error(`Invalid state revision in ${file}`);
|
||||||
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
|
state.runs = normalizeRecord(state.runs, validateRunId, "runs");
|
||||||
@@ -607,7 +601,7 @@ function createWorkflowState(command, options, now) {
|
|||||||
function validateWorkflowState(workflow, label) {
|
function validateWorkflowState(workflow, label) {
|
||||||
assertPlainRecord(workflow, label, WORKFLOW_KEYS);
|
assertPlainRecord(workflow, label, WORKFLOW_KEYS);
|
||||||
if (workflow.schemaVersion !== WORKFLOW_STATE_SCHEMA_VERSION) {
|
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);
|
validateOptionalPointer(workflow.currentPlanPointer);
|
||||||
validateGoal(workflow.originalGoal, "original goal");
|
validateGoal(workflow.originalGoal, "original goal");
|
||||||
@@ -705,7 +699,7 @@ function applyWorkflowTransition(workflow, transition, now) {
|
|||||||
changes.push("detourStack.push");
|
changes.push("detourStack.push");
|
||||||
}
|
}
|
||||||
if (transition.popDetour === true) {
|
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();
|
workflow.detourStack.pop();
|
||||||
changes.push("detourStack.pop");
|
changes.push("detourStack.pop");
|
||||||
}
|
}
|
||||||
@@ -732,7 +726,7 @@ function applyWorkflowTransition(workflow, transition, now) {
|
|||||||
if (Object.hasOwn(transition, "addApprovalGate")) {
|
if (Object.hasOwn(transition, "addApprovalGate")) {
|
||||||
const input = transition.addApprovalGate;
|
const input = transition.addApprovalGate;
|
||||||
if (workflow.pendingApprovalGates.some((gate) => gate.id === input.id)) {
|
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({
|
workflow.pendingApprovalGates.push({
|
||||||
id: input.id,
|
id: input.id,
|
||||||
@@ -744,7 +738,7 @@ function applyWorkflowTransition(workflow, transition, now) {
|
|||||||
if (Object.hasOwn(transition, "resolveApprovalGate")) {
|
if (Object.hasOwn(transition, "resolveApprovalGate")) {
|
||||||
const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate);
|
const index = workflow.pendingApprovalGates.findIndex((gate) => gate.id === transition.resolveApprovalGate);
|
||||||
if (index === -1) {
|
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);
|
workflow.pendingApprovalGates.splice(index, 1);
|
||||||
changes.push("pendingApprovalGates.resolve");
|
changes.push("pendingApprovalGates.resolve");
|
||||||
@@ -950,10 +944,6 @@ function stableIdempotencyKey(projectId, runId, effectKey) {
|
|||||||
return `gstack_${digest}`;
|
return `gstack_${digest}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isoNow(now) {
|
|
||||||
return (now ? now() : new Date()).toISOString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function jsonSafe(value) {
|
function jsonSafe(value) {
|
||||||
if (value === undefined) return null;
|
if (value === undefined) return null;
|
||||||
try {
|
try {
|
||||||
@@ -962,9 +952,3 @@ function jsonSafe(value) {
|
|||||||
return String(value);
|
return String(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function codedError(code, message) {
|
|
||||||
const error = new Error(message);
|
|
||||||
error.code = code;
|
|
||||||
return error;
|
|
||||||
}
|
|
||||||
|
|||||||
+22
-7
@@ -119,12 +119,10 @@ export async function acquireLock(lockPath, options = {}) {
|
|||||||
if (released) return;
|
if (released) return;
|
||||||
released = true;
|
released = true;
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
try {
|
// Locks are leases. A stale-lock reaper may already have removed it,
|
||||||
const current = await readJson(path.join(lockPath, "owner.json"), null);
|
// which readJson represents as null; other failures remain actionable.
|
||||||
if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true });
|
const current = await readJson(path.join(lockPath, "owner.json"), null);
|
||||||
} catch {
|
if (current?.token === token) await fs.rm(lockPath, { recursive: true, force: true });
|
||||||
// Locks are leases. A stale-lock reaper may already have removed it.
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.code !== "EEXIST") throw error;
|
if (error?.code !== "EEXIST") throw error;
|
||||||
@@ -168,10 +166,27 @@ function processIsAlive(pid) {
|
|||||||
|
|
||||||
export async function withLock(lockPath, callback, options = {}) {
|
export async function withLock(lockPath, callback, options = {}) {
|
||||||
const release = await acquireLock(lockPath, options);
|
const release = await acquireLock(lockPath, options);
|
||||||
|
let callbackError;
|
||||||
|
let callbackFailed = false;
|
||||||
try {
|
try {
|
||||||
return await callback();
|
return await callback();
|
||||||
|
} catch (error) {
|
||||||
|
callbackFailed = true;
|
||||||
|
callbackError = error;
|
||||||
|
throw error;
|
||||||
} finally {
|
} 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
+9
-10
@@ -9,6 +9,8 @@ import {
|
|||||||
recoverRuntimeTransactionUnlocked,
|
recoverRuntimeTransactionUnlocked,
|
||||||
withRuntimeLifecycleLock,
|
withRuntimeLifecycleLock,
|
||||||
} from "./managed-home.js";
|
} from "./managed-home.js";
|
||||||
|
import { errorWithCode as upgradeError } from "./errors.js";
|
||||||
|
import { currentIsoTimestamp as isoNow } from "./time.js";
|
||||||
|
|
||||||
export async function stageUpgrade(options) {
|
export async function stageUpgrade(options) {
|
||||||
const home = path.resolve(options.home);
|
const home = path.resolve(options.home);
|
||||||
@@ -133,6 +135,13 @@ export async function stageUpgradeUnlocked(options) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
rollbackErrors.push(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);
|
const error = upgradeError(`Upgrade ${version} failed health checks and was rolled back`, "UPGRADE_ROLLED_BACK", cause);
|
||||||
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
|
if (rollbackErrors.length === 1) error.rollbackError = rollbackErrors[0];
|
||||||
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete");
|
else if (rollbackErrors.length > 1) error.rollbackError = new AggregateError(rollbackErrors, "Runtime rollback was incomplete");
|
||||||
@@ -335,13 +344,3 @@ async function isRealDirectory(directory) {
|
|||||||
function emptyPointer() {
|
function emptyPointer() {
|
||||||
return { schemaVersion: 2, status: "inactive", current: null, lastKnownGood: null };
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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<string, unknown> & {
|
||||||
|
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<void> {
|
||||||
|
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 <path>]');
|
||||||
|
}
|
||||||
|
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();
|
||||||
@@ -1,5 +1,51 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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 install --frozen-lockfile
|
||||||
bun run test:gstack2
|
bun run test:gstack2
|
||||||
|
|||||||
@@ -541,10 +541,6 @@ interface RenderedModuleRecord {
|
|||||||
disposition: string;
|
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
|
* Compute the complete module graph for each independently installable public
|
||||||
* skill. Owner modules are roots because compatibility aliases may select any
|
* skill. Owner modules are roots because compatibility aliases may select any
|
||||||
@@ -570,7 +566,11 @@ function packageModuleClosure(rendered: Map<string, RenderedModuleRecord>): Map<
|
|||||||
for (const source of [...sources]) {
|
for (const source of [...sources]) {
|
||||||
const module = rendered.get(source);
|
const module = rendered.get(source);
|
||||||
if (!module) throw new Error(`${tree} references unknown preserved module ${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 (!rendered.has(dependency)) throw new Error(`${source} references unknown preserved module ${dependency}`);
|
||||||
if (!sources.has(dependency)) {
|
if (!sources.has(dependency)) {
|
||||||
sources.add(dependency);
|
sources.add(dependency);
|
||||||
|
|||||||
@@ -645,10 +645,13 @@ export function validateStructuredResult(value: unknown): value is StructuredHos
|
|||||||
|
|
||||||
function parseJsonCandidate(text: string): unknown {
|
function parseJsonCandidate(text: string): unknown {
|
||||||
const trimmed = text.trim();
|
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];
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1];
|
||||||
if (fenced) return JSON.parse(fenced);
|
try {
|
||||||
throw new Error('final agent message was not JSON');
|
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[]): {
|
export function parseStructuredFinal(messages: string[]): {
|
||||||
@@ -842,7 +845,9 @@ function stageAuthentication(codexHome: string): void {
|
|||||||
if (!fs.existsSync(source)) return;
|
if (!fs.existsSync(source)) return;
|
||||||
const destination = path.join(codexHome, 'auth.json');
|
const destination = path.join(codexHome, 'auth.json');
|
||||||
fs.copyFileSync(source, destination);
|
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: {
|
async function runFixture(options: {
|
||||||
|
|||||||
@@ -221,9 +221,7 @@ function policyUnitTranscript() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function containsSensitiveMaterial(value: string): boolean {
|
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-----)/;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeLivePrompt(value: string): string {
|
function sanitizeLivePrompt(value: string): string {
|
||||||
return value
|
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.`;
|
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<string, string> }> {
|
async function runClaude(prompt: string, model: string, maxBudgetUsd: number): Promise<{ raw: string; parsed: Record<string, string> }> {
|
||||||
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([
|
const proc = Bun.spawn([
|
||||||
'claude', '-p', '--bare', '--no-session-persistence', '--disable-slash-commands', '--no-chrome',
|
'claude', '-p', '--bare', '--no-session-persistence', '--disable-slash-commands', '--no-chrome',
|
||||||
'--model', model, '--max-turns', '1', '--max-budget-usd', maxBudgetUsd.toFixed(2),
|
'--model', model, '--max-turns', '1', '--max-budget-usd', maxBudgetUsd.toFixed(2),
|
||||||
|
|||||||
@@ -344,11 +344,14 @@ function listInstalledSkills(root: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stripTerminalControls(value: string): string {
|
export function stripTerminalControls(value: string): string {
|
||||||
return value
|
const terminalControls = [
|
||||||
.replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '')
|
/\x1B\][^\x07]*(?:\x07|\x1B\\)/g,
|
||||||
.replace(/\x1B\[[0-?]*[ -/]*[@-~]/g, '')
|
/\x1B\[[0-?]*[ -/]*[@-~]/g,
|
||||||
.replace(/\r(?=[^\n])/g, '')
|
/\r(?=[^\n])/g,
|
||||||
.trim();
|
];
|
||||||
|
let clean = value;
|
||||||
|
for (const control of terminalControls) clean = clean.replace(control, '');
|
||||||
|
return clean.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimEvidenceOutput(value: string, maxCharacters = 16_000): string {
|
function trimEvidenceOutput(value: string, maxCharacters = 16_000): string {
|
||||||
@@ -555,9 +558,7 @@ function runRemoval(options: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runFastChecks(repoRoot = DEFAULT_REPO_ROOT): RepositoryInspection {
|
export const runFastChecks = inspectRepository;
|
||||||
return inspectRepository(repoRoot);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence {
|
export function runFullMatrix(options: FullMatrixOptions): InstallMatrixEvidence {
|
||||||
if (!options.outputPath) throw new Error('Full install matrix requires a caller-supplied outputPath');
|
if (!options.outputPath) throw new Error('Full install matrix requires a caller-supplied outputPath');
|
||||||
|
|||||||
+12
-1
@@ -18,13 +18,24 @@ import * as path from "path";
|
|||||||
|
|
||||||
const base = process.argv[2] || "main";
|
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
|
// 1. Find changed files
|
||||||
const diffResult = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], {
|
const diffResult = spawnSync("git", ["diff", "--name-only", `${base}...HEAD`], {
|
||||||
encoding: "utf-8",
|
encoding: "utf-8",
|
||||||
timeout: 10000,
|
timeout: 10000,
|
||||||
});
|
});
|
||||||
const changedFiles = new Set(
|
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) {
|
if (changedFiles.size === 0) {
|
||||||
console.log("No files changed vs", base, "— nothing to check.");
|
console.log("No files changed vs", base, "— nothing to check.");
|
||||||
|
|||||||
+24
-17
@@ -119,10 +119,11 @@ export const DEFAULT_SHARD_COUNT = 20;
|
|||||||
export const DEFAULT_MAX_FILES_PER_SHARD = 20;
|
export const DEFAULT_MAX_FILES_PER_SHARD = 20;
|
||||||
export const FREE_TEST_TIMEOUT_MS = 10_000;
|
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
|
// Deliberately require column zero. That identifies conventional module-scope
|
||||||
// setup while avoiding process.env changes indented inside hooks and tests.
|
// 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 {
|
export function normalizeRelativePath(filePath: string): string {
|
||||||
return filePath.replace(/\\/g, '/');
|
return filePath.replace(/\\/g, '/');
|
||||||
@@ -233,27 +234,29 @@ export function assignFilesToShards(files: string[], shardCount: number): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function containsScheduledProcessExitZero(source: string): boolean {
|
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 {
|
export function hasScheduledProcessExitZero(absolutePath: string): boolean {
|
||||||
try {
|
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||||
return containsScheduledProcessExitZero(fs.readFileSync(absolutePath, 'utf8'));
|
return containsScheduledProcessExitZero(source);
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function containsTopLevelProcessEnvMutation(source: string): boolean {
|
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 {
|
export function hasTopLevelProcessEnvMutation(absolutePath: string): boolean {
|
||||||
try {
|
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||||
return containsTopLevelProcessEnvMutation(fs.readFileSync(absolutePath, 'utf8'));
|
return containsTopLevelProcessEnvMutation(source);
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BoundedShardOptions {
|
export interface BoundedShardOptions {
|
||||||
@@ -276,13 +279,17 @@ export function planBoundedFreeTestShards(
|
|||||||
throw new Error(`Maximum files per shard must be a positive integer. Received: ${maxFilesPerShard}`);
|
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 normal: string[] = [];
|
||||||
const isolated: string[] = [];
|
const isolated: string[] = [];
|
||||||
for (const file of [...new Set(files)].sort()) {
|
for (const file of orderedFiles) {
|
||||||
const absolutePath = path.join(rootDir, file);
|
const absolutePath = path.join(rootDir, file);
|
||||||
|
const source = fs.readFileSync(absolutePath, 'utf8');
|
||||||
if (
|
if (
|
||||||
hasScheduledProcessExitZero(absolutePath)
|
containsScheduledProcessExitZero(source)
|
||||||
|| hasTopLevelProcessEnvMutation(absolutePath)
|
|| containsTopLevelProcessEnvMutation(source)
|
||||||
) isolated.push(file);
|
) isolated.push(file);
|
||||||
else normal.push(file);
|
else normal.push(file);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,17 +53,6 @@ const DEFAULT_TERMINATION_TIMER: TerminationTimerApi = {
|
|||||||
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
cancel: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
||||||
};
|
};
|
||||||
|
|
||||||
function killWithoutThrowing(
|
|
||||||
child: Pick<ChildProcess, 'kill'>,
|
|
||||||
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
|
* 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,
|
* 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 => {
|
const forward = (signal: ForwardedTerminationSignal): void => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (receivedSignal !== null) {
|
if (receivedSignal !== null) {
|
||||||
killWithoutThrowing(child, 'SIGKILL');
|
child.kill('SIGKILL');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
receivedSignal = signal;
|
receivedSignal = signal;
|
||||||
killWithoutThrowing(child, signal);
|
child.kill(signal);
|
||||||
forceTimer = timer.schedule(() => {
|
forceTimer = timer.schedule(() => {
|
||||||
forceTimer = null;
|
forceTimer = null;
|
||||||
killWithoutThrowing(child, 'SIGKILL');
|
child.kill('SIGKILL');
|
||||||
}, graceMs);
|
}, graceMs);
|
||||||
};
|
};
|
||||||
const onSigint = () => forward('SIGINT');
|
const onSigint = () => forward('SIGINT');
|
||||||
const onSigterm = () => forward('SIGTERM');
|
const onSigterm = () => forward('SIGTERM');
|
||||||
const onExit = () => killWithoutThrowing(child, 'SIGKILL');
|
const onExit = () => { child.kill('SIGKILL'); };
|
||||||
|
|
||||||
source.on('SIGINT', onSigint);
|
source.on('SIGINT', onSigint);
|
||||||
source.on('SIGTERM', onSigterm);
|
source.on('SIGTERM', onSigterm);
|
||||||
@@ -283,7 +272,7 @@ export async function runStrictTestShard(files: string[]): Promise<number> {
|
|||||||
const forwarding = installChildSignalForwarding(child);
|
const forwarding = installChildSignalForwarding(child);
|
||||||
|
|
||||||
if (!child.stdout || !child.stderr) {
|
if (!child.stdout || !child.stderr) {
|
||||||
killWithoutThrowing(child, 'SIGKILL');
|
child.kill('SIGKILL');
|
||||||
forwarding.dispose();
|
forwarding.dispose();
|
||||||
throw new Error('Bun test output pipes were not created');
|
throw new Error('Bun test output pipes were not created');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,16 +49,9 @@ if ! command -v bun >/dev/null 2>&1; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! "$NODE_COMMAND" -e '
|
# Reconcile on every setup. Presence checks cannot distinguish the lockfile's
|
||||||
const fs = require("node:fs");
|
# exact versions from stale but loadable packages copied from another checkout.
|
||||||
const path = require("node:path");
|
(cd "$ROOT" && bun install --production --frozen-lockfile)
|
||||||
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
|
|
||||||
|
|
||||||
if [ "${#ARGS[@]}" -gt 0 ]; then
|
if [ "${#ARGS[@]}" -gt 0 ]; then
|
||||||
exec "$NODE_COMMAND" "$ROOT/runtime/install.js" --source "$ROOT" "${ARGS[@]}"
|
exec "$NODE_COMMAND" "$ROOT/runtime/install.js" --source "$ROOT" "${ARGS[@]}"
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { DEFAULT_RUNTIME_BUNDLE } from "../runtime/install.js";
|
import { DEFAULT_RUNTIME_BUNDLE } from "../runtime/install.js";
|
||||||
|
|
||||||
const root = path.resolve(import.meta.dir, "..");
|
const root = path.resolve(import.meta.dir, "..");
|
||||||
const dockerfile = fs.readFileSync(path.join(root, ".devcontainer", "Dockerfile"), "utf8");
|
const dockerfile = fs.readFileSync(path.join(root, ".devcontainer", "Dockerfile"), "utf8");
|
||||||
const workflow = fs.readFileSync(path.join(root, ".github", "workflows", "gstack2-gate.yml"), "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 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 packageJson = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
|
||||||
const iosSources = [
|
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}$/);
|
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", () => {
|
test("drives a loopback page through the installed browser", () => {
|
||||||
expect(smoke).not.toContain("bun install --frozen-lockfile");
|
expect(smoke).not.toContain("bun install --frozen-lockfile");
|
||||||
expect(smoke).toContain('test ! -e "$REPO/node_modules/@anthropic-ai/claude-agent-sdk"');
|
expect(smoke).toContain('test ! -e "$REPO/node_modules/@anthropic-ai/claude-agent-sdk"');
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
runExternalEffect,
|
runExternalEffect,
|
||||||
setupRuntime,
|
setupRuntime,
|
||||||
updateProjectState,
|
updateProjectState,
|
||||||
|
withLock,
|
||||||
} from "../runtime/index.js";
|
} from "../runtime/index.js";
|
||||||
|
|
||||||
const temporaryRoots: string[] = [];
|
const temporaryRoots: string[] = [];
|
||||||
@@ -280,4 +281,24 @@ describe("gstack 2 host-neutral paths and state", () => {
|
|||||||
await fs.chmod(readOnly, 0o700);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,14 +3,17 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { main as runtimeMain } from "../runtime/cli.js";
|
import { main as runtimeMain } from "../runtime/cli.js";
|
||||||
|
import { summarizeRuntimeBundle } from "../scripts/gstack2/audit-runtime-bundle";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CAPABILITY_LAUNCHERS,
|
DEFAULT_CAPABILITY_LAUNCHERS,
|
||||||
DEFAULT_RUNTIME_BUNDLE,
|
DEFAULT_RUNTIME_BUNDLE,
|
||||||
DEFAULT_RUNTIME_HELPERS,
|
DEFAULT_RUNTIME_HELPERS,
|
||||||
defaultBunBuilder,
|
defaultBunBuilder,
|
||||||
installManagedRuntime,
|
installManagedRuntime,
|
||||||
|
runtimeNativePackagePaths,
|
||||||
uninstallManagedRuntime,
|
uninstallManagedRuntime,
|
||||||
runCommand,
|
runCommand,
|
||||||
|
smokeRuntimeBundle,
|
||||||
validateRuntimeBundle,
|
validateRuntimeBundle,
|
||||||
} from "../runtime/install.js";
|
} 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));
|
const bundlePaths = new Set(DEFAULT_RUNTIME_BUNDLE.map((item) => item.path));
|
||||||
for (const dependency of [
|
for (const dependency of [
|
||||||
"node_modules/sharp",
|
"node_modules/sharp",
|
||||||
"node_modules/@img",
|
|
||||||
"node_modules/detect-libc",
|
"node_modules/detect-libc",
|
||||||
"node_modules/semver",
|
"node_modules/semver",
|
||||||
"node_modules/@ngrok",
|
...runtimeNativePackagePaths(),
|
||||||
]) expect(bundlePaths.has(dependency)).toBe(true);
|
]) 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);
|
expect([...bundlePaths].some((item) => item.includes("@huggingface"))).toBe(false);
|
||||||
for (const helper of contract.helpers) {
|
for (const helper of contract.helpers) {
|
||||||
expect(bundlePaths.has(helper.source_path)).toBe(true);
|
expect(bundlePaths.has(helper.source_path)).toBe(true);
|
||||||
@@ -180,6 +184,75 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
}, { createDefaultSource: false });
|
}, { createDefaultSource: false });
|
||||||
}, 30_000);
|
}, 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 () => {
|
test("failed validation and failed smoke checks roll back activation", async () => {
|
||||||
await withFixture(async ({ source, home }) => {
|
await withFixture(async ({ source, home }) => {
|
||||||
await installFixture(source, home, "1.0.0");
|
await installFixture(source, home, "1.0.0");
|
||||||
@@ -193,6 +266,10 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
smokeTest: async () => { throw new Error("smoke failed"); },
|
smokeTest: async () => { throw new Error("smoke failed"); },
|
||||||
})).rejects.toMatchObject({ code: "UPGRADE_ROLLED_BACK" });
|
})).rejects.toMatchObject({ code: "UPGRADE_ROLLED_BACK" });
|
||||||
expect(await activeVersion(home)).toBe("1.0.0");
|
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 () => {
|
test("public upgrade reuses managed validation and rejects arbitrary or symlinked sources", async () => {
|
||||||
if (process.platform === "win32") return;
|
if (process.platform === "win32") return;
|
||||||
await withFixture(async ({ root, source, home }) => {
|
await withFixture(async ({ root, source, home }) => {
|
||||||
@@ -478,15 +594,18 @@ describe("GStack 2 managed runtime installer", () => {
|
|||||||
const log = path.join(root, "bun.log");
|
const log = path.join(root, "bun.log");
|
||||||
await fs.mkdir(fakeBin);
|
await fs.mkdir(fakeBin);
|
||||||
await fs.mkdir(runtime);
|
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.copyFile(path.join(REPO_ROOT, "setup"), path.join(root, "setup"));
|
||||||
await fs.chmod(path.join(root, "setup"), 0o755);
|
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(runtime, "install.js"), 'console.log(`installer=${process.release.name}`);\n');
|
||||||
await fs.writeFile(path.join(fakeBin, "bun"), `#!/bin/sh
|
await fs.writeFile(path.join(fakeBin, "bun"), `#!/bin/sh
|
||||||
printf '%s\\n' "$*" >> "$BUN_LOG"
|
printf '%s\\n' "$*" >> "$BUN_LOG"
|
||||||
mkdir -p "$FIXTURE_ROOT/node_modules/fixture-dependency"
|
mkdir -p "$FIXTURE_ROOT/node_modules/@img/sharp-fixture"
|
||||||
printf '{"name":"fixture-dependency"}\\n' > "$FIXTURE_ROOT/node_modules/fixture-dependency/package.json"
|
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 });
|
`, { mode: 0o755 });
|
||||||
|
|
||||||
const result = await runCommand(path.join(root, "setup"), [], {
|
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");
|
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(await exists(path.join(root, "node_modules", "test-only-sdk"))).toBe(false);
|
||||||
expect(second.stdout).toContain("installer=node");
|
expect(second.stdout).toContain("installer=node");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
Reference in New Issue
Block a user