Skip to content

Commit 6f4ffe0

Browse files
committed
Merge remote-tracking branch 'origin/main' into garrytan/lyon-v2
2 parents ab4eb35 + 386fe51 commit 6f4ffe0

14 files changed

Lines changed: 1056 additions & 87 deletions

CHANGELOG.md

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,100 @@
11
# Changelog
22

3+
## [1.34.1.0] - 2026-05-13
4+
5+
## **`gstack-update-check` resolves remote VERSION via a SHA-pinned URL.**
6+
## **A semver-order guard makes sure the script never proposes a downgrade.**
7+
8+
The version check now runs `git ls-remote https://github.com/garrytan/gstack.git refs/heads/main` to get the live HEAD SHA, then fetches `raw.githubusercontent.com/garrytan/gstack/<SHA>/VERSION`. SHA-pinned raw URLs are immediately consistent, so a freshly-published VERSION shows up right away instead of trailing behind the branch-raw CDN by several minutes. A second guard treats `REMOTE < LOCAL` as up-to-date, so transient stale-CDN responses and dev installs running ahead of main can never produce a backwards `UPGRADE_AVAILABLE` line. The `git ls-remote` call is fenced with `GIT_TERMINAL_PROMPT=0` plus a 5-second low-speed timeout so flaky networks and captive portals cannot hang a skill preamble.
9+
10+
### The numbers that matter
11+
12+
Source: `bun test browse/test/gstack-update-check.test.ts` — 35 existing tests + 3 new semver-guard tests, all green in 1.65s.
13+
14+
| Surface | Before | After |
15+
|---|---|---|
16+
| Remote VERSION fetch | branch-raw URL (`/garrytan/gstack/main/VERSION`), can serve stale content for minutes after a push | `git ls-remote` SHA, then SHA-pinned raw URL (immediately consistent), branch-raw kept as fallback |
17+
| Behavior when REMOTE < LOCAL | `UPGRADE_AVAILABLE <local> <older>` (backwards downgrade prompt) | `UP_TO_DATE <local>` (silent, semver-order guard via `sort -V`) |
18+
| `GSTACK_REMOTE_URL` override semantics | Always honored | Skipped when explicit; preserves `file://` test fixtures and private mirrors |
19+
| `git ls-remote` hang exposure | Not used | `GIT_TERMINAL_PROMPT=0` + `GIT_HTTP_LOW_SPEED_LIMIT=1000` + `GIT_HTTP_LOW_SPEED_TIME=5` enforce a 5-second floor on hung connections |
20+
| Multi-segment version comparison | `[ "$LOCAL" = "$REMOTE" ]` only | `printf "%s\n%s\n" $LOCAL $REMOTE | sort -V | tail -1` validates ordering. `1.9.0.0 < 1.10.0.0` both directions |
21+
| Test coverage for these failure modes | 0 tests | 3 new tests: REMOTE older than LOCAL, multi-segment forward, multi-segment reverse |
22+
23+
The semver guard catches the failure shape directly. If GitHub's branch-raw CDN ever serves stale content again, the script stays silent instead of asking the user to "upgrade" to a version they already passed.
24+
25+
### What this means for builders
26+
27+
Run `/gstack-upgrade` immediately after a new release and the script finds the new VERSION via the live ref instead of waiting for the CDN to refresh. Dev installs running ahead of main also stay quiet now, no more backwards prompts every preamble. No action required, the fix is automatic on upgrade.
28+
29+
### Itemized changes
30+
31+
#### Fixed
32+
33+
- **`bin/gstack-update-check`** — replaced the unconditional `curl` of `raw.githubusercontent.com/.../main/VERSION` with a SHA-pinned fetch path that resolves the live HEAD via `git ls-remote` first, then curls `raw.githubusercontent.com/garrytan/gstack/<SHA>/VERSION`. Branch-raw fetch kept as fallback when `git ls-remote` is unavailable or `GSTACK_REMOTE_URL` is explicitly set.
34+
- **`bin/gstack-update-check`** — added a semver-order guard. After fetching REMOTE, the script runs `sort -V` to confirm REMOTE > LOCAL before emitting `UPGRADE_AVAILABLE`. When LOCAL is at or ahead of REMOTE, it writes `UP_TO_DATE` and exits silently.
35+
- **`bin/gstack-update-check`** — fenced `git ls-remote` with `GIT_TERMINAL_PROMPT=0`, `GIT_HTTP_LOW_SPEED_LIMIT=1000`, and `GIT_HTTP_LOW_SPEED_TIME=5` so a flaky network cannot hang every skill preamble.
36+
37+
#### Added
38+
39+
- **`browse/test/gstack-update-check.test.ts`** — 3 new tests covering: REMOTE older than LOCAL stays silent and caches `UP_TO_DATE`, multi-segment `1.9.0.0 < 1.10.0.0` produces `UPGRADE_AVAILABLE`, multi-segment `1.10.0.0 > 1.9.0.0` stays silent.
40+
41+
## [1.34.0.0] - 2026-05-12
42+
43+
## **GStack is now consumable as a submodule.**
44+
## **Five new exported helpers + `AUTH_TOKEN` env injection + `import.meta.main` gate let downstream Bun projects embed the browse server without forking.**
45+
46+
GStack's `browse/src/server.ts` started life as a CLI entry point: import it and it would bind `Bun.serve` at module load, claim a random port, and write project state to your `.gstack/` dir. Every embedder that wanted to consume gstack as a library had to fork or vendor the file. This release flips that. The browse server now ships an exported API surface (`ServerConfig`, `ServerHandle`, `resolveConfigFromEnv`, `start`), honors `process.env.AUTH_TOKEN` for embedder-driven token allocation, and gates all module-load side effects on `import.meta.main` so plain `import` from a third-party Bun program runs zero side effects. The fetch-handler factory contract is documented in the new types; the runtime factory function (`buildFetchHandler`) is a deliberate follow-up — Phoenix can ship today against the start()+env surface.
47+
48+
The same release ships three security hardening fixes from adversarial review and a real TDZ regression bug fix that surfaced only when `claude` is missing from `PATH`.
49+
50+
### The numbers that matter
51+
52+
Source: `bun test browse/test/` against this branch — 5 new test files + 1 extended.
53+
54+
| Surface | Before | After |
55+
|---|---|---|
56+
| Import `browse/src/server.ts` from a third-party process | Auto-starts a daemon, binds `Bun.serve`, writes state | No side effects (gated on `import.meta.main`) |
57+
| `AUTH_TOKEN` source | Always `crypto.randomUUID()` at module load | `process.env.AUTH_TOKEN` (sanitized, >= 16 chars after unicode-whitespace strip) → randomUUID fallback |
58+
| Exported API for embedders | None (`start` was internal, no types) | `ServerConfig`, `ServerHandle`, `resolveConfigFromEnv`, `start`, `sanitizeAuthToken` |
59+
| `isCustomChromium()` detection | Did not exist | Exported helper: `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred, path substring fallback |
60+
| Chromium profile path | Hardcoded `$HOME/.gstack/chromium-profile` | `resolveChromiumProfile(explicit?)` honors arg → `CHROMIUM_PROFILE` env → `$GSTACK_HOME/chromium-profile` |
61+
| Stale `SingletonLock` / `Socket` / `Cookie` cleanup | Inline at two callsites with raw `fs.unlinkSync` | One helper (`cleanSingletonLocks`) with absolute-path requirement + basename-or-env match guard |
62+
| TDZ on missing `claude` CLI | Latent `ReferenceError` in `checkTranscript` early-return path | `finish()` hoisted above `resolveClaudeCommand()` + try/catch wrap |
63+
| `AUTH_TOKEN=$''` (BOM-only) accepted by `.trim()` | Yes (one-character bearer secret) | No (rejected by unicode-whitespace strip + 16-char minimum) |
64+
| Tests covering new surfaces | 0 | 34 new tests across 5 files (16 in extended `config.test.ts`, 8 `isCustomChromium`, 1 TDZ regression, 12 factory API + side-effect guard) |
65+
66+
The adversarial review pass found the BOM-token bypass before merge — `.trim()` strips ASCII whitespace but not U+FEFF / U+200B / U+00A0. New `sanitizeAuthToken()` uses a unicode-aware regex and rejects anything shorter than 16 chars after stripping, so a misconfigured embedder can no longer ship a one-character bearer.
67+
68+
### What this means for builders embedding gstack
69+
70+
Phoenix and any future Bun-based consumer can now `import { start, resolveConfigFromEnv } from 'browse-server-upstream/browse/src/server'`, set `AUTH_TOKEN` + `BROWSE_PORT` env, and run gstack as a child without forking. The exported `ServerConfig` documents the full factory contract for the eventual `buildFetchHandler` runtime — when that lands in the follow-up PR, today's API surface becomes a no-op compat shim. Run `/gstack-upgrade` to pick it up. The browse CLI behavior (`bun run dev <command>`) is unchanged.
71+
72+
### Itemized changes
73+
74+
### Added
75+
- `browse/src/config.ts`: `resolveGstackHome()` (honors `GSTACK_HOME`, falls back to `os.homedir()/.gstack`), `resolveChromiumProfile(explicit?)`, `cleanSingletonLocks(dir)` with defensive absolute-path + basename/env guard.
76+
- `browse/src/browser-manager.ts`: exported `isCustomChromium()` with `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred signal, substring fallback on `GSTACK_CHROMIUM_PATH`.
77+
- `browse/src/server.ts`: `ServerConfig` and `ServerHandle` types, `resolveConfigFromEnv()`, `sanitizeAuthToken()`, exported `start()`. `AUTH_TOKEN` honors env with unicode-aware sanitization.
78+
- `browse/test/config.test.ts`: 16 new tests (env precedence, defensive guards, ENOENT idempotency).
79+
- `browse/test/browser-manager-custom-chromium.test.ts`: 8 tests covering env-kind, path substring, stock chromium, playwright-bundled cases.
80+
- `browse/test/security-classifier-tdz.test.ts`: regression test for the missing-CLI degraded path (IRON RULE).
81+
- `browse/test/server-factory.test.ts`: 14 tests covering AUTH_TOKEN env semantics + type-surface compile checks + preserved exports.
82+
- `browse/test/server-no-import-side-effects.test.ts`: subprocess sentinel proving `import` doesn't auto-start.
83+
84+
### Changed
85+
- `browse/src/security-classifier.ts`: `finish()` hoisted above `resolveClaudeCommand()` in `checkTranscript` Promise executor. `resolveClaudeCommand()` and `spawn()` calls wrapped in try/catch that degrade to a structured signal instead of rejecting the Promise.
86+
- `browse/src/browser-manager.ts` `launchHeaded`: `--load-extension` gated on `!isCustomChromium()` (prevents `ServiceWorkerState::SetWorkerId` DCHECK with extension-baked custom Chromium). Profile path switches to `resolveChromiumProfile()`. Pre-launch `cleanSingletonLocks(userDataDir)` added.
87+
- `browse/src/server.ts`: signal handlers (SIGINT, SIGTERM, Windows `exit`, `uncaughtException`, `unhandledRejection`) and the auto-kickoff `start().catch(...)` at module bottom now gated on `import.meta.main`. `shutdown()` and `emergencyCleanup()` swap inline `SingletonLock`/`Socket`/`Cookie` loops for `cleanSingletonLocks(resolveChromiumProfile())`.
88+
89+
### Fixed
90+
- TDZ `ReferenceError` in `checkTranscript` when `claude` CLI is missing from `PATH` (latent — only triggered the dormant code path).
91+
- AUTH_TOKEN unicode-whitespace bypass: `.trim()` only stripped ASCII whitespace, so a `process.env.AUTH_TOKEN=$''` (BOM) or `$'​'` (zero-width space) became a one-character bearer secret. New `sanitizeAuthToken()` strips all unicode whitespace and rejects anything shorter than 16 chars.
92+
- `cleanSingletonLocks` path-traversal hardening: now requires absolute paths and matches against absolute-resolved `CHROMIUM_PROFILE` env, blocking CWD-relative footguns.
93+
94+
### For contributors
95+
- The full `buildFetchHandler` runtime extraction (hybrid hoist of 13 module-level mutables into a factory closure, plus `beforeRoute` auth-then-hook wiring, plus `stopListeners` implementation) is **deferred to a follow-up PR**. The exported types document the eventual contract; today's release ships the minimum-viable surface so Phoenix can land v0.6.0.0 against `import { start }` + AUTH_TOKEN env.
96+
- See `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md` for the full plan + 13 decisions + codex outside-voice tensions resolved.
97+
398
## [1.33.2.0] - 2026-05-11
499

