Repository: gsd-build/gsd-2 (TypeScript)

Issue:
[Bug]: gsd --web: Windows — hardcoded Linux CI path in standalone build causes 500 on all API routes
### Bug type

Incorrect behavior or output

### Summary

Summary: gsd --web standalone build embeds Linux CI runner's absolute path in chunks/229.js, causing fileURLToPath() to throw on Windows and 500 all API routes. 

### Steps to reproduce

1. Install gsd-pi 2.41.0 on Windows via npm                                                                                                                                                                                  
2. Run `gsd --web .` from any project directory                                                                                                                                                                              
3. Browser opens, UI shows "Failed to discover projects: 500"                                                                                                                                                                
4. Server logs: `TypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute`with `input: file:///home/runner/work/gsd-2/gsd-2/src/web/bridge-service.ts`                                                                                                                                              

### Expected behavior

 Web UI loads and discovers projects without errors. API routes return 200.                                                                                                                                                     

### Actual behavior

 All API routes that load chunks/229.js (including /api/projects) fail with 500. The fileURLToPath() call rejects the Linux file:// URL on Windows. Web UI is completely unusable.                                              

### GSD version

2.41.0 

### Node.js version

v24.14.0

### Operating system

Windows 11

### Install method

npm global install

### Affected area

Other

### Model and provider

N/A

### Logs, screenshots, and evidence

```shell

```

### Impact

Severity: blocks all web-mode usage on Windows                                                                                                                                                                               
Frequency: 100% repro on any Windows install                                                                                                                                                                                 
Consequence: web UI is completely non-functional for all Windows users

### Additional information

