Files
gstack/browse/test/server-factory.test.ts
Garry Tan 0c88517a0f v1.34.0.0 feat: gstack consumable as submodule (factory-export API + AUTH_TOKEN env + import.meta.main gate) (#1472)
* feat(config): add resolveGstackHome, resolveChromiumProfile, cleanSingletonLocks

Three new exported helpers in browse/src/config.ts:

- resolveGstackHome(): honors GSTACK_HOME env, falls back to os.homedir()/.gstack
  Matches the existing convention in browse/src/telemetry.ts:26 and
  browse/src/domain-skills.ts:66.

- resolveChromiumProfile(explicit?): explicit arg wins -> CHROMIUM_PROFILE env
  -> resolveGstackHome()/chromium-profile. Lets gbrowser pass per-workspace
  profile paths through ServerConfig instead of relying on ambient env state.

- cleanSingletonLocks(dir): removes SingletonLock/Socket/Cookie via safeUnlinkQuiet.
  Defensive guard refuses to operate unless dir basename is 'chromium-profile'
  OR matches explicit CHROMIUM_PROFILE env value, preventing accidental
  deletion in unrelated directories.

Extends browse/test/config.test.ts with 12 tests covering env precedence,
guard behavior, ENOENT swallowing, and CHROMIUM_PROFILE override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(security-classifier): TDZ when claude CLI is missing from PATH

The checkTranscript Promise executor in browse/src/security-classifier.ts
referenced `finish()` at the !claude early-return guard before declaring
it 5 lines later. JavaScript throws ReferenceError: Cannot access 'finish'
before initialization (TDZ) for that path, but the path is only reachable
when resolveClaudeCommand returns null inside the spawn block (a TOCTOU
window vs. the outer checkHaikuAvailable cache).

Fix: hoist `let stdout = ''`, `let done = false`, and `const finish` block
above `const claude = resolveClaudeCommand()` so finish is in scope before
any reference to it. Behavior is identical when claude is on PATH; the
fix only matters for the dormant missing-CLI degraded path.

Adds browse/test/security-classifier-tdz.test.ts as the regression guard:
clears PATH + override env vars, calls checkTranscript, asserts the result
serializes with degraded:true and a meaningful reason field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(browser-manager): isCustomChromium gate + per-workspace profile + lock cleanup

Three fold-ins so gbrowser can become a thin overlay instead of forking
browse-server:

- Export isCustomChromium(): detects custom Chromium builds that bake the
  extension in as a component extension. Prefers explicit
  GSTACK_CHROMIUM_KIND=custom-extension-baked signal; falls back to
  GSTACK_CHROMIUM_PATH substring containing 'GBrowser' / 'gbrowser'.
  Gates the --load-extension push at launchHeaded so we don't trigger
  ServiceWorkerState::SetWorkerId DCHECK when two copies of the same
  service worker race to register.

- Swap hardcoded path.join(HOME, '.gstack', 'chromium-profile') in
  launchHeaded for resolveChromiumProfile() so phoenix can pass a
  per-workspace profile via CHROMIUM_PROFILE env (one daemon per gbd
  workspace, each with a distinct profile dir).

- Call cleanSingletonLocks(userDataDir) immediately after mkdirSync.
  Chromium's ProcessSingleton refuses to start when stale
  SingletonLock/Socket/Cookie files survive a SIGKILL or hard crash;
  pre-launch cleanup defends against the crash case. Safe under external
  coordination (gbd.lock for gbrowser, single-instance CLI check for
  gstack).

The existing .auth.json write at L291-302 is preserved — extensions
still need it for bootstrap even when component-baked.

Adds browse/test/browser-manager-custom-chromium.test.ts with 8 tests
covering both the env-kind and path-substring signals plus stock /
playwright-bundled Chromium negative cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(server): factory-export API surface + import.meta.main gate

Surfaces the embedder API gbrowser (phoenix) needs to consume gstack as a
submodule, and gates module-load side effects so the file is safe to
import without auto-starting a daemon.

Changes to browse/src/server.ts:

- AUTH_TOKEN now honors process.env.AUTH_TOKEN (trimmed) before falling
  back to crypto.randomUUID(). Whitespace-only values are rejected so the
  security boundary can't be silently weakened.

- New exported types: ServerConfig and ServerHandle. ServerConfig documents
  the full factory contract (authToken, browsePort, idleTimeoutMs, config,
  browserManager, chromiumProfile, xvfb, proxyBridge, startTime, beforeRoute).
  ServerHandle documents the return shape (fetchLocal, fetchTunnel,
  shutdown, stopListeners). Caller-owned lifecycle annotations on xvfb and
  proxyBridge prevent double-close bugs from surprise ownership.

- New exported function: resolveConfigFromEnv() builds a ServerConfig-shaped
  object from process.env for CLI use. Embedders construct their own
  ServerConfig explicitly.

- start() is now exported. Embedders can call it with env vars set as a
  v1 escape hatch until full buildFetchHandler extraction lands.

- Signal handlers (SIGINT, SIGTERM, Windows exit, uncaughtException,
  unhandledRejection) and the auto-kickoff at module bottom are now wrapped
  in `if (import.meta.main)`. CLI path is unchanged. Embedders register
  their own handlers.

- shutdown() and emergencyCleanup() now call cleanSingletonLocks(
  resolveChromiumProfile()) instead of inline path+loop. Single
  implementation, defensive guard, honors per-workspace CHROMIUM_PROFILE.

New tests:
- browse/test/server-no-import-side-effects.test.ts: spawns a fresh Bun
  subprocess that imports server.ts, asserts no signal handlers registered,
  no state-dir populated. Guards the core refactor invariant from
  regression.
- browse/test/server-factory.test.ts: 12 tests covering AUTH_TOKEN env
  behavior (honored, whitespace-rejected, trimmed), preserved exports
  (TUNNEL_COMMANDS, canDispatchOverTunnel), and ServerConfig/ServerHandle
  type compatibility.

Deferred to follow-up PR: full buildFetchHandler extraction that hoists
the 13 module-level mutables + helpers into a factory closure. Phoenix
can ship v0.6.0.0 against the start()+env surface today; the cleaner
factory comes next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: harden auth-token validation, TDZ try/catch, lockfile path safety

Three security hardening fixes from /ship adversarial review:

1. AUTH_TOKEN unicode-whitespace bypass (server.ts:67-83).
   Old: `process.env.AUTH_TOKEN?.trim() || randomUUID()` only stripped
   ASCII whitespace. A misconfigured embedder shipping AUTH_TOKEN=$''
   (BOM) or $'​' (zero-width space) would silently get a
   one-character bearer secret. New `sanitizeAuthToken()` strips all
   unicode whitespace via regex and requires >= 16 chars after stripping;
   anything shorter falls back to crypto.randomUUID(). Same sanitizer
   used by `resolveConfigFromEnv()` so the embedder path is hardened too.

2. security-classifier.ts checkTranscript safety net.
   `resolveClaudeCommand()` and `spawn()` can throw under transient
   conditions (PATH probe failure, posix_spawn ENOMEM). Old code let the
   throw propagate and rejected the Promise with a raw exception. Now
   wrapped in try/catch that calls finish() with a degraded signal,
   matching the graceful-degradation contract the layer already promises
   for missing-CLI / exit-nonzero / parse-error.

3. cleanSingletonLocks defensive guard tightened (config.ts).
   Old: basename === 'chromium-profile' OR userDataDir === $CHROMIUM_PROFILE.
   The second branch was env-controlled and the first was bypassable by
   passing a relative path that resolved to chromium-profile via CWD
   drift. New guard: refuses relative paths outright, resolves both
   sides via path.resolve(), and only accepts the env-match path when
   $CHROMIUM_PROFILE is itself absolute.

Test updates: replace the old `.trim()` test with three new cases
covering unicode-whitespace stripping, short-token rejection, and
zero-width-only rejection (server-factory.test.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v1.34.0.0)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:22:30 -04:00

194 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect } from 'bun:test';
import {
resolveConfigFromEnv,
type ServerConfig,
type ServerHandle,
type Surface,
} from '../src/server';
import { TUNNEL_COMMANDS, canDispatchOverTunnel } from '../src/server';
/**
* Tests for the factory-export API surface added so gbrowser (phoenix) can
* consume gstack as a submodule. The full buildFetchHandler hybrid hoist is
* deferred to a follow-up PR; this test file proves the type contract,
* resolveConfigFromEnv behavior, and preserved exports.
*/
describe('server.ts factory API surface', () => {
describe('resolveConfigFromEnv', () => {
test('honors AUTH_TOKEN env var', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = 'fixed-test-token-abc123';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toBe('fixed-test-token-abc123');
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('falls back to randomUUID when AUTH_TOKEN env is empty', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = '';
try {
const cfg = resolveConfigFromEnv();
// randomUUID returns a 36-char hex+dash string.
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('falls back to randomUUID when AUTH_TOKEN is whitespace-only', () => {
const orig = process.env.AUTH_TOKEN;
process.env.AUTH_TOKEN = ' \t \n ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(cfg.authToken.length).toBe(36);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN whitespace is stripped (including unicode whitespace)', () => {
const orig = process.env.AUTH_TOKEN;
// 22 chars after stripping leading/trailing whitespace including BOM (U+FEFF)
// and zero-width space (U+200B), so passes the 16-char minimum.
process.env.AUTH_TOKEN = ' padded-token-abc123xyz ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toBe('padded-token-abc123xyz');
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN shorter than 16 chars after stripping falls back to randomUUID', () => {
const orig = process.env.AUTH_TOKEN;
// Only 5 chars of content — too short for the 16-char minimum.
process.env.AUTH_TOKEN = 'short';
try {
const cfg = resolveConfigFromEnv();
// Must be a UUID, not the rejected short token.
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('AUTH_TOKEN of only zero-width unicode whitespace falls back to randomUUID', () => {
const orig = process.env.AUTH_TOKEN;
// U+200B (ZWSP), U+FEFF (BOM), U+00A0 (NBSP) — would pass .trim() but not the unicode-aware strip.
process.env.AUTH_TOKEN = ' ';
try {
const cfg = resolveConfigFromEnv();
expect(cfg.authToken).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
} finally {
if (orig === undefined) delete process.env.AUTH_TOKEN;
else process.env.AUTH_TOKEN = orig;
}
});
test('reads BROWSE_PORT from env, defaults to 0', () => {
const orig = process.env.BROWSE_PORT;
process.env.BROWSE_PORT = '34567';
try {
expect(resolveConfigFromEnv().browsePort).toBe(34567);
} finally {
if (orig === undefined) delete process.env.BROWSE_PORT;
else process.env.BROWSE_PORT = orig;
}
const origUnset = process.env.BROWSE_PORT;
delete process.env.BROWSE_PORT;
try {
expect(resolveConfigFromEnv().browsePort).toBe(0);
} finally {
if (origUnset !== undefined) process.env.BROWSE_PORT = origUnset;
}
});
test('reads BROWSE_IDLE_TIMEOUT from env, defaults to 30 min (1800000ms)', () => {
const orig = process.env.BROWSE_IDLE_TIMEOUT;
delete process.env.BROWSE_IDLE_TIMEOUT;
try {
expect(resolveConfigFromEnv().idleTimeoutMs).toBe(1800000);
} finally {
if (orig !== undefined) process.env.BROWSE_IDLE_TIMEOUT = orig;
}
});
test('returns a populated config object with the expected shape', () => {
const cfg = resolveConfigFromEnv();
expect(cfg).toMatchObject({
authToken: expect.any(String),
browsePort: expect.any(Number),
idleTimeoutMs: expect.any(Number),
config: expect.objectContaining({
stateDir: expect.any(String),
stateFile: expect.any(String),
auditLog: expect.any(String),
}),
});
});
});
describe('preserved exports', () => {
test('TUNNEL_COMMANDS still exported and populated', () => {
expect(TUNNEL_COMMANDS).toBeInstanceOf(Set);
expect(TUNNEL_COMMANDS.size).toBeGreaterThan(0);
expect(TUNNEL_COMMANDS.has('goto')).toBe(true);
expect(TUNNEL_COMMANDS.has('click')).toBe(true);
});
test('canDispatchOverTunnel still exported and functional', () => {
expect(canDispatchOverTunnel('goto')).toBe(true);
expect(canDispatchOverTunnel('shutdown')).toBe(false);
expect(canDispatchOverTunnel(null)).toBe(false);
expect(canDispatchOverTunnel(undefined)).toBe(false);
expect(canDispatchOverTunnel('')).toBe(false);
});
});
describe('type surface compiles', () => {
// Compile-time shape checks. If these break, TypeScript fails to build
// the test file — which is exactly the API-compat guarantee we want for
// embedders depending on these types.
test('Surface type accepts the two known values', () => {
const local: Surface = 'local';
const tunnel: Surface = 'tunnel';
expect(local).toBe('local');
expect(tunnel).toBe('tunnel');
});
test('ServerConfig type accepts the documented minimum-required fields', () => {
// This compiles only if ServerConfig accepts these field names + types.
const minimalConfigShape = {
authToken: 'tok',
browsePort: 0,
idleTimeoutMs: 1800000,
config: { stateDir: '', stateFile: '', consoleLog: '', networkLog: '', dialogLog: '', auditLog: '', projectDir: '' },
browserManager: {} as any,
startTime: Date.now(),
} satisfies Partial<ServerConfig>;
expect(minimalConfigShape.authToken).toBe('tok');
});
test('ServerHandle type exposes the documented surface', () => {
// Compiles only if these property names exist on ServerHandle.
type AssertHandleFields = ServerHandle extends {
fetchLocal: any;
fetchTunnel: any;
shutdown: any;
stopListeners: any;
} ? true : false;
const assertion: AssertHandleFields = true;
expect(assertion).toBe(true);
});
});
});