5100
## **`./setup` no longer pollutes the global install when run from a Conductor worktree.**

VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
1.33.2.0
1+
1.34.1.0

bin/gstack-update-check

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
#
99
# Env overrides (for testing):
1010
# GSTACK_DIR — override auto-detected gstack root
11-
# GSTACK_REMOTE_URL — override remote VERSION URL
11+
# GSTACK_REMOTE_URL — override remote VERSION URL (branch-pinned fallback)
12+
# GSTACK_REMOTE_REPO — override remote git URL for ls-remote SHA resolution
1213
# GSTACK_STATE_DIR — override ~/.gstack state directory
1314
set -euo pipefail
1415

@@ -19,6 +20,7 @@ MARKER_FILE="$STATE_DIR/just-upgraded-from"
1920
SNOOZE_FILE="$STATE_DIR/update-snoozed"
2021
VERSION_FILE="$GSTACK_DIR/VERSION"
2122
REMOTE_URL="${GSTACK_REMOTE_URL:-https://raw.githubusercontent.com/garrytan/gstack/main/VERSION}"
23+
REMOTE_REPO="${GSTACK_REMOTE_REPO:-https://github.com/garrytan/gstack.git}"
2224

2325
# ─── Force flag (busts cache + snooze for standalone /gstack-upgrade) ──
2426
if [ "${1:-}" = "--force" ]; then
@@ -178,9 +180,34 @@ if [ -n "$_SUPA_URL" ] && [ -n "$_SUPA_KEY" ] && [ "${_TEL_TIER:-off}" != "off"
178180
>/dev/null 2>&1 &
179181
fi
180182

181-
# GitHub raw fetch (primary, always reliable)
183+
# Resolve VERSION via a SHA-pinned raw URL. GitHub's branch-raw CDN
184+
# (raw.githubusercontent.com/<owner>/<repo>/<branch>/...) can serve stale
185+
# content for several minutes after a push, which previously caused
186+
# /gstack-upgrade to silently report "up to date" right after a release
187+
# landed. git ls-remote always returns the live HEAD; SHA-pinned raw URLs
188+
# are immediately consistent.
189+
#
190+
# An explicit GSTACK_REMOTE_URL override (tests, mirrors) skips this path
191+
# so the override is honored verbatim.
182192
REMOTE=""
183-
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
193+
if [ -z "${GSTACK_REMOTE_URL:-}" ]; then
194+
# Disable credential prompts and apply a 5-second low-speed timeout so a
195+
# flaky network or captive portal can't hang every skill preamble.
196+
_LSR_LINE="$(GIT_TERMINAL_PROMPT=0 GIT_HTTP_LOW_SPEED_LIMIT=1000 GIT_HTTP_LOW_SPEED_TIME=5 \
197+
git ls-remote "$REMOTE_REPO" refs/heads/main 2>/dev/null || true)"
198+
_REMOTE_SHA="$(echo "$_LSR_LINE" | awk '{print $1}')"
199+
if echo "$_REMOTE_SHA" | grep -qE '^[0-9a-f]{40}$'; then
200+
_SHA_URL="https://raw.githubusercontent.com/garrytan/gstack/${_REMOTE_SHA}/VERSION"
201+
REMOTE="$(curl -sf --max-time 5 "$_SHA_URL" 2>/dev/null || true)"
202+
fi
203+
fi
204+
205+
# Fallback: branch-pinned URL when ls-remote is unavailable (no git, no
206+
# network, mirror without refs/heads/main) or when GSTACK_REMOTE_URL was
207+
# explicitly overridden.
208+
if [ -z "$REMOTE" ]; then
209+
REMOTE="$(curl -sf --max-time 5 "$REMOTE_URL" 2>/dev/null || true)"
210+
fi
184211
REMOTE="$(echo "$REMOTE" | tr -d '[:space:]')"
185212

