Skip to content

Commit ff0bcd2

Browse files
committed
fix(windows): handle mapped network drives in path resolution and session matching
On Windows, mapped network drives (e.g. Z:\ -> \server\share\) cause two issues: 1. realpathSync() converts drive letter paths to UNC paths, breaking session lookups that use exact string matching on directory paths 2. realpathSync() can throw non-ENOENT errors (EPERM, network timeouts) on inaccessible mapped drives, crashing the file listing Fix by preserving drive letter paths when realpathSync returns UNC, catching all realpathSync errors on Windows, querying sessions with both drive letter and UNC path forms, and normalizing paths for case-insensitive comparison in the frontend project matcher.
1 parent cc68afb commit ff0bcd2

8 files changed

Lines changed: 139 additions & 14 deletions

File tree

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
name: Build Windows Binary
2+
3+
on:
4+
workflow_dispatch:
5+
6+
jobs:
7+
build:
8+
runs-on: ubuntu-latest
9+
steps:
10+
- uses: actions/checkout@v4
11+
12+
- uses: oven-sh/setup-bun@v2
13+
with:
14+
bun-version-file: package.json
15+
16+
- name: Install dependencies
17+
run: bun install
18+
19+
- name: Install cross-platform native modules
20+
run: |
21+
cd packages/opencode
22+
bun install --os="*" --cpu="*" @opentui/core@$(bun -e "console.log(require('./package.json').dependencies['@opentui/core'])")
23+
bun install --os="*" --cpu="*" @parcel/watcher@$(bun -e "console.log(require('./package.json').dependencies['@parcel/watcher'])")
24+
25+
- name: Build all targets
26+
run: |
27+
cd packages/opencode
28+
bun run script/build.ts --skip-install
29+
env:
30+
OPENCODE_VERSION: "0.0.0-local"
31+
32+
- name: Upload Windows x64
33+
uses: actions/upload-artifact@v4
34+
with:
35+
name: opencode-windows-x64
36+
path: packages/opencode/dist/opencode-windows-x64/
37+
38+
- name: Upload Windows x64-baseline
39+
uses: actions/upload-artifact@v4
40+
with:
41+
name: opencode-windows-x64-baseline
42+
path: packages/opencode/dist/opencode-windows-x64-baseline/
43+
44+
- name: Upload Windows arm64
45+
uses: actions/upload-artifact@v4
46+
with:
47+
name: opencode-windows-arm64
48+
path: packages/opencode/dist/opencode-windows-arm64/

packages/app/src/context/global-sync/bootstrap.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,19 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
140140
}, {})
141141
}
142142

143+
function normalizePathForCompare(p: string): string {
144+
let v = p.replace(/\\/g, "/").replace(/\/+$/, "")
145+
if ((v.length >= 2 && v[1] === ":") || v.startsWith("//")) v = v.toLowerCase()
146+
return v
147+
}
148+
143149
function projectID(directory: string, projects: Project[]) {
144-
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
150+
const key = normalizePathForCompare(directory)
151+
return projects.find(
152+
(project) =>
153+
normalizePathForCompare(project.worktree) === key ||
154+
project.sandboxes?.some((s) => normalizePathForCompare(s) === key),
155+
)?.id
145156
}
146157

