Skip to content

Commit 0c88517

Browse files
garrytanclaude
andauthored
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>
1 parent dc6252d commit 0c88517

12 files changed

Lines changed: 943 additions & 83 deletions

CHANGELOG.md

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

3+
## [1.34.0.0] - 2026-05-12
4+
5+
## **GStack is now consumable as a submodule.**
6+
## **Five new exported helpers + `AUTH_TOKEN` env injection + `import.meta.main` gate let downstream Bun projects embed the browse server without forking.**
7+
8+
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.
9+
10+
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`.
11+
12+
### The numbers that matter
13+
14+
Source: `bun test browse/test/` against this branch — 5 new test files + 1 extended.
15+
16+
| Surface | Before | After |
17+
|---|---|---|
18+
| 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`) |
19+
| `AUTH_TOKEN` source | Always `crypto.randomUUID()` at module load | `process.env.AUTH_TOKEN` (sanitized, >= 16 chars after unicode-whitespace strip) → randomUUID fallback |
20+
| Exported API for embedders | None (`start` was internal, no types) | `ServerConfig`, `ServerHandle`, `resolveConfigFromEnv`, `start`, `sanitizeAuthToken` |
21+
| `isCustomChromium()` detection | Did not exist | Exported helper: `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred, path substring fallback |
22+
| Chromium profile path | Hardcoded `$HOME/.gstack/chromium-profile` | `resolveChromiumProfile(explicit?)` honors arg → `CHROMIUM_PROFILE` env → `$GSTACK_HOME/chromium-profile` |
23+
| 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 |
24+
| TDZ on missing `claude` CLI | Latent `ReferenceError` in `checkTranscript` early-return path | `finish()` hoisted above `resolveClaudeCommand()` + try/catch wrap |
25+
| `AUTH_TOKEN=$''` (BOM-only) accepted by `.trim()` | Yes (one-character bearer secret) | No (rejected by unicode-whitespace strip + 16-char minimum) |
26+
| 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) |
27+
28+
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.
29+
30+
### What this means for builders embedding gstack
31+
32+
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.
33+
34+
### Itemized changes
35+
36+
### Added
37+
- `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.
38+
- `browse/src/browser-manager.ts`: exported `isCustomChromium()` with `GSTACK_CHROMIUM_KIND=custom-extension-baked` preferred signal, substring fallback on `GSTACK_CHROMIUM_PATH`.
39+
- `browse/src/server.ts`: `ServerConfig` and `ServerHandle` types, `resolveConfigFromEnv()`, `sanitizeAuthToken()`, exported `start()`. `AUTH_TOKEN` honors env with unicode-aware sanitization.
40+
- `browse/test/config.test.ts`: 16 new tests (env precedence, defensive guards, ENOENT idempotency).
41+
- `browse/test/browser-manager-custom-chromium.test.ts`: 8 tests covering env-kind, path substring, stock chromium, playwright-bundled cases.
42+
- `browse/test/security-classifier-tdz.test.ts`: regression test for the missing-CLI degraded path (IRON RULE).
43+
- `browse/test/server-factory.test.ts`: 14 tests covering AUTH_TOKEN env semantics + type-surface compile checks + preserved exports.
44+
- `browse/test/server-no-import-side-effects.test.ts`: subprocess sentinel proving `import` doesn't auto-start.
45+
46+
### Changed
47+
- `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.
48+
- `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.
49+
- `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())`.
50+
51+
### Fixed
52+
- TDZ `ReferenceError` in `checkTranscript` when `claude` CLI is missing from `PATH` (latent — only triggered the dormant code path).
53+
- 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.
54+
- `cleanSingletonLocks` path-traversal hardening: now requires absolute paths and matches against absolute-resolved `CHROMIUM_PROFILE` env, blocking CWD-relative footguns.
55+
56+
### For contributors
57+
- 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.
58+
- See `/Users/garrytan/.claude/plans/system-instruction-you-are-working-swirling-fountain.md` for the full plan + 13 decisions + codex outside-voice tensions resolved.
59+
360
## [1.33.2.0] - 2026-05-11
461

562
## **`./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.0.0

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;

browse/src/config.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111
*/
1212

1313
import * as fs from 'fs';
14+
import * as os from 'os';
1415
import * as path from 'path';
1516
import { mkdirSecure } from './file-permissions';
17+
import { safeUnlinkQuiet } from './error-handling';
1618

