diff --git a/src/loader.ts b/src/loader.ts index 1d3ce46a2..13e1605b4 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -110,6 +110,11 @@ if (!existsSync(appRoot)) { // GSD_CODING_AGENT_DIR — tells pi's getAgentDir() to return ~/.gsd/agent/ instead of ~/.gsd/agent/ process.env.GSD_CODING_AGENT_DIR = agentDir +// GSD_PKG_ROOT — absolute path to gsd-pi package root. Used by deployed extensions +// (e.g. auto.ts resume path) to import modules like resource-loader.js that live +// in the package tree, not in the deployed ~/.gsd/agent/ tree. +process.env.GSD_PKG_ROOT = gsdRoot + // RTK environment — make ~/.gsd/agent/bin visible to all child-process paths, // not just the bash tool, and force-disable RTK telemetry for GSD-managed use. applyRtkProcessEnv(process.env) diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 92ea79435..1b8d4fd47 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -125,9 +125,9 @@ import { } from "./metrics.js"; import { setLogBasePath, logWarning, logError } from "./workflow-logger.js"; import { homedir } from "node:os"; -import { join, dirname } from "node:path"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; import { readFileSync, existsSync, mkdirSync, writeFileSync, unlinkSync } from "node:fs"; -import { createRequire } from "node:module"; import { atomicWriteSync } from "./atomic-write.js"; import { autoCommitCurrentBranch, @@ -1334,13 +1334,17 @@ export async function startAuto( restoreHookState(s.basePath); // Re-sync managed resources on resume so long-lived auto sessions pick up // bundled extension updates before resume-time verification/state logic runs. + // GSD_PKG_ROOT is set by loader.ts and points to the gsd-pi package root. + // The relative import ("../../../resource-loader.js") only works from the source + // tree; deployed extensions live at ~/.gsd/agent/extensions/gsd/ where the + // relative path resolves to ~/.gsd/agent/resource-loader.js which doesn't exist. + // Using GSD_PKG_ROOT constructs a correct absolute path in both contexts (#3949). const agentDir = process.env.GSD_CODING_AGENT_DIR || join(process.env.GSD_HOME || homedir(), ".gsd", "agent"); - // Resolve resource-loader from the gsd-pi package root — the relative - // "../../../resource-loader.js" path only works from the source tree but - // breaks when extensions are deployed to ~/.gsd/agent/extensions/gsd/. - const _req = createRequire(import.meta.url); - const pkgRoot = dirname(_req.resolve("gsd-pi/package.json")); - const { initResources } = await import(join(pkgRoot, "dist", "resource-loader.js")); + const pkgRoot = process.env.GSD_PKG_ROOT; + const resourceLoaderPath = pkgRoot + ? pathToFileURL(join(pkgRoot, "dist", "resource-loader.js")).href + : new URL("../../../resource-loader.js", import.meta.url).href; + const { initResources } = await import(resourceLoaderPath); initResources(agentDir); // Open the project DB before rebuild/derive so resume uses DB-backed // state instead of falling back to stale markdown parsing (#2940). diff --git a/src/resources/extensions/gsd/tests/resource-loader-import-path.test.ts b/src/resources/extensions/gsd/tests/resource-loader-import-path.test.ts index e8a5bfe85..0908d12d6 100644 --- a/src/resources/extensions/gsd/tests/resource-loader-import-path.test.ts +++ b/src/resources/extensions/gsd/tests/resource-loader-import-path.test.ts @@ -22,16 +22,17 @@ describe("resource-loader import path", () => { ); }); - test("uses createRequire to resolve resource-loader from package root", () => { - // The fix uses createRequire to find gsd-pi/package.json, then imports - // dist/resource-loader.js from there — works in both source and deployed. + test("uses GSD_PKG_ROOT to resolve resource-loader from package root", () => { + // The fix uses GSD_PKG_ROOT (set by loader.ts) to construct an absolute + // file URL to dist/resource-loader.js — works in both source and deployed, + // and on Windows where raw paths fail with ERR_UNSUPPORTED_ESM_URL_SCHEME. assert.ok( - autoSrc.includes('createRequire(import.meta.url)'), - "auto.ts should use createRequire to resolve resource-loader", + autoSrc.includes('process.env.GSD_PKG_ROOT'), + "auto.ts should use GSD_PKG_ROOT to resolve resource-loader", ); assert.ok( - autoSrc.includes('resolve("gsd-pi/package.json")'), - "auto.ts should resolve gsd-pi package root via package.json", + autoSrc.includes('pathToFileURL'), + "auto.ts should convert path to file URL for cross-platform import()", ); }); }); diff --git a/src/tests/auto-resume-resource-loader.test.ts b/src/tests/auto-resume-resource-loader.test.ts new file mode 100644 index 000000000..9926e87c2 --- /dev/null +++ b/src/tests/auto-resume-resource-loader.test.ts @@ -0,0 +1,56 @@ +// GSD2 — Regression test: auto-mode resume resolves resource-loader.js from deployed path (#3949) +// Copyright (c) 2026 Jeremy McSpadden +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join, resolve, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const autoTsPath = join(__dirname, "..", "resources", "extensions", "gsd", "auto.ts"); +const loaderTsPath = join(__dirname, "..", "loader.ts"); + +test("loader.ts sets GSD_PKG_ROOT env var", () => { + const loaderSrc = readFileSync(loaderTsPath, "utf-8"); + assert.ok( + loaderSrc.includes("process.env.GSD_PKG_ROOT"), + "loader.ts must set GSD_PKG_ROOT so deployed extensions can locate package-root modules", + ); +}); + +test("auto.ts resume uses GSD_PKG_ROOT for resource-loader import, not bare relative path", () => { + const autoSrc = readFileSync(autoTsPath, "utf-8"); + + // Must reference GSD_PKG_ROOT to build an absolute path + assert.ok( + autoSrc.includes("process.env.GSD_PKG_ROOT"), + "auto.ts must use GSD_PKG_ROOT to resolve resource-loader.js from deployed extension path", + ); + + // The import must use the computed variable (resourceLoaderPath), not a hardcoded relative path. + assert.ok( + autoSrc.includes("await import(resourceLoaderPath)"), + "auto.ts resource-loader import must use the computed resourceLoaderPath variable, not a hardcoded relative path", + ); + + // The resourceLoaderPath must be constructed from GSD_PKG_ROOT via pathToFileURL + // (raw filesystem paths break on Windows with ERR_UNSUPPORTED_ESM_URL_SCHEME) + assert.ok( + autoSrc.includes("pathToFileURL(join(pkgRoot,"), + "auto.ts must convert the constructed path to a file URL for cross-platform import()", + ); +}); + +test("GSD_PKG_ROOT resolves resource-loader.js correctly from package root", () => { + // Simulate what auto.ts does: given GSD_PKG_ROOT, construct the path + const pkgRoot = resolve(__dirname, "..", ".."); + const resourceLoaderPath = join(pkgRoot, "dist", "resource-loader.js"); + + // After build, dist/resource-loader.js should exist + // (this test runs post-build in CI; in dev it validates the path construction) + const expectedDir = dirname(resourceLoaderPath); + assert.ok( + expectedDir.endsWith(join("dist")), + `resource-loader path should be under dist/, got: ${expectedDir}`, + ); +});