147158
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {

packages/core/src/filesystem.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,21 @@ export namespace AppFileSystem {
194194
return lookup(p) || "application/octet-stream"
195195
}
196196

197+
function isDriveLetterPath(p: string): boolean {
198+
return p.length >= 2 && /^[A-Za-z]:/.test(p)
199+
}
200+
201+
function isUNCPath(p: string): boolean {
202+
return p.startsWith("\\\\") || p.startsWith("//")
203+
}
204+
197205
export function normalizePath(p: string): string {
198206
if (process.platform !== "win32") return p
199207
const resolved = pathResolve(windowsPath(p))
200208
try {
201-
return realpathSync.native(resolved)
209+
const real = realpathSync.native(resolved)
210+
if (isDriveLetterPath(resolved) && isUNCPath(real)) return resolved
211+
return real
202212
} catch {
203213
return resolved
204214
}
@@ -216,9 +226,11 @@ export namespace AppFileSystem {
216226
export function resolve(p: string): string {
217227
const resolved = pathResolve(windowsPath(p))
218228
try {
219-
return normalizePath(realpathSync(resolved))
229+
const real = realpathSync(resolved)
230+
if (process.platform === "win32" && isDriveLetterPath(resolved) && isUNCPath(real)) return normalizePath(resolved)
231+
return normalizePath(real)
220232
} catch (e: any) {
221-
if (e?.code === "ENOENT") return normalizePath(resolved)
233+
if (process.platform === "win32" || e?.code === "ENOENT") return normalizePath(resolved)
222234
throw e
223235
}
224236
}

packages/opencode/src/project/project.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import z from "zod"
2-
import { and } from "drizzle-orm"
2+
import { and, or } from "drizzle-orm"
33
import { Database } from "@/storage/db"
44
import { eq } from "drizzle-orm"
55
import { ProjectTable } from "./project.sql"
@@ -21,6 +21,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
2121
import { zod } from "@/util/effect-zod"
2222
import { NonNegativeInt, optionalOmitUndefined, withStatics } from "@/util/schema"
2323
import { serviceUse } from "@/effect/service-use"
24+
import { realpathSync } from "fs"
2425

2526
const log = Log.create({ service: "project" })
2627

@@ -344,11 +345,22 @@ export const layer: Layer.Layer<
344345
)
345346

346347
if (data.id !== ProjectID.global) {
348+
const dirs = [data.worktree]
349+
if (process.platform === "win32") {
350+
try {
351+
const real = realpathSync(data.worktree)
352+
if (real !== data.worktree) dirs.push(real)
353+
} catch {}
354+
}
355+
const dirCondition =
356+
dirs.length > 1
357+
? or(...dirs.map((d) => eq(SessionTable.directory, d)))!
358+
: eq(SessionTable.directory, data.worktree)
347359
yield* db((d) =>
348360
d
349361
.update(SessionTable)
350362
.set({ project_id: data.id })
351-
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
363+
.where(and(eq(SessionTable.project_id, ProjectID.global), dirCondition))
352364
.run(),
353365
)
354366
}

packages/opencode/src/session/session.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Slug } from "@opencode-ai/core/util/slug"
2+
import { realpathSync } from "fs"
23
import path from "path"
34
import { BusEvent } from "@/bus/bus-event"
45
import { Bus } from "@/bus"
@@ -55,6 +56,17 @@ export function isDefaultTitle(title: string) {
5556
).test(title)
5657
}
5758

59+
function directoryMatchCondition(directory: string): SQL {
60+
if (process.platform !== "win32") return eq(SessionTable.directory, directory)
61+
const alternatives = [directory]
62+
try {
63+
const real = realpathSync(directory)
64+
if (real !== directory && !alternatives.includes(real)) alternatives.push(real)
65+
} catch {}
66+
if (alternatives.length === 1) return eq(SessionTable.directory, directory)
67+
return or(...alternatives.map((d) => eq(SessionTable.directory, d)))!
68+
}
69+
5870
type SessionRow = typeof SessionTable.$inferSelect
5971

