diff --git a/src/tests/bridge-package-root.test.ts b/src/tests/bridge-package-root.test.ts new file mode 100644 index 000000000..8e46101ff --- /dev/null +++ b/src/tests/bridge-package-root.test.ts @@ -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', + ) +}) diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index 2f8a4f212..b5f87cdce 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -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; diff --git a/src/web/safe-import-meta-resolve.ts b/src/web/safe-import-meta-resolve.ts new file mode 100644 index 000000000..95c388c5a --- /dev/null +++ b/src/web/safe-import-meta-resolve.ts @@ -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; + } +}