186213
# Validate: must look like a version number (reject HTML error pages)
@@ -195,7 +222,17 @@ if [ "$LOCAL" = "$REMOTE" ]; then
195222
exit 0
196223
fi
197224

198-
# Versions differ — upgrade available
225+
# Semver-order guard: only flag an upgrade when REMOTE sorts higher than
226+
# LOCAL. Protects against transient stale-CDN regressions (REMOTE < LOCAL)
227+
# and dev installs running ahead of main, both of which would otherwise
228+
# emit a backwards UPGRADE_AVAILABLE line.
229+
_HIGHER="$(printf '%s\n%s\n' "$LOCAL" "$REMOTE" | sort -V | tail -1)"
230+
if [ "$_HIGHER" != "$REMOTE" ]; then
231+
echo "UP_TO_DATE $LOCAL" > "$CACHE_FILE"
232+
exit 0
233+
fi
234+
235+
# REMOTE is strictly newer — upgrade available
199236
echo "UPGRADE_AVAILABLE $LOCAL $REMOTE" > "$CACHE_FILE"
200237
if check_snooze "$REMOTE"; then
201238
exit 0 # snoozed — stay quiet

browse/src/browser-manager.ts

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,25 @@ import { writeSecureFile, mkdirSecure } from './file-permissions';
2020
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
2121
import { validateNavigationUrl } from './url-validation';
2222
import { TabSession, type RefEntry } from './tab-session';
23+
import { resolveChromiumProfile, cleanSingletonLocks } from './config';
24+
25+
/**
26+
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
27+
* already bakes the gstack extension in as a component extension (e.g.,
28+
* GStack Browser.app / GBrowser). Passing --load-extension against such a
29+
* binary triggers a ServiceWorkerState::SetWorkerId DCHECK because two
30+
* copies of the same service worker try to register.
31+
*
32+
* Resolution:
33+
* 1. GSTACK_CHROMIUM_KIND === 'custom-extension-baked' (preferred, explicit)
34+
* 2. GSTACK_CHROMIUM_PATH path substring contains 'GBrowser' or 'gbrowser'
35+
* (fallback for callers that only set the path)
36+
*/
37+
export function isCustomChromium(): boolean {
38+
if (process.env.GSTACK_CHROMIUM_KIND === 'custom-extension-baked') return true;
39+
const p = process.env.GSTACK_CHROMIUM_PATH || '';
40+
return p.includes('GBrowser') || p.includes('gbrowser');
41+
}
2342

