diff --git a/src/adapters/vscode-copilot/index.ts b/src/adapters/vscode-copilot/index.ts index fe65fcc5..ff3f1878 100644 --- a/src/adapters/vscode-copilot/index.ts +++ b/src/adapters/vscode-copilot/index.ts @@ -22,6 +22,7 @@ import { homedir } from "node:os"; import { CopilotBaseAdapter } from "../copilot-base.js"; import { resolveContextModeDataRoot } from "../base.js"; +import { isVSCodeInstallPath } from "../../util/project-dir.js"; import type { CopilotHookInput, CopilotHookModule } from "../copilot-base.js"; import type { @@ -77,10 +78,13 @@ export class VSCodeCopilotAdapter extends CopilotBaseAdapter { // from this cascade — every direct VS Code Copilot session silently // lost its workspace folder. PR #689 5-agent EM audit (Phase A // claim verification) confirmed the gap; this is the minimal fix. + // Skipped when it points at VS Code's own install directory: that + // launch cwd is poisoned for remote workspaces. // 3. process.cwd() — last resort. + const vscodeCwd = process.env.VSCODE_CWD; return ( process.env.CLAUDE_PROJECT_DIR - || process.env.VSCODE_CWD + || (vscodeCwd && !isVSCodeInstallPath(vscodeCwd) ? vscodeCwd : undefined) || process.cwd() ); } diff --git a/src/util/project-dir.ts b/src/util/project-dir.ts index 4524120a..cfdea65e 100644 --- a/src/util/project-dir.ts +++ b/src/util/project-dir.ts @@ -67,6 +67,23 @@ export function isPluginInstallPath(p: string): boolean { return /[/\\]\.(claude|codex)[/\\]plugins[/\\](cache|marketplaces)[/\\]/.test(p); } +/** + * Detect whether a path is VS Code's own application install directory + * rather than a real workspace folder. + * + * VS Code exports its launch cwd as VSCODE_CWD to spawned children. When the + * UI is launched from a shortcut and the workspace is remote, that cwd can + * be the local VS Code installation directory rather than the remote project. + */ +export function isVSCodeInstallPath(p: string): boolean { + if (!p) return false; + return ( + /[/\\]Microsoft VS Code(?: - Insiders)?(?:[/\\]|$)/i.test(p) || + /[/\\]Visual Studio Code(?: - Insiders)?(?:\.app)?(?:[/\\]|$)/i.test(p) || + /[/\\](?:usr[/\\]share|opt)[/\\]code(?:-insiders)?(?:[/\\]|$)/i.test(p) + ); +} + /** * Read the per-session project dir from Claude Code's transcript files. * diff --git a/tests/adapters/vscode-copilot.test.ts b/tests/adapters/vscode-copilot.test.ts index eb5ea21d..9170f5bc 100644 --- a/tests/adapters/vscode-copilot.test.ts +++ b/tests/adapters/vscode-copilot.test.ts @@ -99,6 +99,15 @@ describe("VSCodeCopilotAdapter", () => { expect(event.projectDir).toBe("/vscode/workspace"); }); + it("skips VSCODE_CWD when it points at the VS Code install path", () => { + delete process.env.CLAUDE_PROJECT_DIR; + process.env.VSCODE_CWD = "C:\\Users\\{userName}\\AppData\\Local\\Programs\\Microsoft VS Code"; + const event = adapter.parsePreToolUseInput({ + tool_name: "readFile", + }); + expect(event.projectDir).toBe(process.cwd()); + }); + it("CLAUDE_PROJECT_DIR still wins over VSCODE_CWD when both are set (cascade order)", () => { // Cascade order is locked: CLAUDE_PROJECT_DIR remains the top priority // for users running VS Code under Claude Code's CLI; VSCODE_CWD is the diff --git a/tests/server-stdin-eof-exit.test.ts b/tests/server-stdin-eof-exit.test.ts index 6e5ec589..3d3b828a 100644 --- a/tests/server-stdin-eof-exit.test.ts +++ b/tests/server-stdin-eof-exit.test.ts @@ -194,7 +194,11 @@ setTimeout(() => { }); try { - const result = await waitForClose(child, 2_000); + // The standalone bundle can take several seconds to initialize under + // WSL, especially when native dependencies are cold. The fatal handler + // still exits promptly after registration; allow startup time before + // classifying the child as surviving the exception storm. + const result = await waitForClose(child, 10_000); if (!result.closed) { child.kill("SIGTERM"); await waitForClose(child, 1_000); diff --git a/tests/util/project-dir.test.ts b/tests/util/project-dir.test.ts index 201f85b9..5082d7b0 100644 --- a/tests/util/project-dir.test.ts +++ b/tests/util/project-dir.test.ts @@ -6,6 +6,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { isPluginInstallPath, + isVSCodeInstallPath, resolveProjectDir, resolveProjectDirFromTranscript, } from "../../src/util/project-dir.js"; @@ -97,6 +98,30 @@ describe("isPluginInstallPath", () => { }); }); +describe("isVSCodeInstallPath", () => { + it("matches Windows stable and Insiders install paths", () => { + expect(isVSCodeInstallPath("C:\\Users\\{userName}\\AppData\\Local\\Programs\\Microsoft VS Code")).toBe(true); + expect(isVSCodeInstallPath("C:\\Users\\{userName}\\AppData\\Local\\Programs\\Microsoft VS Code - Insiders")).toBe(true); + }); + + it("matches macOS app bundles", () => { + expect(isVSCodeInstallPath("/Applications/Visual Studio Code.app/Contents/Resources/app")).toBe(true); + expect(isVSCodeInstallPath("/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app")).toBe(true); + }); + + it("matches Linux stable and Insiders install paths", () => { + expect(isVSCodeInstallPath("/usr/share/code")).toBe(true); + expect(isVSCodeInstallPath("/usr/share/code-insiders")).toBe(true); + expect(isVSCodeInstallPath("/opt/code")).toBe(true); + expect(isVSCodeInstallPath("/opt/code-insiders")).toBe(true); + }); + + it("does not match an ordinary project containing code as a substring", () => { + expect(isVSCodeInstallPath("/home/{userName}/source/code-project")).toBe(false); + expect(isVSCodeInstallPath("C:\\Users\\{userName}\\workspace\\vscode-tools")).toBe(false); + }); +}); + describe("resolveProjectDir", () => { it("returns the first non-plugin env var in priority order", () => { const result = resolveProjectDir({