Workaround: Patch `dist/web/standalone/.next/server/chunks/229.js` — replace the `fileURLToPath("file:///home/runner/...")` call with a path derived from `process.env.GSD_WEB_PACKAGE_ROOT`, then start the standalone server manually with `GSD_WEB_PACKAGE_ROOT` set to the gsd-pi install path (no `GSD_WEB_AUTH_TOKEN` to also avoid the auth issue fixed in #1877).                                                                                                                                                                                
                                                                                                                                                                                                                                
Related: #1877 (auth token issue, already fixed)


Discussion:
I have this fixed, writing some tests now to ensure it stays that way
Fix submitted in #1893. The standalone build was evaluating `fileURLToPath(import.meta.url)` at module scope with a baked-in Linux CI path, which throws on Windows. The fix wraps this in a safe helper that returns null on failure, falling through to the `GSD_WEB_PACKAGE_ROOT` env var that `web-mode.ts` always sets at launch time.
**Additional report:** #2129 (by @trirdb) closed as duplicate — confirmed the same hardcoded Linux CI path crash on Windows. Also noted a secondary `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING` error in the same flow (tracked separately in #1959).
Additional forensic confirmation on 2.43.0 (Windows): this is still reproducible from the packaged `/api/boot` path, and the failure happens at **module import time**, before the route handler's `try/catch` can run.

### Repro evidence
Importing the packaged boot route directly fails immediately:

```text
TypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute
    at fileURLToPath (node:internal/url:1609:35)
    at dist/web/standalone/.next/server/chunks/229.js:11:22997
    at dist/web/standalone/.next/server/app/api/boot/route.js:1:536
```

Hitting `/api/boot` from the standalone server returns plain `500 Internal Server Error` (not the JSON `{ error }` payload from the route's catch block), which matches the import-time crash.

### Source path responsible
Current main still has this top-level constant in `src/web/bridge-service.ts:42`:

```ts
const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
```

That value is only used later via `resolveBridgeRuntimeConfig()` (`src/web/bridge-service.ts:1047-1051`), but because it is evaluated at module load time, the packaged standalone bundle can crash **even when `GSD_WEB_PACKAGE_ROOT` is set correctly**.

### Why `/api/boot` becomes a hard 500
`web/app/api/boot/route.ts:1` imports `collectBootPayload` / `resolveProjectCwd` from `src/web/bridge-service.ts` at module scope:

```ts
import { collectBootPayload, resolveProjectCwd } from "../../../../src/web/bridge-service.ts";
```

So when `bridge-service.ts` throws during evaluation, `GET()` never starts and this block is bypassed:

- `web/app/api/boot/route.ts:31-43`

### Concrete fix suggestion
Defer package-root resolution until after env fallback, instead of computing it in a top-level constant. For example:

```ts
function getDefaultPackageRoot(): string {
  return resolve(dirname(fileURLToPath(import.meta.url)), "../..");
}

export function resolveBridgeRuntimeConfig(env = getBridgeDeps().env ?? process.env, projectCwdOverride?: string) {
  const projectCwd = projectCwdOverride || env.GSD_WEB_PROJECT_CWD || process.cwd();
  const projectSessionsDir = env.GSD_WEB_PROJECT_SESSIONS_DIR || getProjectSessionsDir(projectCwd);
  const packageRoot = env.GSD_WEB_PACKAGE_ROOT || getDefaultPackageRoot();
  return { projectCwd, projectSessionsDir, packageRoot };
}
```

That avoids touching `import.meta.url` at import time in packaged standalone mode, where the bundle currently bakes in a Linux CI path.

Note: there is still a separate packaged-mode `/api/boot` issue for TS subprocess loading under `node_modules` (tracked elsewhere), but this Windows failure reproduces first and independently.

Repro confirmed on **gsd-pi 2.43.0** (Windows, Node 25.x):\n\n-  fails during boot readiness after 3 consecutive 500s from  ().\n- Directly loading the route module reproduces import-time crash:\n  - \n  - Error: \n  - Stack points to:  (module ).\n\nProblematic code still present in packaged bundle:\n\n\n\nBecause this executes at module load time,  cannot load on Windows, so  in  sees repeated 500 and aborts web startup.\n\nSo #1881 still appears reproducible in 2.43.0 packaged standalone.\n
Confirmed again on 2.43.0 (Windows): packaged /api/boot still fails before handler execution due ERR_INVALID_FILE_URL_PATH in standalone chunk module init.\n\nRepro command that fails immediately:\n- node -e "require('dist/web/standalone/.next/server/app/api/boot/route.js')"\n\nObserved stack head:\n- TypeError [ERR_INVALID_FILE_URL_PATH]: File URL path must be absolute\n- at dist/web/standalone/.next/server/chunks/229.js:11:22997\n\nThis is still consistent with the baked Linux file URL path in the package-root fallback expression and causes gsd --web boot readiness to abort after repeated 500s.
I'm still getting the same error with version 2.47.0. According to gsd forensic, pull request #1893 should fix the issue. Is there something preventing it from being merged?

Thanks for your work



Patch:
diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts
index 32ed1048bf..fc942bf713 100644
--- a/src/web/bridge-service.ts
+++ b/src/web/bridge-service.ts
@@ -39,7 +39,30 @@ import {
 } from "./auto-dashboard-service.ts";
 import { resolveGsdCliEntry } from "./cli-entry.ts";
 
-const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+// Lazily computed fallback — import.meta.url is baked in at build time by
+// webpack, so when the standalone bundle built on Linux CI runs on Windows the
+// literal file:// URL contains a Unix path that fileURLToPath() rejects.
+// Deferring the computation means it only fires when GSD_WEB_PACKAGE_ROOT is
+// absent, and if it does fire we handle the cross-platform failure gracefully.
+let _defaultPackageRoot: string | undefined;
+function getDefaultPackageRoot(): string {
+  if (_defaultPackageRoot !== undefined) return _defaultPackageRoot;
+  try {
+    _defaultPackageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+  } catch {
+    // Standalone bundle running on a different OS than the builder — the
+    // baked-in import.meta.url is not a valid local file URL.  Fall back to
+    // cwd which is the best available approximation; callers that need the
+    // real package root should set GSD_WEB_PACKAGE_ROOT.
+    _defaultPackageRoot = process.cwd();
+  }
+  return _defaultPackageRoot;
+}
+
+/** @internal — test-only: reset the memoized default package root */
+export function resetDefaultPackageRootForTests(): void {
+  _defaultPackageRoot = undefined;
+}
 const RESPONSE_TIMEOUT_MS = 30_000;
 const START_TIMEOUT_MS = 150_000;
 const MAX_STDERR_BUFFER = 8_000;
@@ -1047,7 +1070,7 @@ async function fallbackWorkspaceIndex(basePath: string): Promise<GSDWorkspaceInd
 export function resolveBridgeRuntimeConfig(env: NodeJS.ProcessEnv = getBridgeDeps().env ?? process.env, projectCwdOverride?: string): BridgeRuntimeConfig {
   const projectCwd = projectCwdOverride || env.GSD_WEB_PROJECT_CWD || process.cwd();
   const projectSessionsDir = env.GSD_WEB_PROJECT_SESSIONS_DIR || getProjectSessionsDir(projectCwd);
-  const packageRoot = env.GSD_WEB_PACKAGE_ROOT || DEFAULT_PACKAGE_ROOT;
+  const packageRoot = env.GSD_WEB_PACKAGE_ROOT || getDefaultPackageRoot();
   return { projectCwd, projectSessionsDir, packageRoot };
 }
 


Test patch:
diff --git a/src/tests/web-bridge-package-root.test.ts b/src/tests/web-bridge-package-root.test.ts
new file mode 100644
index 0000000000..f919ce8731
--- /dev/null
+++ b/src/tests/web-bridge-package-root.test.ts
@@ -0,0 +1,70 @@
+/**
+ * Regression tests for the default package root fallback in bridge-service.
+ *
+ * Issue: gsd-build/gsd-2#1881
+ * The standalone Next.js bundle bakes import.meta.url at build time with the
+ * CI runner's absolute path.  On Windows, fileURLToPath() rejects the Unix
+ * file:// URL at module load time, 500-ing all API routes.
+ *
+ * The fix makes the fallback lazy and catch-guarded so the module loads safely
+ * on any OS regardless of what import.meta.url resolved to at build time.
+ */
+
+import test from "node:test";
+import assert from "node:assert/strict";
+import { resolve } from "node:path";
+
+const bridge = await import("../web/bridge-service.ts");
+
+test("resolveBridgeRuntimeConfig uses GSD_WEB_PACKAGE_ROOT when set", () => {
+  const env = {
+    GSD_WEB_PACKAGE_ROOT: "/custom/package/root",
+    GSD_WEB_PROJECT_CWD: "/some/project",
+  } as unknown as NodeJS.ProcessEnv;
+
+  const config = bridge.resolveBridgeRuntimeConfig(env);
+  assert.equal(config.packageRoot, "/custom/package/root");
+});
+
+test("resolveBridgeRuntimeConfig falls back to lazy default when GSD_WEB_PACKAGE_ROOT is absent", () => {
+  // Reset the memoized value so we exercise the lazy computation path.
+  bridge.resetDefaultPackageRootForTests();
+
+  const env = {
+    GSD_WEB_PROJECT_CWD: "/some/project",
+  } as unknown as NodeJS.ProcessEnv;
+
+  // Should not throw — the lazy getter catches cross-platform failures.
+  const config = bridge.resolveBridgeRuntimeConfig(env);
+  assert.equal(typeof config.packageRoot, "string");
+  assert.ok(config.packageRoot.length > 0, "packageRoot must be a non-empty string");
+});
+
+test("lazy default package root is an absolute path", () => {
+  bridge.resetDefaultPackageRootForTests();
+
+  const env = {
+    GSD_WEB_PROJECT_CWD: "/some/project",
+  } as unknown as NodeJS.ProcessEnv;
+
+  const config = bridge.resolveBridgeRuntimeConfig(env);
+  // resolve() returns the same path if already absolute.
+  assert.equal(config.packageRoot, resolve(config.packageRoot));
+});
+
+test("lazy default package root is memoized across calls", () => {
+  bridge.resetDefaultPackageRootForTests();
+
+  const env = {} as unknown as NodeJS.ProcessEnv;
+
+  const first = bridge.resolveBridgeRuntimeConfig(env).packageRoot;
+  const second = bridge.resolveBridgeRuntimeConfig(env).packageRoot;
+  assert.equal(first, second, "memoized value should be stable across calls");
+});
+
+test("module loads without throwing (regression: eager fileURLToPath crash)", () => {
+  // The fact that we can import bridge-service at the top of this file without
+  // an unhandled exception is itself the primary regression gate.  This test
+  // makes that contract explicit.
+  assert.ok(typeof bridge.resolveBridgeRuntimeConfig === "function");
+});