From 39033473945996ab752fec87b26bde8992651762 Mon Sep 17 00:00:00 2001 From: John Mylchreest Date: Sun, 8 Mar 2026 23:20:43 +0000 Subject: [PATCH 1/2] fix: resolve symlinks in Filesystem.resolve() to prevent duplicate Instance contexts Filesystem.resolve() used path.resolve() which normalizes path segments but does not resolve symlinks. When opencode runs from a symlinked directory, Instance.provide() could create duplicate contexts for the same physical directory, causing Bus event isolation and a blank TUI. Move symlink resolution (realpathSync) into Filesystem.resolve() so all callers benefit. Falls back to the unresolved path if the target does not exist, matching the guard pattern in normalizePath(). Fixes anomalyco/opencode#16647 Fixes anomalyco/opencode#15482 --- packages/opencode/src/util/filesystem.ts | 9 ++++++++- .../opencode/test/util/filesystem.test.ts | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index fb1f5ab9e53..c8102b2ddb3 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -114,8 +114,15 @@ export namespace Filesystem { } // We cannot rely on path.resolve() here because git.exe may come from Git Bash, Cygwin, or MSYS2, so we need to translate these paths at the boundary. + // Also resolves symlinks so that callers using the result as a cache key + // always get the same canonical path for a given physical directory. export function resolve(p: string): string { - return normalizePath(pathResolve(windowsPath(p))) + const resolved = pathResolve(windowsPath(p)) + try { + return normalizePath(realpathSync(resolved)) + } catch { + return normalizePath(resolved) + } } export function windowsPath(p: string): string { diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index c757e3424db..34147f3c77d 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -502,5 +502,24 @@ describe("filesystem", () => { const drive = tmp.path[0].toLowerCase() expect(Filesystem.resolve(`/mnt/${drive}`)).toBe(Filesystem.resolve(`${drive.toUpperCase()}:/`)) }) + + test("resolves symlinked directory to canonical path", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const link = tmp.path + "-symlink" + await fs.symlink(tmp.path, link) + try { + expect(Filesystem.resolve(link)).toBe(Filesystem.resolve(tmp.path)) + } finally { + await fs.unlink(link).catch(() => {}) + } + }) + + test("returns unresolved path when target does not exist", async () => { + await using tmp = await tmpdir() + const nonExistent = path.join(tmp.path, "does-not-exist-" + Date.now()) + const result = Filesystem.resolve(nonExistent) + expect(result).toBe(Filesystem.normalizePath(path.resolve(nonExistent))) + }) }) }) From 4082af38b998829d8b74db0174737bfb537d15f4 Mon Sep 17 00:00:00 2001 From: John Mylchreest Date: Mon, 9 Mar 2026 01:17:37 +0000 Subject: [PATCH 2/2] fix: narrow Filesystem.resolve() catch to ENOENT only The bare catch swallowed all realpathSync errors (EACCES, ELOOP, ENOTDIR), silently falling back to the unresolved path. This could re-introduce duplicate Instance contexts for broken symlinks. Now only ENOENT is caught (path doesn't exist yet); all other errors propagate to the caller. Fixes #16659 --- packages/opencode/src/util/filesystem.ts | 5 ++- .../opencode/test/util/filesystem.test.ts | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index c8102b2ddb3..37f00c6b9c8 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -120,8 +120,9 @@ export namespace Filesystem { const resolved = pathResolve(windowsPath(p)) try { return normalizePath(realpathSync(resolved)) - } catch { - return normalizePath(resolved) + } catch (e) { + if (isEnoent(e)) return normalizePath(resolved) + throw e } } diff --git a/packages/opencode/test/util/filesystem.test.ts b/packages/opencode/test/util/filesystem.test.ts index 34147f3c77d..dfb5791fc56 100644 --- a/packages/opencode/test/util/filesystem.test.ts +++ b/packages/opencode/test/util/filesystem.test.ts @@ -521,5 +521,48 @@ describe("filesystem", () => { const result = Filesystem.resolve(nonExistent) expect(result).toBe(Filesystem.normalizePath(path.resolve(nonExistent))) }) + + test("throws ELOOP on symlink cycle", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const a = path.join(tmp.path, "a") + const b = path.join(tmp.path, "b") + await fs.symlink(b, a) + await fs.symlink(a, b) + expect(() => Filesystem.resolve(a)).toThrow() + }) + + test("throws EACCES on permission-denied symlink target", async () => { + if (process.platform === "win32") return + if (process.getuid?.() === 0) return // skip when running as root + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "restricted") + await fs.mkdir(dir) + const link = path.join(tmp.path, "link") + await fs.symlink(dir, link) + // Remove all permissions from the target directory's parent entry + // realpathSync needs execute permission on each component + await fs.chmod(dir, 0o000) + try { + // Resolving a path *inside* the restricted dir should throw EACCES + expect(() => Filesystem.resolve(path.join(link, "child"))).toThrow() + } finally { + await fs.chmod(dir, 0o755) + } + }) + + test("rethrows non-ENOENT errors", () => { + // Verify the contract: only ENOENT is caught, other errors propagate + // We test this indirectly via ELOOP above, but also verify ENOTDIR + if (process.platform === "win32") return + const tmpFile = path.join(process.env.TMPDIR || "/tmp", `oc-test-${Date.now()}`) + require("fs").writeFileSync(tmpFile, "not-a-directory") + try { + // Treating a file as a directory component should throw ENOTDIR + expect(() => Filesystem.resolve(path.join(tmpFile, "child"))).toThrow() + } finally { + require("fs").unlinkSync(tmpFile) + } + }) }) })