From a2f508faac03334f574ecf5a289b0f8e4fea4657 Mon Sep 17 00:00:00 2001 From: can1357 Date: Thu, 30 Apr 2026 04:25:08 +0200 Subject: [PATCH] fix(coding-agent): resolve junctions/symlinks when classifying omp update target isPathInDirectory only normalized strings via path.resolve, so on Windows when Bun is installed via Scoop (~/.bun is a junction to scoop\persist\ Oven-sh.Bun\.bun) the omp path from $which and the bunBinDir from 'bun pm bin -g' compared as different directories, causing 'omp update' to take the binary-swap path instead of 'bun install -g' and fail with EPERM unlinking omp.exe.bak (Bun has the running exe open). Layer fs.realpathSync.native on top of the existing lexical guard, resolving the file's parent dir so non-existent target paths still fall through. Fixes #845 --- packages/coding-agent/src/cli/update-cli.ts | 26 +++++++++- .../coding-agent/test/issue-845-repro.test.ts | 52 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 packages/coding-agent/test/issue-845-repro.test.ts diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index d8b3caded..8dcb47b1c 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -53,13 +53,37 @@ function normalizePathForComparison(filePath: string): string { return normalized; } -function isPathInDirectory(filePath: string, directoryPath: string): boolean { +function tryRealpath(p: string): string | undefined { + try { + return fs.realpathSync.native(p); + } catch { + return undefined; + } +} + +function isPathInDirectoryLexical(filePath: string, directoryPath: string): boolean { const normalizedPath = normalizePathForComparison(path.resolve(filePath)); const normalizedDirectory = normalizePathForComparison(path.resolve(directoryPath)); const relativePath = path.relative(normalizedDirectory, normalizedPath); return relativePath === "" || (!relativePath.startsWith("..") && !path.isAbsolute(relativePath)); } +function isPathInDirectory(filePath: string, directoryPath: string): boolean { + if (isPathInDirectoryLexical(filePath, directoryPath)) return true; + // Layer realpath resolution on top of the lexical guard. On Windows, ~/.bun + // is a junction when Bun is installed via Scoop, so `bun pm bin -g` and the + // PATH-resolved omp path can refer to the same directory through different + // strings. path.resolve does not traverse junctions/symlinks; realpath does. + // Resolve the file's parent directory to tolerate the file itself not yet + // existing (e.g. a fresh install path) while still catching link-traversed + // equality once the directory exists. + const fileDir = tryRealpath(path.dirname(path.resolve(filePath))); + const dirReal = tryRealpath(path.resolve(directoryPath)); + if (!fileDir || !dirReal) return false; + const resolvedFile = path.join(fileDir, path.basename(filePath)); + return isPathInDirectoryLexical(resolvedFile, dirReal); +} + type UpdateTarget = { method: "bun" } | { method: "binary"; path: string }; function resolveUpdateMethod(ompPath: string, bunBinDir: string | undefined): "bun" | "binary" { diff --git a/packages/coding-agent/test/issue-845-repro.test.ts b/packages/coding-agent/test/issue-845-repro.test.ts new file mode 100644 index 000000000..cc30bba25 --- /dev/null +++ b/packages/coding-agent/test/issue-845-repro.test.ts @@ -0,0 +1,52 @@ +import { afterAll, beforeAll, describe, expect, it } from "bun:test"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { _resolveUpdateMethodForTest } from "../src/cli/update-cli"; + +// Issue #845: on Windows with Bun installed via Scoop, ~/.bun is a junction +// to scoop\persist\Oven-sh.Bun\.bun. `bun pm bin -g` and the omp path that +// $which finds may end up referring to the same directory through different +// path strings (one through the junction, one through the real target). +// `isPathInDirectory` did purely lexical comparison via path.resolve, which +// does not follow filesystem links, so it misclassified Bun-installed omp +// as "binary" and tried to swap omp.exe in place – which fails on Windows +// because Bun has the file open (EPERM on unlink of .bak). +// +// We reproduce the realpath-resolution bug with a symlink (works on macOS / +// Linux; the bug is realpath, not junction-specific). + +describe("issue-845: resolveUpdateMethod follows symlinks/junctions", () => { + let tmpRoot: string; + let realBinDir: string; + let linkedBinDir: string; + let ompPathViaLink: string; + + beforeAll(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omp-issue-845-")); + realBinDir = path.join(tmpRoot, "real", "bin"); + fs.mkdirSync(realBinDir, { recursive: true }); + fs.writeFileSync(path.join(realBinDir, "omp"), "#!/bin/sh\n", { mode: 0o755 }); + + linkedBinDir = path.join(tmpRoot, "link-bin"); + fs.symlinkSync(realBinDir, linkedBinDir, "dir"); + ompPathViaLink = path.join(linkedBinDir, "omp"); + }); + + afterAll(() => { + fs.rmSync(tmpRoot, { recursive: true, force: true }); + }); + + it("classifies omp reached through a symlinked bin dir as bun-managed", () => { + // $which resolves through the symlink, `bun pm bin -g` returns the real path + // (or vice versa). Either direction must be recognized. + const method = _resolveUpdateMethodForTest(ompPathViaLink, realBinDir); + expect(method).toBe("bun"); + }); + + it("classifies omp at the real bin dir as bun-managed when bunBinDir is symlinked", () => { + const ompAtReal = path.join(realBinDir, "omp"); + const method = _resolveUpdateMethodForTest(ompAtReal, linkedBinDir); + expect(method).toBe("bun"); + }); +});