Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,62 @@
# Changelog

## [1.34.0.0] - 2026-05-12

## **GStack is now consumable as a submodule.**
## **Five new exported helpers + `AUTH_TOKEN` env injection + `import.meta.main` gate let downstream Bun projects embed the browse server without forking.**

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.

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`.

### The numbers that matter

Source: `bun test browse/test/` against this branch — 5 new test files + 1 extended.

| Surface | Before | After |
|---|---|---|
| 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`) |
| `AUTH_TOKEN` source | Always `crypto.randomUUID()` at module load | `process.env.AUTH_TOKEN` (sanitized, >= 16 chars after unicode-whitespace strip) → randomUUID fallback |
| Exported API for embedders | None (`start` was internal, no types) | `ServerConfig`, `ServerHandle`, `resolveConfigFromEnv`, `start`, `sanitizeAuthToken` |
| `isCustomChromium()` detection | Did not exist | Exported helper: `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred, path substring fallback |
| Chromium profile path | Hardcoded `$HOME/.gstack/chromium-profile` | `resolveChromiumProfile(explicit?)` honors arg → `CHROMIUM_PROFILE` env → `$GSTACK_HOME/chromium-profile` |
| 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 |
| TDZ on missing `claude` CLI | Latent `ReferenceError` in `checkTranscript` early-return path | `finish()` hoisted above `resolveClaudeCommand()` + try/catch wrap |
| `AUTH_TOKEN=$''` (BOM-only) accepted by `.trim()` | Yes (one-character bearer secret) | No (rejected by unicode-whitespace strip + 16-char minimum) |
| 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) |

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.

### What this means for builders embedding gstack

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.

### Itemized changes

### Added
- `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.
- `browse/src/browser-manager.ts`: exported `isCustomChromium()` with `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred signal, substring fallback on `GSTACK_CHROMIUM_PATH`.
- `browse/src/server.ts`: `ServerConfig` and `ServerHandle` types, `resolveConfigFromEnv()`, `sanitizeAuthToken()`, exported `start()`. `AUTH_TOKEN` honors env with unicode-aware sanitization.
- `browse/test/config.test.ts`: 16 new tests (env precedence, defensive guards, ENOENT idempotency).
- `browse/test/browser-manager-custom-chromium.test.ts`: 8 tests covering env-kind, path substring, stock chromium, playwright-bundled cases.
- `browse/test/security-classifier-tdz.test.ts`: regression test for the missing-CLI degraded path (IRON RULE).
- `browse/test/server-factory.test.ts`: 14 tests covering AUTH_TOKEN env semantics + type-surface compile checks + preserved exports.
- `browse/test/server-no-import-side-effects.test.ts`: subprocess sentinel proving `import` doesn't auto-start.

### Changed
- `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.
- `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.
- `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())`.

### Fixed
- TDZ `ReferenceError` in `checkTranscript` when `claude` CLI is missing from `PATH` (latent — only triggered the dormant code path).
- 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.
- `cleanSingletonLocks` path-traversal hardening: now requires absolute paths and matches against absolute-resolved `CHROMIUM_PROFILE` env, blocking CWD-relative footguns.

### For contributors
- 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.
- See `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md` for the full plan + 13 decisions + codex outside-voice tensions resolved.

## [1.33.2.0] - 2026-05-11

## **`./setup` no longer pollutes the global install when run from a Conductor worktree.**
Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
1.33.2.0
1.34.0.0
43 changes: 39 additions & 4 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,25 @@ import { writeSecureFile, mkdirSecure } from './file-permissions';
import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers';
import { validateNavigationUrl } from './url-validation';
import { TabSession, type RefEntry } from './tab-session';
import { resolveChromiumProfile, cleanSingletonLocks } from './config';

/**
* Detect whether GSTACK_CHROMIUM_PATH points at a custom Chromium build that
* already bakes the gstack extension in as a component extension (e.g.,
* GStack Browser.app / GBrowser). Passing --load-extension against such a
* binary triggers a ServiceWorkerState::SetWorkerId DCHECK because two
* copies of the same service worker try to register.
*
* Resolution:
* 1. GSTACK_CHROMIUM_KIND === 'custom-extension-baked' (preferred, explicit)
* 2. GSTACK_CHROMIUM_PATH path substring contains 'GBrowser' or 'gbrowser'
* (fallback for callers that only set the path)
*/
export function isCustomChromium(): boolean {
if (process.env.GSTACK_CHROMIUM_KIND === 'custom-extension-baked') return true;
const p = process.env.GSTACK_CHROMIUM_PATH || '';
return p.includes('GBrowser') || p.includes('gbrowser');
}

export type { RefEntry };

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

// Pre-launch cleanup of stale SingletonLock/Socket/Cookie. Chromium's
// ProcessSingleton refuses to start when these exist from a prior crash
// (SIGKILL, hard crash) — the lockfiles point at a PID that may no longer
// exist. Shutdown cleanup doesn't run on hard crashes, so we clean here
// too. Safe under external coordination: gbd.lock for gbrowser,
// single-instance CLI check for gstack.
cleanSingletonLocks(userDataDir);

// Support custom Chromium binary via GSTACK_CHROMIUM_PATH env var.
// Used by GStack Browser.app to point at the bundled Chromium.
const executablePath = process.env.GSTACK_CHROMIUM_PATH || undefined;
Expand Down
67 changes: 67 additions & 0 deletions browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
*/

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { mkdirSecure } from './file-permissions';
import { safeUnlinkQuiet } from './error-handling';

export interface BrowseConfig {
projectDir: string;
Expand Down Expand Up @@ -151,3 +153,68 @@ export function readVersionHash(execPath: string = process.execPath): string | n
return null;
}
}

/**
* Resolve the gstack home directory.
*
* Honors the existing convention used by telemetry.ts and domain-skills.ts:
* 1. GSTACK_HOME env (explicit override)
* 2. $HOME/.gstack (default)
*/
export function resolveGstackHome(): string {
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
}

/**
* Resolve the Chromium profile directory.
*
* Resolution order:
* 1. `explicit` arg (passed via ServerConfig.chromiumProfile by embedders)
* 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace)
* 3. <resolveGstackHome()>/chromium-profile (default)
*/
export function resolveChromiumProfile(explicit?: string): string {
if (explicit && explicit.length > 0) return explicit;
const env = process.env.CHROMIUM_PROFILE;
if (env && env.length > 0) return env;
return path.join(resolveGstackHome(), 'chromium-profile');
}

/**
* Pre-launch / shutdown cleanup of stale Chromium singleton lockfiles
* (SingletonLock, SingletonSocket, SingletonCookie). Chromium's
* ProcessSingleton refuses to start when these exist from a prior crash
* (SIGKILL, hard crash, etc.) since they point at a PID that no longer exists.
*
* Defensive guard: refuses to operate unless ALL of these hold:
* 1. `userDataDir` is an absolute path (no CWD-relative footguns)
* 2. basename is exactly 'chromium-profile' OR the absolute path matches
* the absolute form of $CHROMIUM_PROFILE env value
*
* Prevents accidentally deleting lock files from an unrelated directory if
* profile resolution is misconfigured upstream (CWD drift, env injection).
*
* Caller MUST ensure external coordination has already guaranteed no live
* peer is using this profile (gbd.lock for gbrowser; single-instance CLI
* check for gstack).
*/
export function cleanSingletonLocks(userDataDir: string): void {
if (!path.isAbsolute(userDataDir)) {
console.warn(`[browse] cleanSingletonLocks: refusing relative path: ${userDataDir}`);
return;
}
const resolved = path.resolve(userDataDir);
const basename = path.basename(resolved);
const explicitProfile = process.env.CHROMIUM_PROFILE;
const explicitAbs = explicitProfile && path.isAbsolute(explicitProfile)
? path.resolve(explicitProfile)
: null;
const isSafe = basename === 'chromium-profile' || (explicitAbs !== null && resolved === explicitAbs);
if (!isSafe) {
console.warn(`[browse] cleanSingletonLocks: refusing to clean unrecognized profile dir: ${resolved}`);
return;
}
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
safeUnlinkQuiet(path.join(resolved, lockFile));
}
}
38 changes: 27 additions & 11 deletions browse/src/security-classifier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -500,17 +500,9 @@ export async function checkTranscript(params: {
// timeout rate in the v1.5.2.0 ensemble bench because of this, plus
// ~44k cache_creation tokens per call (massive cost inflation).
// Using os.tmpdir() gives Haiku a clean context for pure classification.
const claude = resolveClaudeCommand();
if (!claude) {
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } });
}
const p = spawn(claude.command, [
...claude.argsPrefix,
'-p', prompt,
'--model', HAIKU_MODEL,
'--output-format', 'json',
], { stdio: ['ignore', 'pipe', 'pipe'], cwd: os.tmpdir() });

// TDZ fix: declare `finish` BEFORE `resolveClaudeCommand` so the early
// return at the !claude guard below doesn't ReferenceError. Triggered
// only when claude CLI is missing from PATH (dormant otherwise).
let stdout = '';
let done = false;
const finish = (signal: LayerSignal) => {
Expand All @@ -519,6 +511,30 @@ export async function checkTranscript(params: {
resolve(signal);
};

// Wrap resolveClaudeCommand + spawn in try/catch so any unexpected
// throw (PATH probe failure, transient FS error) degrades gracefully
// instead of rejecting the Promise with a raw exception.
let claude: ReturnType<typeof resolveClaudeCommand>;
try {
claude = resolveClaudeCommand();
} catch (err: any) {
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: `resolve_error_${err?.message ?? 'unknown'}` } });
}
if (!claude) {
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } });
}
let p: ReturnType<typeof spawn>;
try {
p = spawn(claude.command, [
...claude.argsPrefix,
'-p', prompt,
'--model', HAIKU_MODEL,
'--output-format', 'json',
], { stdio: ['ignore', 'pipe', 'pipe'], cwd: os.tmpdir() });
} catch (err: any) {
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: `spawn_throw_${err?.message ?? 'unknown'}` } });
}

p.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
p.on('exit', (code) => {
if (code !== 0) {
Expand Down
Loading
Loading