From 5020cd57666c33347fdd585374df9dc9a99c6bdf Mon Sep 17 00:00:00 2001 From: Max <109485367+hawkff@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:30:25 -0500 Subject: [PATCH 1/3] Session-pin workspace for stable tool path resolution - add per-session workspace pinning for tool-hook path resolution\n- keep file/shell tool defaults anchored to non-config workspace paths\n- harden config-dir ancestry checks with path-relative logic\n- add regression coverage for worktree-loss within same session\n- bound session workspace cache growth --- src/plugin.ts | 118 ++++++++++++++++++++------- tests/unit/plugin-tools-hook.test.ts | 62 ++++++++++++-- 2 files changed, 144 insertions(+), 36 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 5501a93..3f655b8 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -3,7 +3,7 @@ import { tool } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; import { mkdir } from "fs/promises"; import { homedir } from "os"; -import { isAbsolute, join, resolve } from "path"; +import { isAbsolute, join, relative, resolve } from "path"; import { ToolMapper, type ToolUpdate } from "./acp/tools.js"; import { startCursorOAuth } from "./auth"; import { LineBuffer } from "./streaming/line-buffer.js"; @@ -68,6 +68,34 @@ function getGlobalKey(): string { return "__opencode_cursor_proxy_server__"; } +function getOpenCodeConfigPrefix(): string { + const configHome = process.env.XDG_CONFIG_HOME + ? resolve(process.env.XDG_CONFIG_HOME) + : join(homedir(), ".config"); + return join(configHome, "opencode"); +} + +function isWithinPath(root: string, candidate: string): boolean { + const rel = relative(root, candidate); + return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); +} + +function resolveCandidate(value: string | undefined): string { + if (!value || value.trim().length === 0) { + return ""; + } + return resolve(value); +} + +function isNonConfigPath(pathValue: string): boolean { + if (!pathValue) { + return false; + } + 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) { @@ -79,23 +107,20 @@ function resolveWorkspaceDirectory(worktree: string | undefined, directory: stri return resolve(envProjectDir); } - const configHome = process.env.XDG_CONFIG_HOME - ? resolve(process.env.XDG_CONFIG_HOME) - : join(homedir(), ".config"); - const configPrefix = join(configHome, "opencode"); + const configPrefix = getOpenCodeConfigPrefix(); - const worktreeCandidate = worktree ? resolve(worktree) : ""; - if (worktreeCandidate && !worktreeCandidate.startsWith(configPrefix)) { + const worktreeCandidate = resolveCandidate(worktree); + if (worktreeCandidate && !isWithinPath(configPrefix, worktreeCandidate)) { return worktreeCandidate; } - const dirCandidate = directory ? resolve(directory) : ""; - if (dirCandidate && !dirCandidate.startsWith(configPrefix)) { + const dirCandidate = resolveCandidate(directory); + if (dirCandidate && !isWithinPath(configPrefix, dirCandidate)) { return dirCandidate; } - const cwd = process.cwd(); - if (cwd && !cwd.startsWith(configPrefix)) { + const cwd = resolve(process.cwd()); + if (cwd && !isWithinPath(configPrefix, cwd)) { return cwd; } @@ -1371,25 +1396,54 @@ function jsonSchemaToZod(jsonSchema: any): any { return zodShape; } -function resolveToolContextBaseDir(context: any, fallbackBaseDir?: string): string | null { - // OpenCode's plugin runtime may report `context.directory` as the OpenCode config dir - // (e.g. ~/.config/opencode). Prefer `worktree`, otherwise ignore config-dir `directory` - // and fall back to the provider workspace. - const configHome = process.env.XDG_CONFIG_HOME - ? resolve(process.env.XDG_CONFIG_HOME) - : join(homedir(), ".config"); - const configPrefix = join(configHome, "opencode"); +function resolveToolContextBaseDirWithSession( + context: any, + fallbackBaseDir?: string, + sessionWorkspaceBySession?: Map, +): string | null { + const sessionID = typeof context?.sessionID === "string" && context.sessionID.trim().length > 0 + ? context.sessionID.trim() + : ""; + + const worktree = resolveCandidate(typeof context?.worktree === "string" ? context.worktree : undefined); + const directory = resolveCandidate(typeof context?.directory === "string" ? context.directory : undefined); + const fallback = resolveCandidate(fallbackBaseDir); + const pinned = sessionID && sessionWorkspaceBySession + ? resolveCandidate(sessionWorkspaceBySession.get(sessionID)) + : ""; + + const pinSession = (candidate: string) => { + if (sessionID && sessionWorkspaceBySession && isNonConfigPath(candidate)) { + if (!sessionWorkspaceBySession.has(sessionID) && sessionWorkspaceBySession.size >= SESSION_WORKSPACE_CACHE_LIMIT) { + const oldestSession = sessionWorkspaceBySession.keys().next().value; + if (typeof oldestSession === "string") { + sessionWorkspaceBySession.delete(oldestSession); + } + } + sessionWorkspaceBySession.set(sessionID, candidate); + } + }; + + if (isNonConfigPath(worktree)) { + pinSession(worktree); + return worktree; + } - const worktree = typeof context?.worktree === "string" ? context.worktree.trim() : ""; - if (worktree) return worktree; + if (isNonConfigPath(pinned)) { + return pinned; + } - const directory = typeof context?.directory === "string" ? context.directory.trim() : ""; - if (directory && !resolve(directory).startsWith(configPrefix)) return directory; + if (isNonConfigPath(directory)) { + pinSession(directory); + return directory; + } - const fallback = typeof fallbackBaseDir === "string" ? fallbackBaseDir.trim() : ""; - if (fallback) return fallback; + if (isNonConfigPath(fallback)) { + pinSession(fallback); + return fallback; + } - return directory || null; + return null; } function toAbsoluteWithBase(value: unknown, baseDir: string): unknown { @@ -1408,8 +1462,9 @@ function applyToolContextDefaults( rawArgs: Record, context: any, fallbackBaseDir?: string, + sessionWorkspaceBySession?: Map, ): Record { - const baseDir = resolveToolContextBaseDir(context, fallbackBaseDir); + const baseDir = resolveToolContextBaseDirWithSession(context, fallbackBaseDir, sessionWorkspaceBySession); if (!baseDir) { return rawArgs; } @@ -1447,6 +1502,7 @@ function applyToolContextDefaults( */ function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): Record { const entries: Record = {}; + const sessionWorkspaceBySession = new Map(); const tools = registry.list(); for (const t of tools) { const handler = registry.getHandler(t.name); @@ -1459,7 +1515,13 @@ function buildToolHookEntries(registry: CoreRegistry, fallbackBaseDir?: string): args: zodArgs, async execute(args: any, context: any) { try { - const normalizedArgs = applyToolContextDefaults(toolName, args, context, fallbackBaseDir); + const normalizedArgs = applyToolContextDefaults( + toolName, + args, + context, + fallbackBaseDir, + sessionWorkspaceBySession, + ); return await handler(normalizedArgs); } catch (error: any) { log.warn("Tool hook execution failed", { tool: toolName, error: String(error?.message || error) }); diff --git a/tests/unit/plugin-tools-hook.test.ts b/tests/unit/plugin-tools-hook.test.ts index 60bb8ca..5a775af 100644 --- a/tests/unit/plugin-tools-hook.test.ts +++ b/tests/unit/plugin-tools-hook.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, mkdirSync } from "fs"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, mkdirSync, existsSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { CursorPlugin } from "../../src/plugin"; @@ -20,17 +20,20 @@ function createMockInput(directory: string, worktree: string = directory): Plugi }; } -function createToolContext(directory: string, worktree: string = directory): any { - return { - sessionID: "test-session", +function createToolContext(directory: string, worktree?: string, sessionID = "test-session"): any { + const context: any = { + sessionID, messageID: "test-message", agent: "test-agent", directory, - worktree, abort: new AbortController().signal, metadata: () => {}, ask: async () => {}, }; + if (worktree !== undefined) { + context.worktree = worktree; + } + return context; } describe("Plugin tool hook", () => { @@ -78,7 +81,7 @@ describe("Plugin tool hook", () => { path: "nested/output.txt", content: "hello from context", }, - createToolContext(projectDir), + createToolContext(projectDir, projectDir), ); const expectedPath = join(projectDir, "nested/output.txt"); @@ -126,7 +129,7 @@ describe("Plugin tool hook", () => { { command: "pwd", }, - createToolContext(projectDir), + createToolContext(projectDir, projectDir), ); expect(realpathSync((out || "").trim())).toBe(realpathSync(projectDir)); @@ -143,7 +146,7 @@ describe("Plugin tool hook", () => { { command: "pwd", }, - createToolContext(projectDir), + createToolContext(projectDir, projectDir), ); expect(realpathSync((out || "").trim())).toBe(realpathSync(projectDir)); @@ -151,4 +154,47 @@ describe("Plugin tool hook", () => { rmSync(projectDir, { recursive: true, force: true }); } }); + + it("pins non-config workspace per session and reuses it when later context loses worktree", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "plugin-hook-session-pin-project-")); + const xdgConfigHome = mkdtempSync(join(tmpdir(), "plugin-hook-session-pin-xdg-")); + const unexpectedDir = mkdtempSync(join(tmpdir(), "plugin-hook-session-pin-unexpected-")); + const prevXdg = process.env.XDG_CONFIG_HOME; + + try { + process.env.XDG_CONFIG_HOME = xdgConfigHome; + const configDir = join(xdgConfigHome, "opencode"); + mkdirSync(configDir, { recursive: true }); + + const hooks = await CursorPlugin(createMockInput(configDir, configDir)); + + const out1 = await hooks.tool?.write?.execute( + { path: "nested/first.txt", content: "first" }, + createToolContext(configDir, projectDir, "session-pin-1"), + ); + const out2 = await hooks.tool?.write?.execute( + { path: "nested/second.txt", content: "second" }, + createToolContext(configDir, undefined, "session-pin-1"), + ); + + const expectedFirstPath = join(projectDir, "nested/first.txt"); + const expectedSecondPath = join(projectDir, "nested/second.txt"); + const unexpectedPath = join(unexpectedDir, "nested/second.txt"); + + expect(readFileSync(expectedFirstPath, "utf-8")).toBe("first"); + expect(readFileSync(expectedSecondPath, "utf-8")).toBe("second"); + expect(out1).toContain(expectedFirstPath); + expect(out2).toContain(expectedSecondPath); + expect(existsSync(unexpectedPath)).toBe(false); + } finally { + if (prevXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = prevXdg; + } + rmSync(projectDir, { recursive: true, force: true }); + rmSync(xdgConfigHome, { recursive: true, force: true }); + rmSync(unexpectedDir, { recursive: true, force: true }); + } + }); }); From 39b892b90d819565556341cf9b46a5c1b8bf1b34 Mon Sep 17 00:00:00 2001 From: Max <109485367+hawkff@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:59:16 -0500 Subject: [PATCH 2/3] Fix macOS config path alias detection in workspace resolver - normalize workspace/config path comparisons using realpath-aware canonicalization\n- harden macOS case-variant path handling during config-dir detection\n- add regression coverage for symlink-alias config path resolution --- src/plugin.ts | 24 +++++++++++++++- tests/unit/plugin-tools-hook.test.ts | 43 +++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/plugin.ts b/src/plugin.ts index 3f655b8..ebf5430 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,6 +1,7 @@ import type { Plugin, PluginInput } from "@opencode-ai/plugin"; import { tool } from "@opencode-ai/plugin"; import type { Auth } from "@opencode-ai/sdk"; +import { realpathSync } from "fs"; import { mkdir } from "fs/promises"; import { homedir } from "os"; import { isAbsolute, join, relative, resolve } from "path"; @@ -75,8 +76,29 @@ function getOpenCodeConfigPrefix(): string { return join(configHome, "opencode"); } +function canonicalizePathForCompare(pathValue: string): string { + const resolvedPath = resolve(pathValue); + let normalizedPath = resolvedPath; + + try { + normalizedPath = typeof realpathSync.native === "function" + ? realpathSync.native(resolvedPath) + : realpathSync(resolvedPath); + } catch { + normalizedPath = resolvedPath; + } + + if (process.platform === "darwin") { + return normalizedPath.toLowerCase(); + } + + return normalizedPath; +} + function isWithinPath(root: string, candidate: string): boolean { - const rel = relative(root, candidate); + const normalizedRoot = canonicalizePathForCompare(root); + const normalizedCandidate = canonicalizePathForCompare(candidate); + const rel = relative(normalizedRoot, normalizedCandidate); return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel)); } diff --git a/tests/unit/plugin-tools-hook.test.ts b/tests/unit/plugin-tools-hook.test.ts index 5a775af..fd1f26b 100644 --- a/tests/unit/plugin-tools-hook.test.ts +++ b/tests/unit/plugin-tools-hook.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { mkdtempSync, readFileSync, realpathSync, rmSync, mkdirSync, existsSync } from "fs"; +import { mkdtempSync, readFileSync, realpathSync, rmSync, mkdirSync, existsSync, symlinkSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { CursorPlugin } from "../../src/plugin"; @@ -197,4 +197,45 @@ describe("Plugin tool hook", () => { rmSync(unexpectedDir, { recursive: true, force: true }); } }); + + it("treats config path aliases (symlink/case variants) as config and falls back to workspace", async () => { + const projectDir = mkdtempSync(join(tmpdir(), "plugin-hook-config-alias-project-")); + const xdgConfigHome = mkdtempSync(join(tmpdir(), "plugin-hook-config-alias-xdg-")); + const aliasParentDir = mkdtempSync(join(tmpdir(), "plugin-hook-config-alias-parent-")); + const aliasXdgHome = join(aliasParentDir, "xdg-home-alias"); + const prevXdg = process.env.XDG_CONFIG_HOME; + + try { + process.env.XDG_CONFIG_HOME = xdgConfigHome; + symlinkSync(xdgConfigHome, aliasXdgHome); + + const configDir = join(xdgConfigHome, "opencode"); + mkdirSync(configDir, { recursive: true }); + + const aliasConfigDir = join(aliasXdgHome, "opencode"); + const filename = `symlink-alias-${Date.now()}.txt`; + + const hooks = await CursorPlugin(createMockInput(configDir, projectDir)); + const out = await hooks.tool?.write?.execute( + { path: `nested/${filename}`, content: "alias fallback" }, + createToolContext(aliasConfigDir, undefined, "session-alias-1"), + ); + + const expectedPath = join(projectDir, `nested/${filename}`); + const unexpectedPath = join(configDir, `nested/${filename}`); + + expect(readFileSync(expectedPath, "utf-8")).toBe("alias fallback"); + expect(out).toContain(expectedPath); + expect(existsSync(unexpectedPath)).toBe(false); + } finally { + if (prevXdg === undefined) { + delete process.env.XDG_CONFIG_HOME; + } else { + process.env.XDG_CONFIG_HOME = prevXdg; + } + rmSync(projectDir, { recursive: true, force: true }); + rmSync(xdgConfigHome, { recursive: true, force: true }); + rmSync(aliasParentDir, { recursive: true, force: true }); + } + }); }); From 091d1f21d914f446d8f68f33b9547854a9eceb17 Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 12 Feb 2026 15:16:52 -0500 Subject: [PATCH 3/3] Fix tool-loop-guard history seeding expectations --- src/provider/tool-loop-guard.ts | 10 +++++++++- tests/unit/provider-tool-loop-guard.test.ts | 2 +- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/provider/tool-loop-guard.ts b/src/provider/tool-loop-guard.ts index 5c2e334..f66dc6e 100644 --- a/src/provider/tool-loop-guard.ts +++ b/src/provider/tool-loop-guard.ts @@ -224,6 +224,7 @@ function indexToolLoopHistory(messages: Array): { } for (const call of assistantCalls) { + const schemaSignature = deriveSchemaValidationSignature(call.name, call.argKeys); const errorClass = normalizeErrorClassForTool( call.name, byCallId.get(call.id) ?? latestByToolName.get(call.name) ?? latest ?? "unknown", @@ -240,6 +241,14 @@ function indexToolLoopHistory(messages: Array): { if (coarseSuccessFP) { incrementCount(initialCoarseCounts, coarseSuccessFP); } + + if (schemaSignature) { + incrementCount( + initialValidationCounts, + `${call.name}|schema:${schemaSignature}|validation`, + ); + incrementCount(initialValidationCoarseCounts, `${call.name}|validation`); + } continue; } const strictFingerprint = `${call.name}|${call.argShape}|${errorClass}`; @@ -247,7 +256,6 @@ function indexToolLoopHistory(messages: Array): { incrementCount(initialCounts, strictFingerprint); incrementCount(initialCoarseCounts, coarseFingerprint); - const schemaSignature = deriveSchemaValidationSignature(call.name, call.argKeys); if (!schemaSignature) { continue; } diff --git a/tests/unit/provider-tool-loop-guard.test.ts b/tests/unit/provider-tool-loop-guard.test.ts index 2c8b5e2..8ea0808 100644 --- a/tests/unit/provider-tool-loop-guard.test.ts +++ b/tests/unit/provider-tool-loop-guard.test.ts @@ -361,7 +361,7 @@ describe("tool loop guard", () => { expect(d1.errorClass).toBe("success"); expect(d1.triggered).toBe(false); expect(d2.triggered).toBe(false); - expect(d3.triggered).toBe(false); + expect(d3.triggered).toBe(true); expect(d4.triggered).toBe(true); expect(d4.fingerprint.includes("|path:")).toBe(true); expect(d4.fingerprint.endsWith("|success")).toBe(true);