6072
export function fromRow(row: SessionRow): Info {
@@ -829,13 +841,13 @@ function* listByProject(
829841

830842
conditions.push(
831843
input.directory
832-
? or(...conds, and(isNull(SessionTable.path), eq(SessionTable.directory, input.directory))!)!
844+
? or(...conds, and(isNull(SessionTable.path), directoryMatchCondition(input.directory))!)!
833845
: or(...conds)!,
834846
)
835847
}
836848
} else if (input.scope !== "project" && !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES) {
837849
if (input.directory) {
838-
conditions.push(eq(SessionTable.directory, input.directory))
850+
conditions.push(directoryMatchCondition(input.directory))
839851
}
840852
}
841853
if (input.roots) {
@@ -876,7 +888,7 @@ export function* listGlobal(input?: {
876888
const conditions: SQL[] = []
877889

878890
if (input?.directory) {
879-
conditions.push(eq(SessionTable.directory, input.directory))
891+
conditions.push(directoryMatchCondition(input.directory))
880892
}
881893
if (input?.roots) {
882894
conditions.push(isNull(SessionTable.parent_id))

packages/opencode/src/util/filesystem.ts

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,22 @@ export async function mimeType(p: string): Promise<string> {
110110
* This is needed because Windows paths are case-insensitive but LSP servers
111111
* may return paths with different casing than what we send them.
112112
*/
113+
114+
function isDriveLetterPath(p: string): boolean {
115+
return p.length >= 2 && /^[A-Za-z]:/.test(p)
116+
}
117+
118+
function isUNCPath(p: string): boolean {
119+
return p.startsWith("\\\\") || p.startsWith("//")
120+
}
121+
113122
export function normalizePath(p: string): string {
114123
if (process.platform !== "win32") return p
115124
const resolved = win32.normalize(win32.resolve(windowsPath(p)))
116125
try {
117-
return realpathSync.native(resolved)
126+
const real = realpathSync.native(resolved)
127+
if (isDriveLetterPath(resolved) && isUNCPath(real)) return resolved
128+
return real
118129
} catch {
119130
return resolved
120131
}
@@ -135,9 +146,11 @@ export function normalizePathPattern(p: string): string {
135146
export function resolve(p: string): string {
136147
const resolved = pathResolve(windowsPath(p))
137148
try {
138-
return normalizePath(realpathSync(resolved))
149+
const real = realpathSync(resolved)
150+
if (process.platform === "win32" && isDriveLetterPath(resolved) && isUNCPath(real)) return normalizePath(resolved)
151+
return normalizePath(real)
139152
} catch (e) {
140-
if (isEnoent(e)) return normalizePath(resolved)
153+
if (process.platform === "win32" || isEnoent(e)) return normalizePath(resolved)
141154
throw e
142155
}
143156
}

packages/opencode/src/v2/session.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { SessionID } from "@/session/schema"
33
import { WorkspaceID } from "@/control-plane/schema"
44
import { and, asc, desc, eq, gt, gte, isNull, like, lt, or, type SQL } from "@/storage/db"
55
import * as Database from "@/storage/db"
6+
import { realpathSync } from "fs"
67
import { Context, DateTime, Effect, Layer, Option, Schema } from "effect"
78
import { SessionMessage } from "./session-message"
89
import type { Prompt } from "./session-prompt"
@@ -20,6 +21,17 @@ export type Delivery = Schema.Schema.Type<typeof Delivery>
2021

2122
export const DefaultDelivery = "immediate" satisfies Delivery
2223

24+
function directoryMatchCondition(directory: string): SQL {
25+
if (process.platform !== "win32") return eq(SessionTable.directory, directory)
26+
const alternatives = [directory]
27+
try {
28+
const real = realpathSync(directory)
29+
if (real !== directory && !alternatives.includes(real)) alternatives.push(real)
30+
} catch {}
31+
if (alternatives.length === 1) return eq(SessionTable.directory, directory)
32+
return or(...alternatives.map((d) => eq(SessionTable.directory, d)))!
33+
}
34+
2335
export class Info extends Schema.Class<Info>("Session.Info")({
2436
id: SessionID,
2537
parentID: optionalOmitUndefined(SessionID),
@@ -158,7 +170,7 @@ export const layer = Layer.effect(
158170
if (direction === "previous" && order === "asc") order = "desc"
159171
if (direction === "previous" && order === "desc") order = "asc"
160172
const conditions: SQL[] = []
161-
if (input.directory) conditions.push(eq(SessionTable.directory, input.directory))
173+
if (input.directory) conditions.push(directoryMatchCondition(input.directory))
162174
if (input.path)
163175
conditions.push(or(eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`))!)
164176
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))

packages/opencode/test/util/filesystem.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,12 @@ describe("filesystem", () => {
614614
const b = path.join(tmp.path, "b")
615615
await fs.symlink(b, a)
616616
await fs.symlink(a, b)
617-
expect(() => Filesystem.resolve(a)).toThrow()
617+
if (process.platform === "win32") {
618+
const result = Filesystem.resolve(a)
619+
expect(typeof result).toBe("string")
620+
} else {
621+
expect(() => Filesystem.resolve(a)).toThrow()
622+
}
618623
})
619624

620625
// Windows: chmod(0o000) is a no-op, so EACCES cannot be triggered

0 commit comments

Comments
 (0)