fix(web): use safePackageRootFromImportUrl for cross-platform package root (#1881) (#1893)

Replace raw fileURLToPath in getDefaultPackageRoot with safePackageRootFromImportUrl
which returns null instead of throwing when the URL is not a valid local file URL.
This prevents the standalone bundle from crashing on Windows when import.meta.url
is baked in at build time with a Linux file:// path.

Co-authored-by: trek-e <trek-e@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher 2026-04-05 07:43:46 -04:00 committed by GitHub
parent b155c92708
commit ba71db3b28
3 changed files with 113 additions and 16 deletions

View file

@ -0,0 +1,71 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
/**
* Regression test for #1881: Windows web mode hardcoded Linux CI path in
* standalone build.
*
* The Next.js standalone build bakes import.meta.url into compiled chunks as
* the CI runner's absolute Linux path (file:///home/runner/work/gsd-2/gsd-2/…).
* On Windows, fileURLToPath() rejects this with "File URL path must be
* absolute". The fix wraps the derivation in safePackageRootFromImportUrl()
* so the module-level constant never throws, and resolveBridgeRuntimeConfig
* falls through to the GSD_WEB_PACKAGE_ROOT env var.
*/
import { safePackageRootFromImportUrl } from '../web/safe-import-meta-resolve.ts'
test('safePackageRootFromImportUrl returns a path for a valid native file URL', () => {
const result = safePackageRootFromImportUrl(import.meta.url)
assert.ok(result !== null, 'should return a path for a valid native file URL')
assert.ok(typeof result === 'string')
assert.ok(result.length > 0)
})
test('safePackageRootFromImportUrl returns null for a non-file URL', () => {
const result = safePackageRootFromImportUrl('https://example.com/foo/bar.ts')
assert.equal(result, null)
})
test('safePackageRootFromImportUrl returns null for empty input', () => {
const result = safePackageRootFromImportUrl('')
assert.equal(result, null)
})
test('safePackageRootFromImportUrl returns null for malformed URL', () => {
const result = safePackageRootFromImportUrl('not-a-url')
assert.equal(result, null)
})
test('safePackageRootFromImportUrl respects ancestorLevels', () => {
// With 0 levels, should return the directory of the module itself
const level0 = safePackageRootFromImportUrl(import.meta.url, 0)
const level2 = safePackageRootFromImportUrl(import.meta.url, 2)
assert.ok(level0 !== null)
assert.ok(level2 !== null)
// level0 is deeper than level2
assert.ok(level0.length > level2.length)
})
test('bridge-service.ts uses safePackageRootFromImportUrl for DEFAULT_PACKAGE_ROOT', () => {
const source = readFileSync(join(process.cwd(), 'src', 'web', 'bridge-service.ts'), 'utf-8')
assert.ok(
source.includes('safePackageRootFromImportUrl(import.meta.url)'),
'bridge-service.ts must derive DEFAULT_PACKAGE_ROOT via the safe helper',
)
const rawPattern = 'const DEFAULT_PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url'
assert.ok(
!source.includes(rawPattern),
'bridge-service.ts must not use raw fileURLToPath for DEFAULT_PACKAGE_ROOT',
)
})
test('bridge-service resolveBridgeRuntimeConfig falls back to lazy default', () => {
const source = readFileSync(join(process.cwd(), 'src', 'web', 'bridge-service.ts'), 'utf-8')
assert.ok(
source.includes('env.GSD_WEB_PACKAGE_ROOT || getDefaultPackageRoot()'),
'resolveBridgeRuntimeConfig must fall back to lazy default package root',
)
})

View file

@ -2,9 +2,10 @@ import { execFile, spawn, type ChildProcess, type SpawnOptions } from "node:chil
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { StringDecoder } from "node:string_decoder";
import type { Readable } from "node:stream";
import { join, resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { join, resolve } from "node:path";
import { pathToFileURL } from "node:url";
import { resolveTypeStrippingFlag, resolveSubprocessModule, buildSubprocessPrefixArgs } from "./ts-subprocess-flags.ts";
import { safePackageRootFromImportUrl } from "./safe-import-meta-resolve.ts";
import type { AgentSessionEvent, SessionStateChangeReason } from "../../packages/pi-coding-agent/src/core/agent-session.ts";
import type {
@ -39,23 +40,14 @@ import {
} from "./auto-dashboard-service.ts";
import { resolveGsdCliEntry } from "./cli-entry.ts";
// 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.
// The standalone Next.js bundle bakes import.meta.url at build time with the
// CI runner's absolute path. On Windows, fileURLToPath() rejects a Linux
// file:// URL at module load time. Use a lazy getter so the derivation is
// deferred to first use (not module load) and falls back to cwd on failure.
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();
}
_defaultPackageRoot = safePackageRootFromImportUrl(import.meta.url) ?? process.cwd();
return _defaultPackageRoot;
}
@ -63,6 +55,7 @@ function getDefaultPackageRoot(): string {
export function resetDefaultPackageRootForTests(): void {
_defaultPackageRoot = undefined;
}
const RESPONSE_TIMEOUT_MS = 30_000;
const START_TIMEOUT_MS = 150_000;
const MAX_STDERR_BUFFER = 8_000;

View file

@ -0,0 +1,33 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* Derive a package root from an import.meta.url, returning null on failure.
*
* The Next.js standalone build bakes import.meta.url as the CI runner's
* absolute path (e.g. file:///home/runner/work/gsd-2/gsd-2/src/web/bridge-service.ts).
* On Windows, fileURLToPath() rejects this Linux path with
* "File URL path must be absolute".
*
* This helper catches that error so the module-level constant never throws,
* letting resolveBridgeRuntimeConfig() fall through to the GSD_WEB_PACKAGE_ROOT
* env var that web-mode.ts always sets at launch time.
*
* @param importUrl - The value of import.meta.url at the call site.
* @param ancestorLevels - How many directory levels to ascend from the module's
* directory to reach the package root (default 2: src/web/ -> root).
* @returns Resolved absolute package root path, or null if the URL cannot be
* converted to a native path on this platform.
*/
export function safePackageRootFromImportUrl(
importUrl: string,
ancestorLevels = 2,
): string | null {
try {
const moduleDir = dirname(fileURLToPath(importUrl));
const segments = Array.from({ length: ancestorLevels }, () => "..");
return resolve(moduleDir, ...segments);
} catch {
return null;
}
}