diff --git a/src/plugin.ts b/src/plugin.ts index ad3b2b2..811d253 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -219,37 +219,76 @@ function isNonConfigPath(pathValue: string): boolean { return !isWithinPath(getOpenCodeConfigPrefix(), pathValue); } -const SESSION_WORKSPACE_CACHE_LIMIT = 200; - -function resolveWorkspaceDirectory(worktree: string | undefined, directory: string | undefined): string { - const envWorkspace = process.env.CURSOR_ACP_WORKSPACE?.trim(); - if (envWorkspace) { - return resolve(envWorkspace); +// Filesystem roots are never a meaningful workspace: accepting "/" (or a bare +// Windows drive root like "C:\") makes every tool treat the whole machine as +// the project, which is both unsafe and a common symptom of a daemon that +// was launched without a real cwd (e.g. systemd unit without WorkingDirectory). +export function isRootPath(pathValue: string): boolean { + if (!pathValue) { + return false; + } + const resolved = resolve(pathValue); + if (resolved === "/") { + return true; } + return /^[A-Za-z]:[\\/]?$/.test(resolved); +} - const envProjectDir = process.env.OPENCODE_CURSOR_PROJECT_DIR?.trim(); - if (envProjectDir) { - return resolve(envProjectDir); +function isAcceptableWorkspace(pathValue: string, configPrefix: string): boolean { + if (!pathValue) { + return false; + } + if (isRootPath(pathValue)) { + return false; } + if (isWithinPath(configPrefix, pathValue)) { + return false; + } + return true; +} +const SESSION_WORKSPACE_CACHE_LIMIT = 200; + +export function resolveWorkspaceDirectory( + worktree: string | undefined, + directory: string | undefined, +): string { const configPrefix = getOpenCodeConfigPrefix(); + const envWorkspace = resolveCandidate(process.env.CURSOR_ACP_WORKSPACE); + if (envWorkspace && !isRootPath(envWorkspace)) { + return envWorkspace; + } + + const envProjectDir = resolveCandidate(process.env.OPENCODE_CURSOR_PROJECT_DIR); + if (envProjectDir && !isRootPath(envProjectDir)) { + return envProjectDir; + } + const worktreeCandidate = resolveCandidate(worktree); - if (worktreeCandidate && !isWithinPath(configPrefix, worktreeCandidate)) { + if (isAcceptableWorkspace(worktreeCandidate, configPrefix)) { return worktreeCandidate; } const dirCandidate = resolveCandidate(directory); - if (dirCandidate && !isWithinPath(configPrefix, dirCandidate)) { + if (isAcceptableWorkspace(dirCandidate, configPrefix)) { return dirCandidate; } const cwd = resolve(process.cwd()); - if (cwd && !isWithinPath(configPrefix, cwd)) { + if (isAcceptableWorkspace(cwd, configPrefix)) { return cwd; } - return dirCandidate || cwd || configPrefix; + // Fall back to the user's home directory rather than "/" when every other + // signal is unusable. $HOME is always writable for the current user and + // keeps tool scopes sane even when the daemon was spawned from root. + const home = resolveCandidate(homedir()); + if (home && !isRootPath(home)) { + return home; + } + + return configPrefix; } type ProxyRuntimeState = { diff --git a/tests/unit/plugin-workspace-resolution.test.ts b/tests/unit/plugin-workspace-resolution.test.ts new file mode 100644 index 0000000..94160cd --- /dev/null +++ b/tests/unit/plugin-workspace-resolution.test.ts @@ -0,0 +1,122 @@ +import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { mkdtempSync, rmSync } from "fs"; +import { homedir, tmpdir } from "os"; +import { join, resolve } from "path"; + +import { resolveWorkspaceDirectory, isRootPath } from "../../src/plugin"; + +describe("isRootPath", () => { + it("recognises the POSIX root", () => { + expect(isRootPath("/")).toBe(true); + }); + + it.if(process.platform === "win32")("recognises Windows drive roots", () => { + expect(isRootPath("C:\\")).toBe(true); + expect(isRootPath("D:/")).toBe(true); + expect(isRootPath("C:")).toBe(true); + }); + + it("rejects ordinary paths", () => { + expect(isRootPath("/home/user")).toBe(false); + expect(isRootPath("/tmp")).toBe(false); + expect(isRootPath("")).toBe(false); + }); +}); + +describe("resolveWorkspaceDirectory", () => { + let previousXdgConfigHome: string | undefined; + let previousWorkspaceEnv: string | undefined; + let previousProjectDirEnv: string | undefined; + let previousCwd: string; + let tempConfigHome: string; + let tempWorkspace: string; + + beforeEach(() => { + previousXdgConfigHome = process.env.XDG_CONFIG_HOME; + previousWorkspaceEnv = process.env.CURSOR_ACP_WORKSPACE; + previousProjectDirEnv = process.env.OPENCODE_CURSOR_PROJECT_DIR; + previousCwd = process.cwd(); + + tempConfigHome = mkdtempSync(join(tmpdir(), "opencode-cursor-cfg-")); + tempWorkspace = mkdtempSync(join(tmpdir(), "opencode-cursor-ws-")); + process.env.XDG_CONFIG_HOME = tempConfigHome; + delete process.env.CURSOR_ACP_WORKSPACE; + delete process.env.OPENCODE_CURSOR_PROJECT_DIR; + }); + + afterEach(() => { + try { + process.chdir(previousCwd); + } catch { + // best-effort restore + } + + if (previousXdgConfigHome === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = previousXdgConfigHome; + } + if (previousWorkspaceEnv === undefined) { + delete process.env.CURSOR_ACP_WORKSPACE; + } else { + process.env.CURSOR_ACP_WORKSPACE = previousWorkspaceEnv; + } + if (previousProjectDirEnv === undefined) { + delete process.env.OPENCODE_CURSOR_PROJECT_DIR; + } else { + process.env.OPENCODE_CURSOR_PROJECT_DIR = previousProjectDirEnv; + } + + rmSync(tempConfigHome, { recursive: true, force: true }); + rmSync(tempWorkspace, { recursive: true, force: true }); + }); + + it("prefers a real worktree over cwd", () => { + const result = resolveWorkspaceDirectory(tempWorkspace, undefined); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("prefers directory when worktree is missing", () => { + const result = resolveWorkspaceDirectory(undefined, tempWorkspace); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("rejects '/' from worktree and directory and falls back to cwd", () => { + process.chdir(tempWorkspace); + const result = resolveWorkspaceDirectory("/", "/"); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("falls back to $HOME when worktree, directory, and cwd are all '/'", () => { + process.chdir("/"); + const result = resolveWorkspaceDirectory("/", "/"); + expect(result).toBe(resolve(homedir())); + expect(result).not.toBe("/"); + }); + + it("rejects '/' provided via CURSOR_ACP_WORKSPACE", () => { + process.env.CURSOR_ACP_WORKSPACE = "/"; + process.chdir(tempWorkspace); + const result = resolveWorkspaceDirectory(undefined, undefined); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("rejects '/' provided via OPENCODE_CURSOR_PROJECT_DIR", () => { + process.env.OPENCODE_CURSOR_PROJECT_DIR = "/"; + process.chdir(tempWorkspace); + const result = resolveWorkspaceDirectory(undefined, undefined); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("respects CURSOR_ACP_WORKSPACE when it is a real directory", () => { + process.env.CURSOR_ACP_WORKSPACE = tempWorkspace; + const result = resolveWorkspaceDirectory(undefined, undefined); + expect(result).toBe(resolve(tempWorkspace)); + }); + + it("skips paths inside the opencode config prefix", () => { + const insideConfig = join(tempConfigHome, "opencode", "plugin"); + const result = resolveWorkspaceDirectory(insideConfig, insideConfig); + expect(result).not.toBe(insideConfig); + }); +});