diff --git a/src/resources/extensions/gsd/auto.ts b/src/resources/extensions/gsd/auto.ts index 54f8b4da5..d5a20a264 100644 --- a/src/resources/extensions/gsd/auto.ts +++ b/src/resources/extensions/gsd/auto.ts @@ -241,6 +241,29 @@ const s = new AutoSession(); /** Throttle STATE.md rebuilds — at most once per 30 seconds */ const STATE_REBUILD_MIN_INTERVAL_MS = 30_000; +function captureProjectRootEnv(projectRoot: string): void { + if (!s.projectRootEnvCaptured) { + s.hadProjectRootEnv = Object.prototype.hasOwnProperty.call(process.env, "GSD_PROJECT_ROOT"); + s.previousProjectRootEnv = process.env.GSD_PROJECT_ROOT ?? null; + s.projectRootEnvCaptured = true; + } + process.env.GSD_PROJECT_ROOT = projectRoot; +} + +function restoreProjectRootEnv(): void { + if (!s.projectRootEnvCaptured) return; + + if (s.hadProjectRootEnv && s.previousProjectRootEnv !== null) { + process.env.GSD_PROJECT_ROOT = s.previousProjectRootEnv; + } else { + delete process.env.GSD_PROJECT_ROOT; + } + + s.previousProjectRootEnv = null; + s.hadProjectRootEnv = false; + s.projectRootEnvCaptured = false; +} + export function shouldUseWorktreeIsolation(): boolean { const prefs = loadEffectiveGSDPreferences()?.preferences?.git; if (prefs?.isolation === "worktree") return true; @@ -542,6 +565,7 @@ function handleLostSessionLock( s.active = false; s.paused = false; clearUnitTimeout(); + restoreProjectRootEnv(); deregisterSigtermHandler(); clearCmuxSidebar(loadEffectiveGSDPreferences()?.preferences); const base = lockBase(); @@ -577,6 +601,7 @@ function cleanupAfterLoopExit(ctx: ExtensionContext): void { s.currentUnit = null; s.active = false; clearUnitTimeout(); + restoreProjectRootEnv(); // Clear crash lock and release session lock so the next `/gsd next` does // not see a stale lock with the current PID and treat it as a "remote" @@ -846,6 +871,7 @@ export async function stopAuto( ctx?.ui.setStatus("gsd-auto", undefined); ctx?.ui.setWidget("gsd-progress", undefined); ctx?.ui.setFooter(undefined); + restoreProjectRootEnv(); // Reset all session state in one call s.reset(); @@ -934,6 +960,7 @@ export async function pauseAuto( s.active = false; s.paused = true; + restoreProjectRootEnv(); s.pendingVerificationRetry = null; s.verificationRetryCount.clear(); ctx?.ui.setStatus("gsd-auto", "paused"); @@ -1305,6 +1332,7 @@ export async function startAuto( ); logCmuxEvent(loadEffectiveGSDPreferences()?.preferences, s.stepMode ? "Step-mode resumed." : "Auto-mode resumed.", "progress"); + captureProjectRootEnv(s.originalBasePath || s.basePath); await autoLoop(ctx, pi, s, buildLoopDeps()); cleanupAfterLoopExit(ctx); return; @@ -1329,6 +1357,7 @@ export async function startAuto( ); if (!ready) return; + captureProjectRootEnv(s.originalBasePath || s.basePath); try { syncCmuxSidebar(loadEffectiveGSDPreferences()?.preferences, await deriveState(s.basePath)); } catch (err) { @@ -1569,4 +1598,3 @@ export { buildLoopRemediationSteps, } from "./auto-recovery.js"; export { resolveExpectedArtifactPath } from "./auto-artifact-paths.js"; - diff --git a/src/resources/extensions/gsd/auto/session.ts b/src/resources/extensions/gsd/auto/session.ts index 5f822a51f..dbf8cd0b9 100644 --- a/src/resources/extensions/gsd/auto/session.ts +++ b/src/resources/extensions/gsd/auto/session.ts @@ -84,6 +84,9 @@ export class AutoSession { // ── Paths ──────────────────────────────────────────────────────────────── basePath = ""; originalBasePath = ""; + previousProjectRootEnv: string | null = null; + hadProjectRootEnv = false; + projectRootEnvCaptured = false; gitService: GitServiceImpl | null = null; // ── Dispatch counters ──────────────────────────────────────────────────── @@ -192,6 +195,9 @@ export class AutoSession { // Paths this.basePath = ""; this.originalBasePath = ""; + this.previousProjectRootEnv = null; + this.hadProjectRootEnv = false; + this.projectRootEnvCaptured = false; this.gitService = null; // Dispatch diff --git a/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts new file mode 100644 index 000000000..98f6a11e2 --- /dev/null +++ b/src/resources/extensions/gsd/tests/auto-project-root-env.test.ts @@ -0,0 +1,29 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const sourcePath = join(import.meta.dirname, "..", "auto.ts"); +const source = readFileSync(sourcePath, "utf-8"); + +test("auto-mode captures GSD_PROJECT_ROOT before entering the dispatch loop", () => { + const captureDeclIdx = source.indexOf("function captureProjectRootEnv(projectRoot: string): void {"); + assert.ok(captureDeclIdx > -1, "auto.ts should define captureProjectRootEnv()"); + + const resumeCallIdx = source.indexOf("captureProjectRootEnv(s.originalBasePath || s.basePath);"); + assert.ok(resumeCallIdx > -1, "auto.ts should capture GSD_PROJECT_ROOT before resume autoLoop"); + + const firstAutoLoopIdx = source.indexOf("await autoLoop(ctx, pi, s, buildLoopDeps());"); + assert.ok(firstAutoLoopIdx > -1, "auto.ts should invoke autoLoop()"); + assert.ok( + resumeCallIdx < firstAutoLoopIdx, + "auto.ts must set GSD_PROJECT_ROOT before the first autoLoop() call", + ); +}); + +test("auto-mode restores GSD_PROJECT_ROOT when execution stops or pauses", () => { + assert.match(source, /function restoreProjectRootEnv\(\): void \{/); + assert.match(source, /cleanupAfterLoopExit\(ctx: ExtensionContext\): void \{[\s\S]*restoreProjectRootEnv\(\);/); + assert.match(source, /export async function pauseAuto\([\s\S]*restoreProjectRootEnv\(\);/); + assert.match(source, /\} finally \{[\s\S]*restoreProjectRootEnv\(\);[\s\S]*s\.reset\(\);/); +}); diff --git a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts index 97cb3b3c1..cceac52aa 100644 --- a/src/resources/extensions/gsd/tests/workflow-mcp.test.ts +++ b/src/resources/extensions/gsd/tests/workflow-mcp.test.ts @@ -1,7 +1,8 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { @@ -70,6 +71,43 @@ test("buildWorkflowMcpServers mirrors explicit launch config", () => { }); }); +test("detectWorkflowMcpLaunchConfig resolves the bundled server from GSD_PROJECT_ROOT", () => { + const repoRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-root-")); + const worktreeRoot = mkdtempSync(join(tmpdir(), "gsd-workflow-worktree-")); + const cliPath = join(repoRoot, "packages", "mcp-server", "dist", "cli.js"); + + mkdirSync(join(repoRoot, "packages", "mcp-server", "dist"), { recursive: true }); + writeFileSync(cliPath, "#!/usr/bin/env node\n", "utf-8"); + + const launch = detectWorkflowMcpLaunchConfig(worktreeRoot, { + GSD_PROJECT_ROOT: repoRoot, + }); + + assert.deepEqual(launch, { + name: "gsd-workflow", + command: process.execPath, + args: [cliPath], + cwd: repoRoot, + env: { + GSD_PERSIST_WRITE_GATE_STATE: "1", + GSD_WORKFLOW_PROJECT_ROOT: repoRoot, + }, + }); +}); + +test("detectWorkflowMcpLaunchConfig resolves the bundled server relative to the installed GSD package", () => { + const launch = detectWorkflowMcpLaunchConfig("/tmp/project", { + GSD_BIN_PATH: "/tmp/gsd-loader.js", + }); + + assert.equal(launch?.command, process.execPath); + assert.equal(launch?.cwd, "/tmp/project"); + assert.equal(launch?.env?.GSD_CLI_PATH, "/tmp/gsd-loader.js"); + assert.equal(launch?.env?.GSD_WORKFLOW_PROJECT_ROOT, "/tmp/project"); + assert.equal(typeof launch?.args?.[0], "string"); + assert.match(launch?.args?.[0] ?? "", /packages[\/\\]mcp-server[\/\\]dist[\/\\]cli\.js$/); +}); + test("usesWorkflowMcpTransport matches local externalCli providers", () => { assert.equal(usesWorkflowMcpTransport("externalCli", "local://claude-code"), true); assert.equal(usesWorkflowMcpTransport("externalCli", "https://api.example.com"), false); diff --git a/src/resources/extensions/gsd/workflow-mcp.ts b/src/resources/extensions/gsd/workflow-mcp.ts index a4b5047cc..ead4ea8b5 100644 --- a/src/resources/extensions/gsd/workflow-mcp.ts +++ b/src/resources/extensions/gsd/workflow-mcp.ts @@ -1,6 +1,7 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; export interface WorkflowMcpLaunchConfig { name: string; @@ -66,6 +67,15 @@ function lookupCommand(command: string, platform: NodeJS.Platform = process.plat } } +function getBundledWorkflowMcpCliPath(env: NodeJS.ProcessEnv): string | null { + if (!env.GSD_BIN_PATH?.trim() && !env.GSD_CLI_PATH?.trim()) return null; + + const bundledCli = resolve( + fileURLToPath(new URL("../../../../packages/mcp-server/dist/cli.js", import.meta.url)), + ); + return existsSync(bundledCli) ? bundledCli : null; +} + export function detectWorkflowMcpLaunchConfig( projectRoot = process.cwd(), env: NodeJS.ProcessEnv = process.env, @@ -75,16 +85,19 @@ export function detectWorkflowMcpLaunchConfig( const explicitArgs = parseJsonEnv(env, "GSD_WORKFLOW_MCP_ARGS"); const explicitEnv = parseJsonEnv>(env, "GSD_WORKFLOW_MCP_ENV"); const explicitCwd = env.GSD_WORKFLOW_MCP_CWD?.trim(); + const gsdCliPath = env.GSD_CLI_PATH?.trim() || env.GSD_BIN_PATH?.trim(); const workflowProjectRoot = explicitEnv?.GSD_WORKFLOW_PROJECT_ROOT?.trim() || env.GSD_WORKFLOW_PROJECT_ROOT?.trim() || + env.GSD_PROJECT_ROOT?.trim() || explicitCwd || projectRoot; + const resolvedWorkflowProjectRoot = resolve(workflowProjectRoot); if (explicitCommand) { const launchEnv = { ...(explicitEnv ?? {}), - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", GSD_WORKFLOW_PROJECT_ROOT: resolve(workflowProjectRoot), }; @@ -97,17 +110,32 @@ export function detectWorkflowMcpLaunchConfig( }; } - const distCli = resolve(projectRoot, "packages", "mcp-server", "dist", "cli.js"); + const bundledCli = getBundledWorkflowMcpCliPath(env); + if (bundledCli) { + return { + name, + command: process.execPath, + args: [bundledCli], + cwd: resolvedWorkflowProjectRoot, + env: { + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), + GSD_PERSIST_WRITE_GATE_STATE: "1", + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, + }, + }; + } + + const distCli = resolve(resolvedWorkflowProjectRoot, "packages", "mcp-server", "dist", "cli.js"); if (existsSync(distCli)) { return { name, command: process.execPath, args: [distCli], - cwd: projectRoot, + cwd: resolvedWorkflowProjectRoot, env: { - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", - GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot), + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, }, }; } @@ -118,9 +146,9 @@ export function detectWorkflowMcpLaunchConfig( name, command: binPath, env: { - ...(env.GSD_CLI_PATH ? { GSD_CLI_PATH: env.GSD_CLI_PATH } : {}), + ...(gsdCliPath ? { GSD_CLI_PATH: gsdCliPath } : {}), GSD_PERSIST_WRITE_GATE_STATE: "1", - GSD_WORKFLOW_PROJECT_ROOT: resolve(projectRoot), + GSD_WORKFLOW_PROJECT_ROOT: resolvedWorkflowProjectRoot, }, }; }