1719
export interface BrowseConfig {
1820
projectDir: string;
@@ -151,3 +153,68 @@ export function readVersionHash(execPath: string = process.execPath): string | n
151153
return null;
152154
}
153155
}
156+
157+
/**
158+
* Resolve the gstack home directory.
159+
*
160+
* Honors the existing convention used by telemetry.ts and domain-skills.ts:
161+
* 1. GSTACK_HOME env (explicit override)
162+
* 2. $HOME/.gstack (default)
163+
*/
164+
export function resolveGstackHome(): string {
165+
return process.env.GSTACK_HOME || path.join(os.homedir(), '.gstack');
166+
}
167+
168+
/**
169+
* Resolve the Chromium profile directory.
170+
*
171+
* Resolution order:
172+
* 1. `explicit` arg (passed via ServerConfig.chromiumProfile by embedders)
173+
* 2. CHROMIUM_PROFILE env (used by gbrowser's gbd per-workspace)
174+
* 3. <resolveGstackHome()>/chromium-profile (default)
175+
*/
176+
export function resolveChromiumProfile(explicit?: string): string {
177+
if (explicit && explicit.length > 0) return explicit;
178+
const env = process.env.CHROMIUM_PROFILE;
179+
if (env && env.length > 0) return env;
180+
return path.join(resolveGstackHome(), 'chromium-profile');
181+
}
182+
183+
/**
184+
* Pre-launch / shutdown cleanup of stale Chromium singleton lockfiles
185+
* (SingletonLock, SingletonSocket, SingletonCookie). Chromium's
186+
* ProcessSingleton refuses to start when these exist from a prior crash
187+
* (SIGKILL, hard crash, etc.) since they point at a PID that no longer exists.
188+
*
189+
* Defensive guard: refuses to operate unless ALL of these hold:
190+
* 1. `userDataDir` is an absolute path (no CWD-relative footguns)
191+
* 2. basename is exactly 'chromium-profile' OR the absolute path matches
192+
* the absolute form of $CHROMIUM_PROFILE env value
193+
*
194+
* Prevents accidentally deleting lock files from an unrelated directory if
195+
* profile resolution is misconfigured upstream (CWD drift, env injection).
196+
*
197+
* Caller MUST ensure external coordination has already guaranteed no live
198+
* peer is using this profile (gbd.lock for gbrowser; single-instance CLI
199+
* check for gstack).
200+
*/
201+
export function cleanSingletonLocks(userDataDir: string): void {
202+
if (!path.isAbsolute(userDataDir)) {
203+
console.warn(`[browse] cleanSingletonLocks: refusing relative path: ${userDataDir}`);
204+
return;
205+
}
206+
const resolved = path.resolve(userDataDir);
207+
const basename = path.basename(resolved);
208+
const explicitProfile = process.env.CHROMIUM_PROFILE;
209+
const explicitAbs = explicitProfile && path.isAbsolute(explicitProfile)
210+
? path.resolve(explicitProfile)
211+
: null;
212+
const isSafe = basename === 'chromium-profile' || (explicitAbs !== null && resolved === explicitAbs);
213+
if (!isSafe) {
214+
console.warn(`[browse] cleanSingletonLocks: refusing to clean unrecognized profile dir: ${resolved}`);
215+
return;
216+
}
217+
for (const lockFile of ['SingletonLock', 'SingletonSocket', 'SingletonCookie']) {
218+
safeUnlinkQuiet(path.join(resolved, lockFile));
219+
}
220+
}

browse/src/security-classifier.ts

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -500,17 +500,9 @@ export async function checkTranscript(params: {
500500
// timeout rate in the v1.5.2.0 ensemble bench because of this, plus
501501
// ~44k cache_creation tokens per call (massive cost inflation).
502502
// Using os.tmpdir() gives Haiku a clean context for pure classification.
503-
const claude = resolveClaudeCommand();
504-
if (!claude) {
505-
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } });
506-
}
507-
const p = spawn(claude.command, [
508-
...claude.argsPrefix,
509-
'-p', prompt,
510-
'--model', HAIKU_MODEL,
511-
'--output-format', 'json',
512-
], { stdio: ['ignore', 'pipe', 'pipe'], cwd: os.tmpdir() });
513-
503+
// TDZ fix: declare `finish` BEFORE `resolveClaudeCommand` so the early
504+
// return at the !claude guard below doesn't ReferenceError. Triggered
505+
// only when claude CLI is missing from PATH (dormant otherwise).
514506
let stdout = '';
515507
let done = false;
516508
const finish = (signal: LayerSignal) => {
@@ -519,6 +511,30 @@ export async function checkTranscript(params: {
519511
resolve(signal);
520512
};
521513

514+
// Wrap resolveClaudeCommand + spawn in try/catch so any unexpected
515+
// throw (PATH probe failure, transient FS error) degrades gracefully
516+
// instead of rejecting the Promise with a raw exception.
517+
let claude: ReturnType<typeof resolveClaudeCommand>;
518+
try {
519+
claude = resolveClaudeCommand();
520+
} catch (err: any) {
521+
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: `resolve_error_${err?.message ?? 'unknown'}` } });
522+
}
523+
if (!claude) {
524+
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: 'claude_cli_not_found' } });
525+
}
526+
let p: ReturnType<typeof spawn>;
527+
try {
528+
p = spawn(claude.command, [
529+
...claude.argsPrefix,
530+
'-p', prompt,
531+
'--model', HAIKU_MODEL,
532+
'--output-format', 'json',
533+
], { stdio: ['ignore', 'pipe', 'pipe'], cwd: os.tmpdir() });
534+
} catch (err: any) {
535+
return finish({ layer: 'transcript_classifier', confidence: 0, meta: { degraded: true, reason: `spawn_throw_${err?.message ?? 'unknown'}` } });
536+
}
537+
522538
p.stdout.on('data', (d: Buffer) => (stdout += d.toString()));
523539
p.on('exit', (code) => {
524540
if (code !== 0) {

0 commit comments

Comments
 (0)