2443
export type { RefEntry };
2544

@@ -283,9 +302,17 @@ export class BrowserManager {
283302
'--disable-blink-features=AutomationControlled',
284303
];
285304
if (extensionPath) {
286-
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
287-
launchArgs.push(`--load-extension=${extensionPath}`);
288-
// Write auth token for extension bootstrap.
305+
// Skip --load-extension when running against a custom Chromium build
306+
// that already bakes the extension in as a component extension
307+
// (gbrowser / GStack Browser.app). Loading it twice causes a
308+
// ServiceWorkerState::SetWorkerId DCHECK crash.
309+
if (!isCustomChromium()) {
310+
launchArgs.push(`--disable-extensions-except=${extensionPath}`);
311+
launchArgs.push(`--load-extension=${extensionPath}`);
312+
}
313+
// Write auth token for extension bootstrap (still required even when
314+
// the extension is component-baked — it reads ~/.gstack/.auth.json at
315+
// startup to learn how to call the daemon).
289316
// Write to ~/.gstack/.auth.json (not the extension dir, which may be read-only
290317
// in .app bundles and breaks codesigning).
291318
if (authToken) {
@@ -308,9 +335,17 @@ export class BrowserManager {
308335
// so we use Playwright's bundled Chromium which reliably loads extensions.
309336
const fs = require('fs');
310337
const path = require('path');
311-
const userDataDir = path.join(process.env.HOME || '/tmp', '.gstack', 'chromium-profile');
338+
const userDataDir = resolveChromiumProfile();
312339
fs.mkdirSync(userDataDir, { recursive: true });
313340

341+
// Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's
342+
// ProcessSingleton refuses to start when these exist from a prior crash
343+
// (SIGKILL, hard crash) — the lockfiles point at a PID that may no longer
344+
// exist. Shutdown cleanup doesn't run on hard crashes, so we clean here
345+
// too. Safe under external coordination: gbd.lock for gbrowser,
346+
// single-instance CLI check for gstack.
347+
cleanSingletonLocks(userDataDir);
348+
314349
// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
315350
// Used by GStack Browser.app to point at the bundled Chromium.
316351
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;

0 commit comments

Comments
 (0)