From fabbaefb5a9e9b6c25f52c8ff190c5fd6fdb2156 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:31:04 +1000 Subject: [PATCH 01/14] fix(core): canonicalize stored project paths --- .../opencode/src/control-plane/workspace.ts | 5 ++- packages/opencode/src/path/path.ts | 40 +++++++++++++++++++ packages/opencode/src/project/instance.ts | 15 +++---- packages/opencode/src/project/project.ts | 20 +++++----- packages/opencode/src/session/index.ts | 13 +++--- packages/opencode/test/path/path.test.ts | 24 +++++++++++ .../opencode/test/project/project.test.ts | 15 +++++++ 7 files changed, 108 insertions(+), 24 deletions(-) create mode 100644 packages/opencode/src/path/path.ts create mode 100644 packages/opencode/test/path/path.test.ts diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index e5294844b148..b82378715b1f 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,6 +12,7 @@ import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" +import { Path } from "@/path/path" export namespace Workspace { export const Event = { @@ -40,7 +41,7 @@ export namespace Workspace { type: row.type, branch: row.branch, name: row.name, - directory: row.directory, + directory: row.directory ? Path.stored(row.directory) : null, extra: row.extra, projectID: row.project_id, } @@ -65,7 +66,7 @@ export namespace Workspace { type: config.type, branch: config.branch ?? null, name: config.name ?? null, - directory: config.directory ?? null, + directory: config.directory ? Path.stored(config.directory) : null, extra: config.extra ?? null, projectID: input.projectID, } diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts new file mode 100644 index 000000000000..74e60d90149a --- /dev/null +++ b/packages/opencode/src/path/path.ts @@ -0,0 +1,40 @@ +import { Filesystem } from "@/util/filesystem" + +/** + * Any legal path text we ingest from the outside world. + * + * You might see: + * - `C:\Users\RUNNER~1\repo` + * - `C:/Users/runneradmin/repo` + * - `/c/Users/runneradmin/repo` + * - `/cygdrive/c/Users/runneradmin/repo` + * + * You should not assume: + * - native separators + * - canonical casing + * - long Windows names + * - symlinks resolved + * - safe to persist directly + */ +export type RawPath = string & { readonly __raw: unique symbol } + +/** + * Canonical path value we keep in storage and long-lived runtime state. + * + * You might see: + * - `C:\Users\runneradmin\repo` + * - `/Users/luke/repo` + * + * You should not see: + * - `RUNNER~1` + * - `/cygdrive/c/...` + * - slash-only key forms used just for comparison + */ +export type StoredPath = string & { readonly __stored: unique symbol } + +export namespace Path { + export function stored(input: RawPath | string): StoredPath { + if (!input || input === "/") return input as StoredPath + return Filesystem.resolve(input) as StoredPath + } +} diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 4c9b2e107bc8..54b99b3fc3b1 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -3,6 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" +import { Path, type StoredPath } from "@/path/path" import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" @@ -13,7 +14,7 @@ export interface Shape { project: Project.Info } const context = Context.create("instance") -const cache = new Map>() +const cache = new Map>() const disposal = { all: undefined as Promise | undefined, @@ -52,18 +53,18 @@ function boot(input: { directory: string; init?: () => Promise; project?: P }) } -function track(directory: string, next: Promise) { +function track(dir: StoredPath, next: Promise) { const task = next.catch((error) => { - if (cache.get(directory) === task) cache.delete(directory) + if (cache.get(dir) === task) cache.delete(dir) throw error }) - cache.set(directory, task) + cache.set(dir, task) return task } export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Filesystem.resolve(input.directory) + const directory = Path.stored(input.directory) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) @@ -117,7 +118,7 @@ export const Instance = { return State.create(() => Instance.directory, init, dispose) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Filesystem.resolve(input.directory) + const directory = Path.stored(input.directory) Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) @@ -129,7 +130,7 @@ export const Instance = { const directory = Instance.directory Log.Default.info("disposing instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) - cache.delete(directory) + cache.delete(directory as StoredPath) emit(directory) }, async disposeAll() { diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 1cef41c85c91..828d6200b79d 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,20 +15,21 @@ import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" +import { Path, type StoredPath } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) - function gitpath(cwd: string, name: string) { - if (!name) return cwd + function gitpath(cwd: string, name: string): StoredPath { + if (!name) return Path.stored(cwd) // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") - if (!name) return cwd + if (!name) return Path.stored(cwd) name = Filesystem.windowsPath(name) - if (path.isAbsolute(name)) return path.normalize(name) - return path.resolve(cwd, name) + if (path.isAbsolute(name)) return Path.stored(name) + return Path.stored(path.resolve(cwd, name)) } export const Info = z @@ -74,7 +75,7 @@ export namespace Project { : undefined return { id: ProjectID.make(row.id), - worktree: row.worktree, + worktree: Path.stored(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -83,7 +84,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(Path.stored), commands: row.commands ?? undefined, } } @@ -96,6 +97,7 @@ export namespace Project { } export async function fromDirectory(directory: string) { + directory = Path.stored(directory) log.info("fromDirectory", { directory }) const data = await iife(async () => { @@ -103,7 +105,7 @@ export namespace Project { const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox = path.dirname(dotgit) + let sandbox = Path.stored(path.dirname(dotgit)) const gitBinary = which("git") @@ -125,7 +127,7 @@ export namespace Project { .then(async (result) => { const common = gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : path.dirname(common) + return common === sandbox ? sandbox : Path.stored(path.dirname(common)) }) .catch(() => undefined) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index f2d436ff10d9..d7b875f55a5f 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -25,6 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" +import { Path } from "@/path/path" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -68,7 +69,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: Path.stored(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -92,7 +93,7 @@ export namespace Session { workspace_id: info.workspaceID, parent_id: info.parentID, slug: info.slug, - directory: info.directory, + directory: Path.stored(info.directory), title: info.title, version: info.version, share_url: info.share?.url, @@ -307,7 +308,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: Instance.project.id, - directory: input.directory, + directory: Path.stored(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -552,7 +553,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, Path.stored(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -592,7 +593,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, input.directory)) + conditions.push(eq(SessionTable.directory, Path.stored(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -638,7 +639,7 @@ export namespace Session { projects.set(item.id, { id: item.id, name: item.name ?? undefined, - worktree: item.worktree, + worktree: Path.stored(item.worktree), }) } } diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts new file mode 100644 index 000000000000..751969a03d64 --- /dev/null +++ b/packages/opencode/test/path/path.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test" +import fs from "fs/promises" +import path from "path" +import { Path } from "../../src/path/path" +import { tmpdir } from "../fixture/fixture" + +describe("path", () => { + test("keeps sentinel storage paths unchanged", () => { + expect(String(Path.stored(""))).toBe("") + expect(String(Path.stored("/"))).toBe("/") + }) + + test("resolves Windows alias roots for stored paths", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(real, { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, "junction") + + expect(Path.stored(alias)).toBe(Path.stored(real)) + }) +}) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..11d7f54490bb 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" +import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" @@ -100,6 +101,20 @@ describe("Project.fromDirectory", () => { expect(fileExists).toBe(true) }) + test("canonicalizes Windows alias roots before persisting", async () => { + if (process.platform !== "win32") return + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + const alias = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-alias") + await fs.symlink(tmp.path, alias, "junction") + + const { project, sandbox } = await p.fromDirectory(alias) + + expect(String(project.worktree)).toBe(tmp.path) + expect(String(sandbox)).toBe(tmp.path) + }) + test("keeps git vcs when rev-list exits non-zero with empty output", async () => { const p = await loadProject() await using tmp = await tmpdir() From c3a84201ef2183f104a3a427bb1460c123ed0848 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:44:07 +1000 Subject: [PATCH 02/14] fix(core): preserve stored path routes --- packages/opencode/src/path/path.ts | 57 ++++++++++++++++++- packages/opencode/test/path/path.test.ts | 9 +-- .../opencode/test/project/project.test.ts | 15 ----- 3 files changed, 61 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 74e60d90149a..9c6ad1056ebb 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -1,4 +1,6 @@ import { Filesystem } from "@/util/filesystem" +import { lstatSync, readdirSync, realpathSync } from "fs" +import path from "path" /** * Any legal path text we ingest from the outside world. @@ -23,18 +25,71 @@ export type RawPath = string & { readonly __raw: unique symbol } * * You might see: * - `C:\Users\runneradmin\repo` + * - `C:\Repos\my-link` * - `/Users/luke/repo` * * You should not see: * - `RUNNER~1` * - `/cygdrive/c/...` * - slash-only key forms used just for comparison + * + * Stored paths preserve the user's chosen route. They should not resolve + * symlink, junction, mapped-drive, or UNC roots to some different target. */ export type StoredPath = string & { readonly __stored: unique symbol } +const match = (dir: string, part: string) => { + try { + const low = part.toLowerCase() + return readdirSync(dir).find((item) => item.toLowerCase() === low) + } catch { + return + } +} + +const win = (input: string) => { + const full = path.resolve(Filesystem.windowsPath(input)) + const root = path.parse(full).root + const parts = full + .slice(root.length) + .split(/[\\/]+/) + .filter(Boolean) + let dir = root + + for (let i = 0; i < parts.length; i++) { + const part = parts[i] + const next = path.join(dir, part) + const hit = (() => { + try { + return lstatSync(next) + } catch { + return + } + })() + + if (!hit) return path.join(dir, ...parts.slice(i)) as StoredPath + if (hit.isSymbolicLink()) { + dir = path.join(dir, match(dir, part) ?? part) + continue + } + + const name = (() => { + try { + return path.basename(realpathSync.native(next)) + } catch { + return match(dir, part) ?? part + } + })() + dir = path.join(dir, name) + } + + return dir as StoredPath +} + export namespace Path { export function stored(input: RawPath | string): StoredPath { if (!input || input === "/") return input as StoredPath - return Filesystem.resolve(input) as StoredPath + if (process.platform !== "win32") return path.resolve(input) as StoredPath + return win(input) } } diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 751969a03d64..0909088a9c03 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -10,15 +10,16 @@ describe("path", () => { expect(String(Path.stored("/"))).toBe("/") }) - test("resolves Windows alias roots for stored paths", async () => { - if (process.platform !== "win32") return + test("preserves symlink routes in stored paths", async () => { await using tmp = await tmpdir() const real = path.join(tmp.path, "Target") await fs.mkdir(real, { recursive: true }) + await fs.mkdir(path.join(real, "Leaf"), { recursive: true }) const alias = path.join(tmp.path, "Alias") - await fs.symlink(real, alias, "junction") + await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") - expect(Path.stored(alias)).toBe(Path.stored(real)) + expect(String(Path.stored(alias))).toBe(alias) + expect(String(Path.stored(path.join(alias, "Leaf")))).toBe(path.join(alias, "Leaf")) }) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 11d7f54490bb..a71fe0528f02 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,7 +2,6 @@ import { describe, expect, mock, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" -import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" @@ -101,20 +100,6 @@ describe("Project.fromDirectory", () => { expect(fileExists).toBe(true) }) - test("canonicalizes Windows alias roots before persisting", async () => { - if (process.platform !== "win32") return - const p = await loadProject() - await using tmp = await tmpdir({ git: true }) - - const alias = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-alias") - await fs.symlink(tmp.path, alias, "junction") - - const { project, sandbox } = await p.fromDirectory(alias) - - expect(String(project.worktree)).toBe(tmp.path) - expect(String(sandbox)).toBe(tmp.path) - }) - test("keeps git vcs when rev-list exits non-zero with empty output", async () => { const p = await loadProject() await using tmp = await tmpdir() From f69c7cb88ef6c91a9d5f9a2cd1edcddfb10d5aec Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:46:31 +1000 Subject: [PATCH 03/14] test(core): cover stored path forms --- packages/opencode/test/path/path.test.ts | 39 ++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 0909088a9c03..5d06c1e20472 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -10,6 +10,12 @@ describe("path", () => { expect(String(Path.stored("/"))).toBe("/") }) + test("keeps missing paths on the chosen route", async () => { + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "missing", "child") + expect(String(Path.stored(dir))).toBe(path.resolve(dir)) + }) + test("preserves symlink routes in stored paths", async () => { await using tmp = await tmpdir() @@ -21,5 +27,38 @@ describe("path", () => { expect(String(Path.stored(alias))).toBe(alias) expect(String(Path.stored(path.join(alias, "Leaf")))).toBe(path.join(alias, "Leaf")) + expect(String(Path.stored(alias))).not.toBe(real) + expect(String(Path.stored(path.join(alias, "Leaf")))).not.toBe(path.join(real, "Leaf")) + }) + + test("keeps native posix routes without introducing Windows key forms", async () => { + if (process.platform === "win32") return + await using tmp = await tmpdir() + const dir = path.join(tmp.path, "Thing") + await fs.mkdir(dir, { recursive: true }) + + expect(String(Path.stored(dir))).toBe(dir) + expect(String(Path.stored(dir))).not.toContain("\\") + }) + + test("normalizes Windows bash-style paths to native routes", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const drive = tmp.path[0].toLowerCase() + const rest = tmp.path.slice(2).replaceAll("\\", "/") + + expect(String(Path.stored(`/${drive}${rest}`))).toBe(tmp.path) + expect(String(Path.stored(`/cygdrive/${drive}${rest}`))).toBe(tmp.path) + expect(String(Path.stored(`/mnt/${drive}${rest}`))).toBe(tmp.path) + expect(String(Path.stored(`/${drive}${rest}`))).not.toContain("/cygdrive/") + expect(String(Path.stored(`/${drive}${rest}`))).not.toContain(`/mnt/${drive}`) + }) + + test("preserves UNC routes for network-style paths", () => { + if (process.platform !== "win32") return + const dir = "\\\\server\\share\\Repo\\folder" + expect(String(Path.stored(dir))).toBe(dir) + expect(String(Path.stored(dir))).not.toContain("RUNNER~1") }) }) From 687e73898e5aae46a871729f67b0b3d6540e4a97 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:48:53 +1000 Subject: [PATCH 04/14] test(core): avoid brittle alias assertions --- packages/opencode/test/path/path.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 5d06c1e20472..80e0f30a1320 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -4,6 +4,12 @@ import path from "path" import { Path } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" +const alias = (input: string) => + input + .split(/[\\/]+/) + .filter(Boolean) + .some((part) => /^[^ .\\/]{1,6}~\d(?:\.[^ .\\/]{0,3})?$/i.test(part)) + describe("path", () => { test("keeps sentinel storage paths unchanged", () => { expect(String(Path.stored(""))).toBe("") @@ -59,6 +65,6 @@ describe("path", () => { if (process.platform !== "win32") return const dir = "\\\\server\\share\\Repo\\folder" expect(String(Path.stored(dir))).toBe(dir) - expect(String(Path.stored(dir))).not.toContain("RUNNER~1") + expect(alias(String(Path.stored(dir)))).toBe(false) }) }) From a4e53b12e75d6a50a9c8b38b401cec0085cf1dc4 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 14:59:45 +1000 Subject: [PATCH 05/14] test(core): harden stored path invariants --- packages/opencode/src/project/project.ts | 10 ++++- packages/opencode/test/path/path.test.ts | 41 ++++++++++++++++--- .../opencode/test/project/project.test.ts | 15 +++++++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 828d6200b79d..9e1fc580841d 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -20,6 +20,12 @@ import { Path, type StoredPath } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) + function keep(curr: StoredPath, next: string) { + const full = Path.stored(next) + if (Filesystem.resolve(curr) === Filesystem.resolve(full)) return curr + return full + } + function gitpath(cwd: string, name: string): StoredPath { if (!name) return Path.stored(cwd) // git output includes trailing newlines; keep path whitespace intact. @@ -127,7 +133,7 @@ export namespace Project { .then(async (result) => { const common = gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : Path.stored(path.dirname(common)) + return common === sandbox ? sandbox : keep(sandbox, path.dirname(common)) }) .catch(() => undefined) @@ -201,7 +207,7 @@ export namespace Project { } } - sandbox = top + sandbox = keep(sandbox, top) return { id, diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 80e0f30a1320..0eeedab9d35d 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -1,14 +1,32 @@ import { describe, expect, test } from "bun:test" +import { dlopen, ptr } from "bun:ffi" import fs from "fs/promises" import path from "path" import { Path } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" -const alias = (input: string) => - input - .split(/[\\/]+/) - .filter(Boolean) - .some((part) => /^[^ .\\/]{1,6}~\d(?:\.[^ .\\/]{0,3})?$/i.test(part)) +const k32 = + process.platform === "win32" + ? dlopen("kernel32.dll", { + GetShortPathNameW: { args: ["ptr", "ptr", "u32"], returns: "u32" }, + }) + : undefined + +const wide = (input: string) => Buffer.from(input + "\0", "utf16le") + +const text = (input: Uint16Array) => { + const end = input.indexOf(0) + return Buffer.from(input.buffer, input.byteOffset, (end === -1 ? input.length : end) * 2).toString("utf16le") +} + +const short = (input: string) => { + if (!k32) return + const src = wide(input) + const out = new Uint16Array(4096) + const len = k32.symbols.GetShortPathNameW(ptr(src), ptr(out), out.length) + if (!len) return + return text(out) +} describe("path", () => { test("keeps sentinel storage paths unchanged", () => { @@ -61,10 +79,21 @@ describe("path", () => { expect(String(Path.stored(`/${drive}${rest}`))).not.toContain(`/mnt/${drive}`) }) + test("expands Windows short-name aliases when one exists", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const raw = short(tmp.path) + if (!raw || raw === tmp.path) return + + expect(raw).toContain("~") + expect(String(Path.stored(raw))).toBe(tmp.path) + expect(String(Path.stored(raw))).not.toContain("~") + }) + test("preserves UNC routes for network-style paths", () => { if (process.platform !== "win32") return const dir = "\\\\server\\share\\Repo\\folder" expect(String(Path.stored(dir))).toBe(dir) - expect(alias(String(Path.stored(dir)))).toBe(false) }) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index a71fe0528f02..26f843fd5f07 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -2,6 +2,7 @@ import { describe, expect, mock, test } from "bun:test" import { Project } from "../../src/project/project" import { Log } from "../../src/util/log" import { $ } from "bun" +import fs from "fs/promises" import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" @@ -100,6 +101,20 @@ describe("Project.fromDirectory", () => { expect(fileExists).toBe(true) }) + test("preserves chosen route when opening a repo through a symlink", async () => { + const p = await loadProject() + await using tmp = await tmpdir({ git: true }) + + const alias = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-alias") + await fs.symlink(tmp.path, alias, process.platform === "win32" ? "junction" : "dir") + + const { project, sandbox } = await p.fromDirectory(alias) + + expect(project.worktree).toBe(alias) + expect(sandbox).toBe(alias) + expect(project.worktree).not.toBe(tmp.path) + }) + test("keeps git vcs when rev-list exits non-zero with empty output", async () => { const p = await loadProject() await using tmp = await tmpdir() From 1b44f4cb07f9100571b7ca724b6bccb7ed719018 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:15:08 +1000 Subject: [PATCH 06/14] test(core): cover stored path invariants --- packages/opencode/src/path/path.ts | 14 ++++--- packages/opencode/src/project/project.ts | 8 ++-- .../test/control-plane/workspace-sync.test.ts | 39 +++++++++++++++++++ .../test/effect/instance-state.test.ts | 32 +++++++++++++++ packages/opencode/test/path/path.test.ts | 25 ++++++++++-- .../opencode/test/server/session-list.test.ts | 23 +++++++++++ 6 files changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index 9c6ad1056ebb..c56f969164de 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -38,7 +38,7 @@ export type RawPath = string & { readonly __raw: unique symbol } */ export type StoredPath = string & { readonly __stored: unique symbol } -const match = (dir: string, part: string) => { +const caseMatch = (dir: string, part: string) => { try { const low = part.toLowerCase() return readdirSync(dir).find((item) => item.toLowerCase() === low) @@ -47,7 +47,7 @@ const match = (dir: string, part: string) => { } } -const win = (input: string) => { +const storedWin = (input: string) => { const full = path.resolve(Filesystem.windowsPath(input)) const root = path.parse(full).root const parts = full @@ -69,7 +69,7 @@ const win = (input: string) => { if (!hit) return path.join(dir, ...parts.slice(i)) as StoredPath if (hit.isSymbolicLink()) { - dir = path.join(dir, match(dir, part) ?? part) + dir = path.join(dir, caseMatch(dir, part) ?? part) continue } @@ -77,7 +77,7 @@ const win = (input: string) => { try { return path.basename(realpathSync.native(next)) } catch { - return match(dir, part) ?? part + return caseMatch(dir, part) ?? part } })() dir = path.join(dir, name) @@ -90,6 +90,10 @@ export namespace Path { export function stored(input: RawPath | string): StoredPath { if (!input || input === "/") return input as StoredPath if (process.platform !== "win32") return path.resolve(input) as StoredPath - return win(input) + return storedWin(input) + } + + export function same(a: RawPath | string, b: RawPath | string) { + return Filesystem.resolve(stored(a)) === Filesystem.resolve(stored(b)) } } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9e1fc580841d..2b583511cc97 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -20,9 +20,9 @@ import { Path, type StoredPath } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) - function keep(curr: StoredPath, next: string) { + function preserve(curr: StoredPath, next: string) { const full = Path.stored(next) - if (Filesystem.resolve(curr) === Filesystem.resolve(full)) return curr + if (Path.same(curr, full)) return curr return full } @@ -133,7 +133,7 @@ export namespace Project { .then(async (result) => { const common = gitpath(sandbox, await result.text()) // Avoid going to parent of sandbox when git-common-dir is empty. - return common === sandbox ? sandbox : keep(sandbox, path.dirname(common)) + return common === sandbox ? sandbox : preserve(sandbox, path.dirname(common)) }) .catch(() => undefined) @@ -207,7 +207,7 @@ export namespace Project { } } - sandbox = keep(sandbox, top) + sandbox = preserve(sandbox, top) return { id, diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 0f8d608fb39b..4763fc438ebb 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -10,6 +10,12 @@ import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" import type { Adaptor } from "../../src/control-plane/types" +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + afterEach(async () => { mock.restore() await resetDatabase() @@ -96,4 +102,37 @@ describe("control-plane/workspace.startSyncing", () => { await sync.stop() }) + + test("stores and rehydrates workspace directories in one stored form", async () => { + const { Workspace } = await import("../../src/control-plane/workspace") + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const dir = process.platform === "win32" ? bash(tmp.path) : tmp.path + adaptors.installAdaptor("testing-path", { + ...TestAdaptor, + configure(config) { + return { + ...config, + directory: dir, + name: "remote-b", + } + }, + async create() {}, + }) + + const created = await Workspace.create({ + type: "testing-path", + branch: null, + projectID: project.id, + extra: null, + }) + + const loaded = await Workspace.get(created.id) + const listed = Workspace.list(project) + + expect(created.directory).toBe(tmp.path) + expect(loaded?.directory).toBe(tmp.path) + expect(listed.find((item) => item.id === created.id)?.directory).toBe(tmp.path) + }) }) diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 2d527482ba11..4f071ad21d9a 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -4,6 +4,12 @@ import { InstanceState } from "../../src/effect/instance-state" import { Instance } from "../../src/project/instance" import { tmpdir } from "../fixture/fixture" +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + async function access(state: InstanceState, dir: string) { return Instance.provide({ directory: dir, @@ -85,6 +91,32 @@ test("InstanceState invalidates on reload", async () => { ) }) +test("Instance.provide collapses equivalent Windows bash spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + let n = 0 + + const a = await Instance.provide({ + directory: tmp.path, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + const b = await Instance.provide({ + directory: bash(tmp.path), + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + expect(a).toBe(tmp.path) + expect(b).toBe(tmp.path) + expect(n).toBe(1) +}) + test("InstanceState invalidates on disposeAll", async () => { await using one = await tmpdir() await using two = await tmpdir() diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 0eeedab9d35d..796d4ca57d86 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -29,6 +29,12 @@ const short = (input: string) => { } describe("path", () => { + const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` + } + test("keeps sentinel storage paths unchanged", () => { expect(String(Path.stored(""))).toBe("") expect(String(Path.stored("/"))).toBe("/") @@ -55,6 +61,18 @@ describe("path", () => { expect(String(Path.stored(path.join(alias, "Leaf")))).not.toBe(path.join(real, "Leaf")) }) + test("keeps missing descendants on a chosen symlink route", async () => { + await using tmp = await tmpdir() + + const real = path.join(tmp.path, "Target") + await fs.mkdir(real, { recursive: true }) + const alias = path.join(tmp.path, "Alias") + await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") + + expect(String(Path.stored(path.join(alias, "missing", "child")))).toBe(path.join(alias, "missing", "child")) + expect(String(Path.stored(path.join(alias, "missing", "child")))).not.toBe(path.join(real, "missing", "child")) + }) + test("keeps native posix routes without introducing Windows key forms", async () => { if (process.platform === "win32") return await using tmp = await tmpdir() @@ -69,14 +87,15 @@ describe("path", () => { if (process.platform !== "win32") return await using tmp = await tmpdir() + const root = bash(tmp.path) const drive = tmp.path[0].toLowerCase() const rest = tmp.path.slice(2).replaceAll("\\", "/") - expect(String(Path.stored(`/${drive}${rest}`))).toBe(tmp.path) + expect(String(Path.stored(root))).toBe(tmp.path) expect(String(Path.stored(`/cygdrive/${drive}${rest}`))).toBe(tmp.path) expect(String(Path.stored(`/mnt/${drive}${rest}`))).toBe(tmp.path) - expect(String(Path.stored(`/${drive}${rest}`))).not.toContain("/cygdrive/") - expect(String(Path.stored(`/${drive}${rest}`))).not.toContain(`/mnt/${drive}`) + expect(String(Path.stored(root))).not.toContain("/cygdrive/") + expect(String(Path.stored(root))).not.toContain(`/mnt/${drive}`) }) test("expands Windows short-name aliases when one exists", async () => { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 675a89011f96..50904a919781 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -3,10 +3,17 @@ import path from "path" import { Instance } from "../../src/project/instance" import { Session } from "../../src/session" import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" const projectRoot = path.join(__dirname, "../..") Log.init({ print: false }) +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + describe("Session.list", () => { test("filters by directory", async () => { await Instance.provide({ @@ -87,4 +94,20 @@ describe("Session.list", () => { }, }) }) + + test("matches the stored directory when queried with a Windows bash path", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "bash-path-session" }) + const sessions = [...Session.list({ directory: bash(tmp.path) })] + + expect(sessions.map((item) => item.id)).toContain(session.id) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(tmp.path) + }, + }) + }) }) From fdbcf91993a21b82b483b31beaf443dea3dfe517 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:21:32 +1000 Subject: [PATCH 07/14] fix(core): canonicalize stored drive roots --- packages/opencode/src/path/path.ts | 8 ++++-- .../test/effect/instance-state.test.ts | 28 +++++++++++++++++++ packages/opencode/test/path/path.test.ts | 8 ++++++ .../opencode/test/server/session-list.test.ts | 18 ++++++++++++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index c56f969164de..b144b4737710 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -47,14 +47,16 @@ const caseMatch = (dir: string, part: string) => { } } +const root = (input: string) => input.replace(/^[a-z]:/, (x) => x.toUpperCase()) + const storedWin = (input: string) => { const full = path.resolve(Filesystem.windowsPath(input)) - const root = path.parse(full).root + const base = root(path.parse(full).root) const parts = full - .slice(root.length) + .slice(base.length) .split(/[\\/]+/) .filter(Boolean) - let dir = root + let dir = base for (let i = 0; i < parts.length; i++) { const part = parts[i] diff --git a/packages/opencode/test/effect/instance-state.test.ts b/packages/opencode/test/effect/instance-state.test.ts index 4f071ad21d9a..723bd98a91cd 100644 --- a/packages/opencode/test/effect/instance-state.test.ts +++ b/packages/opencode/test/effect/instance-state.test.ts @@ -117,6 +117,34 @@ test("Instance.provide collapses equivalent Windows bash spellings", async () => expect(n).toBe(1) }) +test("Instance.provide collapses drive-letter casing variants", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + let n = 0 + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + const a = await Instance.provide({ + directory: tmp.path, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + const b = await Instance.provide({ + directory: raw, + init: async () => { + n += 1 + }, + fn: async () => Instance.directory, + }) + + expect(a).toBe(tmp.path) + expect(b).toBe(tmp.path) + expect(n).toBe(1) +}) + test("InstanceState invalidates on disposeAll", async () => { await using one = await tmpdir() await using two = await tmpdir() diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 796d4ca57d86..08d23e2dce0f 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -98,6 +98,14 @@ describe("path", () => { expect(String(Path.stored(root))).not.toContain(`/mnt/${drive}`) }) + test("canonicalizes lowercase drive roots to the stored drive casing", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir() + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + expect(String(Path.stored(raw))).toBe(tmp.path) + }) + test("expands Windows short-name aliases when one exists", async () => { if (process.platform !== "win32") return await using tmp = await tmpdir() diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 50904a919781..80d8c39a5d55 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -110,4 +110,22 @@ describe("Session.list", () => { }, }) }) + + test("matches the stored directory when queried with a lowercase drive path", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({ title: "lower-drive-session" }) + const sessions = [...Session.list({ directory: raw })] + + expect(sessions.map((item) => item.id)).toContain(session.id) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(tmp.path) + }, + }) + }) }) From 4bb7659a4d5002d3468e20f78b31fc10bb24b5bd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:27:42 +1000 Subject: [PATCH 08/14] fix(core): normalize sandbox path updates --- packages/opencode/src/project/project.ts | 2 ++ .../opencode/test/project/project.test.ts | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 2b583511cc97..fd399a0f891b 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -418,6 +418,7 @@ export namespace Project { export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) + directory = Path.stored(directory) const sandboxes = [...row.sandboxes] if (!sandboxes.includes(directory)) sandboxes.push(directory) const result = Database.use((db) => @@ -442,6 +443,7 @@ export namespace Project { export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) + directory = Path.stored(directory) const sandboxes = row.sandboxes.filter((s) => s !== directory) const result = Database.use((db) => db diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 26f843fd5f07..2fb486ea03db 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -297,6 +297,33 @@ describe("Project.discover", () => { }) }) +describe("Project sandboxes", () => { + test("dedupes equivalent sandbox path spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + + await Project.addSandbox(project.id, tmp.path) + const updated = await Project.addSandbox(project.id, raw) + + expect(updated.sandboxes).toEqual([tmp.path]) + }) + + test("removes a sandbox across equivalent path spellings", async () => { + if (process.platform !== "win32") return + await using tmp = await tmpdir({ git: true }) + const { project } = await Project.fromDirectory(tmp.path) + + await Project.addSandbox(project.id, tmp.path) + const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + const updated = await Project.removeSandbox(project.id, raw) + + expect(updated.sandboxes).toEqual([]) + }) +}) + describe("Project.update", () => { test("should update name", async () => { await using tmp = await tmpdir({ git: true }) From 20ba5b92ecc2c33caa31ddfaba9ef0eef66f93aa Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:36:52 +1000 Subject: [PATCH 09/14] fix(core): migrate stored path data --- packages/opencode/src/index.ts | 3 + packages/opencode/src/path/migration.ts | 73 +++++++++++ packages/opencode/test/path/migration.test.ts | 113 ++++++++++++++++++ 3 files changed, 189 insertions(+) create mode 100644 packages/opencode/src/path/migration.ts create mode 100644 packages/opencode/test/path/migration.test.ts diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index b3d1db7eb0cb..39998f8b4aaf 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -34,6 +34,7 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" +import { PathMigration } from "./path/migration" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -120,6 +121,8 @@ let cli = yargs(hideBin(process.argv)) } process.stderr.write("Database migration complete." + EOL) } + + await PathMigration.run() }) .usage("\n" + UI.logo()) .completion("completion", "generate shell completion script") diff --git a/packages/opencode/src/path/migration.ts b/packages/opencode/src/path/migration.ts new file mode 100644 index 000000000000..83cc47cf4101 --- /dev/null +++ b/packages/opencode/src/path/migration.ts @@ -0,0 +1,73 @@ +import path from "path" +import { eq } from "@/storage/db" +import { Global } from "@/global" +import { Log } from "@/util/log" +import { Filesystem } from "@/util/filesystem" +import { Database } from "@/storage/db" +import { ProjectTable } from "@/project/project.sql" +import { SessionTable } from "@/session/session.sql" +import { WorkspaceTable } from "@/control-plane/workspace.sql" +import { Path } from "./path" + +export namespace PathMigration { + const log = Log.create({ service: "path-migration" }) + + const uniq = (input: string[]) => [...new Set(input.map((item) => Path.stored(item)))] + + export type Stats = { + projects: number + sessions: number + workspaces: number + } + + export async function run(input: { force?: boolean; marker?: string } = {}) { + if (process.platform !== "win32") { + return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + } + + const mark = input.marker ?? path.join(Global.Path.state, "stored-path-v1") + if (!input.force && (await Filesystem.exists(mark))) { + return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + } + + const stats = Database.transaction((db) => { + const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + + for (const row of db.select().from(ProjectTable).all()) { + const worktree = Path.stored(row.worktree) + const sandboxes = uniq(row.sandboxes) + if ( + worktree === row.worktree && + sandboxes.every((item, idx) => item === row.sandboxes[idx]) && + sandboxes.length === row.sandboxes.length + ) + continue + + db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run() + stats.projects += 1 + } + + for (const row of db.select().from(SessionTable).all()) { + const directory = Path.stored(row.directory) + if (directory === row.directory) continue + + db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() + stats.sessions += 1 + } + + for (const row of db.select().from(WorkspaceTable).all()) { + const directory = row.directory ? Path.stored(row.directory) : null + if (directory === row.directory) continue + + db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() + stats.workspaces += 1 + } + + return stats + }) + + log.info("completed", stats) + if (!input.force) await Filesystem.write(mark, "1") + return stats + } +} diff --git a/packages/opencode/test/path/migration.test.ts b/packages/opencode/test/path/migration.test.ts new file mode 100644 index 000000000000..92469521d7d7 --- /dev/null +++ b/packages/opencode/test/path/migration.test.ts @@ -0,0 +1,113 @@ +import { afterEach, describe, expect, test } from "bun:test" +import path from "path" +import { Global } from "../../src/global" +import { Database } from "../../src/storage/db" +import { ProjectID } from "../../src/project/schema" +import { ProjectTable } from "../../src/project/project.sql" +import { SessionID } from "../../src/session/schema" +import { SessionTable } from "../../src/session/session.sql" +import { WorkspaceID } from "../../src/control-plane/schema" +import { WorkspaceTable } from "../../src/control-plane/workspace.sql" +import { PathMigration } from "../../src/path/migration" +import { tmpdir } from "../fixture/fixture" +import { resetDatabase } from "../fixture/db" +import { eq } from "../../src/storage/db" + +const bash = (input: string) => { + const drive = input[0].toLowerCase() + const rest = input.slice(2).replaceAll("\\", "/") + return `/${drive}${rest}` +} + +afterEach(async () => { + await resetDatabase() +}) + +describe("path migration", () => { + test("rewrites old stored project, sandbox, session, and workspace paths", async () => { + if (process.platform !== "win32") return + await resetDatabase() + await using tmp = await tmpdir({ git: true }) + + const prev = Global.Path.state + ;(Global.Path as { state: string }).state = tmp.path + + try { + const low = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) + const raw = bash(tmp.path) + const projectID = ProjectID.make("project-path-migrate") + const sessionID = SessionID.make("session-path-migrate") + const workspaceID = WorkspaceID.make("workspace-path-migrate") + + Database.use((db) => { + db.insert(ProjectTable) + .values({ + id: projectID, + worktree: raw, + vcs: "git", + name: null, + icon_url: null, + icon_color: null, + time_created: 1, + time_updated: 1, + time_initialized: null, + sandboxes: [low, tmp.path], + commands: null, + }) + .run() + + db.insert(SessionTable) + .values({ + id: sessionID, + project_id: projectID, + workspace_id: null, + parent_id: null, + slug: "session-path-migrate", + directory: raw, + title: "session", + version: "v2", + share_url: null, + summary_additions: null, + summary_deletions: null, + summary_files: null, + summary_diffs: null, + revert: null, + permission: null, + time_created: 1, + time_updated: 1, + time_compacting: null, + time_archived: null, + }) + .run() + + db.insert(WorkspaceTable) + .values({ + id: workspaceID, + type: "worktree", + branch: null, + name: "workspace", + directory: low, + extra: null, + project_id: projectID, + }) + .run() + }) + + const stats = await PathMigration.run({ force: true, marker: path.join(tmp.path, "marker") }) + + const project = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) + const session = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) + const workspace = Database.use((db) => + db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, workspaceID)).get(), + ) + + expect(stats).toEqual({ projects: 1, sessions: 1, workspaces: 1 }) + expect(project?.worktree).toBe(tmp.path) + expect(project?.sandboxes).toEqual([tmp.path]) + expect(session?.directory).toBe(tmp.path) + expect(workspace?.directory).toBe(tmp.path) + } finally { + ;(Global.Path as { state: string }).state = prev + } + }) +}) From 2270ad4f05dfebab42f3709d759a22734ee6c38c Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:44:22 +1000 Subject: [PATCH 10/14] fix(core): run stored path migration before reads --- packages/opencode/src/cli/cmd/tui/thread.ts | 9 +-- .../opencode/src/control-plane/workspace.ts | 2 +- packages/opencode/src/index.ts | 3 - packages/opencode/src/path/migration.ts | 64 +++++++++---------- packages/opencode/src/project/project.ts | 4 +- packages/opencode/src/server/server.ts | 3 +- packages/opencode/src/session/index.ts | 4 +- packages/opencode/src/storage/db.ts | 3 + packages/opencode/test/path/migration.test.ts | 2 +- 9 files changed, 46 insertions(+), 48 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 6e787c7afddd..91a45e4a56a7 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -14,6 +14,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" +import { Path } from "@/path/path" declare global { const OPENCODE_WORKER_PATH: string @@ -115,10 +116,10 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Filesystem.resolve(process.env.PWD ?? process.cwd()) + const root = Path.stored(process.env.PWD ?? process.cwd()) const next = args.project - ? Filesystem.resolve(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Filesystem.resolve(process.cwd()) + ? Path.stored(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + : Path.stored(process.cwd()) const file = await target() try { process.chdir(next) @@ -126,7 +127,7 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = Filesystem.resolve(process.cwd()) + const cwd = Path.stored(process.cwd()) const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index b82378715b1f..cf45e0b725be 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -41,7 +41,7 @@ export namespace Workspace { type: row.type, branch: row.branch, name: row.name, - directory: row.directory ? Path.stored(row.directory) : null, + directory: row.directory, extra: row.extra, projectID: row.project_id, } diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 39998f8b4aaf..b3d1db7eb0cb 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -34,7 +34,6 @@ import path from "path" import { Global } from "./global" import { JsonMigration } from "./storage/json-migration" import { Database } from "./storage/db" -import { PathMigration } from "./path/migration" process.on("unhandledRejection", (e) => { Log.Default.error("rejection", { @@ -121,8 +120,6 @@ let cli = yargs(hideBin(process.argv)) } process.stderr.write("Database migration complete." + EOL) } - - await PathMigration.run() }) .usage("\n" + UI.logo()) .completion("completion", "generate shell completion script") diff --git a/packages/opencode/src/path/migration.ts b/packages/opencode/src/path/migration.ts index 83cc47cf4101..ccb24aa1981f 100644 --- a/packages/opencode/src/path/migration.ts +++ b/packages/opencode/src/path/migration.ts @@ -1,9 +1,9 @@ +import { type SQLiteBunDatabase } from "drizzle-orm/bun-sqlite" import path from "path" import { eq } from "@/storage/db" import { Global } from "@/global" import { Log } from "@/util/log" -import { Filesystem } from "@/util/filesystem" -import { Database } from "@/storage/db" +import { existsSync, writeFileSync } from "fs" import { ProjectTable } from "@/project/project.sql" import { SessionTable } from "@/session/session.sql" import { WorkspaceTable } from "@/control-plane/workspace.sql" @@ -20,54 +20,50 @@ export namespace PathMigration { workspaces: number } - export async function run(input: { force?: boolean; marker?: string } = {}) { + export function run(db: SQLiteBunDatabase, input: { force?: boolean; marker?: string } = {}) { if (process.platform !== "win32") { return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats } const mark = input.marker ?? path.join(Global.Path.state, "stored-path-v1") - if (!input.force && (await Filesystem.exists(mark))) { + if (!input.force && existsSync(mark)) { return { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats } - const stats = Database.transaction((db) => { - const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats + const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats - for (const row of db.select().from(ProjectTable).all()) { - const worktree = Path.stored(row.worktree) - const sandboxes = uniq(row.sandboxes) - if ( - worktree === row.worktree && - sandboxes.every((item, idx) => item === row.sandboxes[idx]) && - sandboxes.length === row.sandboxes.length - ) - continue + for (const row of db.select().from(ProjectTable).all()) { + const worktree = Path.stored(row.worktree) + const sandboxes = uniq(row.sandboxes) + if ( + worktree === row.worktree && + sandboxes.every((item, idx) => item === row.sandboxes[idx]) && + sandboxes.length === row.sandboxes.length + ) + continue - db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run() - stats.projects += 1 - } - - for (const row of db.select().from(SessionTable).all()) { - const directory = Path.stored(row.directory) - if (directory === row.directory) continue + db.update(ProjectTable).set({ worktree, sandboxes }).where(eq(ProjectTable.id, row.id)).run() + stats.projects += 1 + } - db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() - stats.sessions += 1 - } + for (const row of db.select().from(SessionTable).all()) { + const directory = Path.stored(row.directory) + if (directory === row.directory) continue - for (const row of db.select().from(WorkspaceTable).all()) { - const directory = row.directory ? Path.stored(row.directory) : null - if (directory === row.directory) continue + db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() + stats.sessions += 1 + } - db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() - stats.workspaces += 1 - } + for (const row of db.select().from(WorkspaceTable).all()) { + const directory = row.directory ? Path.stored(row.directory) : null + if (directory === row.directory) continue - return stats - }) + db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() + stats.workspaces += 1 + } log.info("completed", stats) - if (!input.force) await Filesystem.write(mark, "1") + if (!input.force) writeFileSync(mark, "1") return stats } } diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index fd399a0f891b..89823aa3ba5a 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -81,7 +81,7 @@ export namespace Project { : undefined return { id: ProjectID.make(row.id), - worktree: Path.stored(row.worktree), + worktree: row.worktree, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -90,7 +90,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes.map(Path.stored), + sandboxes: row.sandboxes, commands: row.commands ?? undefined, } } diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3cb..95d22fb1b762 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,6 +38,7 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" +import { Path } from "@/path/path" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -193,7 +194,7 @@ export namespace Server { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Filesystem.resolve( + const directory = Path.stored( (() => { try { return decodeURIComponent(raw) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index d7b875f55a5f..699ce5167349 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -69,7 +69,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: Path.stored(row.directory), + directory: row.directory, parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -639,7 +639,7 @@ export namespace Session { projects.set(item.id, { id: item.id, name: item.name ?? undefined, - worktree: Path.stored(item.worktree), + worktree: item.worktree, }) } } diff --git a/packages/opencode/src/storage/db.ts b/packages/opencode/src/storage/db.ts index 1bb8c1a69bf3..590fbcc22775 100644 --- a/packages/opencode/src/storage/db.ts +++ b/packages/opencode/src/storage/db.ts @@ -14,6 +14,7 @@ import { Installation } from "../installation" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" import { init } from "#db" +import { PathMigration } from "@/path/migration" declare const OPENCODE_MIGRATIONS: { sql: string; timestamp: number; name: string }[] | undefined @@ -108,6 +109,8 @@ export namespace Database { migrate(db, entries) } + PathMigration.run(db) + return db }) diff --git a/packages/opencode/test/path/migration.test.ts b/packages/opencode/test/path/migration.test.ts index 92469521d7d7..3b83f93accbb 100644 --- a/packages/opencode/test/path/migration.test.ts +++ b/packages/opencode/test/path/migration.test.ts @@ -93,7 +93,7 @@ describe("path migration", () => { .run() }) - const stats = await PathMigration.run({ force: true, marker: path.join(tmp.path, "marker") }) + const stats = PathMigration.run(Database.Client(), { force: true, marker: path.join(tmp.path, "marker") }) const project = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()) const session = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, sessionID)).get()) From 82d4bd728d3919bec877c7aec8ce9d45585acb33 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:59:23 +1000 Subject: [PATCH 11/14] docs(core): clarify stored path sentinel --- packages/opencode/src/path/path.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/path/path.ts b/packages/opencode/src/path/path.ts index b144b4737710..f047d04d82d5 100644 --- a/packages/opencode/src/path/path.ts +++ b/packages/opencode/src/path/path.ts @@ -88,14 +88,30 @@ const storedWin = (input: string) => { return dir as StoredPath } -export namespace Path { - export function stored(input: RawPath | string): StoredPath { +export namespace StoredPath { + /** + * Parse raw external input into the canonical stored path form. + */ + export function parse(input: RawPath | string): StoredPath { + // Legacy sentinel: "/" is used internally to represent the global/non-project + // pseudo-root. It is not a real Windows StoredPath, but we preserve it here + // for backward compatibility with existing project/session/instance logic. + // Tech debt: split this sentinel out from StoredPath entirely. if (!input || input === "/") return input as StoredPath if (process.platform !== "win32") return path.resolve(input) as StoredPath return storedWin(input) } + /** + * Rehydrate a value that is already stored in canonical form. + * + * Use this for trusted values we previously persisted ourselves. + */ + export function unsafe(input: string): StoredPath { + return input as StoredPath + } + export function same(a: RawPath | string, b: RawPath | string) { - return Filesystem.resolve(stored(a)) === Filesystem.resolve(stored(b)) + return Filesystem.resolve(parse(a)) === Filesystem.resolve(parse(b)) } } From 42da4048875fa109dea2655d8ac41f2ffecf9dae Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:01:30 +1000 Subject: [PATCH 12/14] refactor(core): type stored path boundaries --- packages/opencode/src/cli/cmd/tui/thread.ts | 10 ++-- .../src/control-plane/adaptors/worktree.ts | 3 +- packages/opencode/src/control-plane/types.ts | 3 +- .../opencode/src/control-plane/workspace.ts | 6 +-- packages/opencode/src/path/migration.ts | 10 ++-- packages/opencode/src/project/instance.ts | 6 +-- packages/opencode/src/project/project.ts | 40 ++++++++-------- packages/opencode/src/server/server.ts | 4 +- packages/opencode/src/session/index.ts | 20 ++++---- .../test/control-plane/workspace-sync.test.ts | 9 ++-- packages/opencode/test/path/path.test.ts | 42 ++++++++--------- .../opencode/test/project/project.test.ts | 47 ++++++++++--------- .../opencode/test/server/session-list.test.ts | 5 +- 13 files changed, 108 insertions(+), 97 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 91a45e4a56a7..8f3cd98e3fdc 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -14,7 +14,7 @@ import type { EventSource } from "./context/sdk" import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32" import { TuiConfig } from "@/config/tui" import { Instance } from "@/project/instance" -import { Path } from "@/path/path" +import { StoredPath } from "@/path/path" declare global { const OPENCODE_WORKER_PATH: string @@ -116,10 +116,10 @@ export const TuiThreadCommand = cmd({ // Resolve relative --project paths from PWD, then use the real cwd after // chdir so the thread and worker share the same directory key. - const root = Path.stored(process.env.PWD ?? process.cwd()) + const root = StoredPath.parse(process.env.PWD ?? process.cwd()) const next = args.project - ? Path.stored(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) - : Path.stored(process.cwd()) + ? StoredPath.parse(path.isAbsolute(args.project) ? args.project : path.join(root, args.project)) + : StoredPath.parse(process.cwd()) const file = await target() try { process.chdir(next) @@ -127,7 +127,7 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = Path.stored(process.cwd()) + const cwd = StoredPath.parse(Filesystem.resolve(process.cwd())) const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/src/control-plane/adaptors/worktree.ts b/packages/opencode/src/control-plane/adaptors/worktree.ts index f84890950115..7f9e03ed9568 100644 --- a/packages/opencode/src/control-plane/adaptors/worktree.ts +++ b/packages/opencode/src/control-plane/adaptors/worktree.ts @@ -1,4 +1,5 @@ import z from "zod" +import { StoredPath } from "@/path/path" import { Worktree } from "@/worktree" import { type Adaptor, WorkspaceInfo } from "../types" @@ -17,7 +18,7 @@ export const WorktreeAdaptor: Adaptor = { ...info, name: worktree.name, branch: worktree.branch, - directory: worktree.directory, + directory: StoredPath.parse(worktree.directory), } }, async create(info) { diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index ab628a693814..503eafe46aee 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -1,5 +1,6 @@ import z from "zod" import { ProjectID } from "@/project/schema" +import { StoredPath } from "@/path/path" import { WorkspaceID } from "./schema" export const WorkspaceInfo = z.object({ @@ -7,7 +8,7 @@ export const WorkspaceInfo = z.object({ type: z.string(), branch: z.string().nullable(), name: z.string().nullable(), - directory: z.string().nullable(), + directory: z.custom().nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, }) diff --git a/packages/opencode/src/control-plane/workspace.ts b/packages/opencode/src/control-plane/workspace.ts index cf45e0b725be..3ac191ed2b09 100644 --- a/packages/opencode/src/control-plane/workspace.ts +++ b/packages/opencode/src/control-plane/workspace.ts @@ -12,7 +12,7 @@ import { getAdaptor } from "./adaptors" import { WorkspaceInfo } from "./types" import { WorkspaceID } from "./schema" import { parseSSE } from "./sse" -import { Path } from "@/path/path" +import { StoredPath } from "@/path/path" export namespace Workspace { export const Event = { @@ -41,7 +41,7 @@ export namespace Workspace { type: row.type, branch: row.branch, name: row.name, - directory: row.directory, + directory: row.directory ? StoredPath.unsafe(row.directory) : null, extra: row.extra, projectID: row.project_id, } @@ -66,7 +66,7 @@ export namespace Workspace { type: config.type, branch: config.branch ?? null, name: config.name ?? null, - directory: config.directory ? Path.stored(config.directory) : null, + directory: config.directory ? StoredPath.parse(config.directory) : null, extra: config.extra ?? null, projectID: input.projectID, } diff --git a/packages/opencode/src/path/migration.ts b/packages/opencode/src/path/migration.ts index ccb24aa1981f..4ee7a11be309 100644 --- a/packages/opencode/src/path/migration.ts +++ b/packages/opencode/src/path/migration.ts @@ -7,12 +7,12 @@ import { existsSync, writeFileSync } from "fs" import { ProjectTable } from "@/project/project.sql" import { SessionTable } from "@/session/session.sql" import { WorkspaceTable } from "@/control-plane/workspace.sql" -import { Path } from "./path" +import { StoredPath } from "./path" export namespace PathMigration { const log = Log.create({ service: "path-migration" }) - const uniq = (input: string[]) => [...new Set(input.map((item) => Path.stored(item)))] + const uniq = (input: string[]) => [...new Set(input.map((item) => StoredPath.parse(item)))] export type Stats = { projects: number @@ -33,7 +33,7 @@ export namespace PathMigration { const stats = { projects: 0, sessions: 0, workspaces: 0 } satisfies Stats for (const row of db.select().from(ProjectTable).all()) { - const worktree = Path.stored(row.worktree) + const worktree = StoredPath.parse(row.worktree) const sandboxes = uniq(row.sandboxes) if ( worktree === row.worktree && @@ -47,7 +47,7 @@ export namespace PathMigration { } for (const row of db.select().from(SessionTable).all()) { - const directory = Path.stored(row.directory) + const directory = StoredPath.parse(row.directory) if (directory === row.directory) continue db.update(SessionTable).set({ directory }).where(eq(SessionTable.id, row.id)).run() @@ -55,7 +55,7 @@ export namespace PathMigration { } for (const row of db.select().from(WorkspaceTable).all()) { - const directory = row.directory ? Path.stored(row.directory) : null + const directory = row.directory ? StoredPath.parse(row.directory) : null if (directory === row.directory) continue db.update(WorkspaceTable).set({ directory }).where(eq(WorkspaceTable.id, row.id)).run() diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 54b99b3fc3b1..a3b10ec59041 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -3,7 +3,7 @@ import { disposeInstance } from "@/effect/instance-registry" import { Filesystem } from "@/util/filesystem" import { iife } from "@/util/iife" import { Log } from "@/util/log" -import { Path, type StoredPath } from "@/path/path" +import { StoredPath } from "@/path/path" import { Context } from "../util/context" import { Project } from "./project" import { State } from "./state" @@ -64,7 +64,7 @@ function track(dir: StoredPath, next: Promise) { export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { - const directory = Path.stored(input.directory) + const directory = StoredPath.parse(input.directory) let existing = cache.get(directory) if (!existing) { Log.Default.info("creating instance", { directory }) @@ -118,7 +118,7 @@ export const Instance = { return State.create(() => Instance.directory, init, dispose) }, async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { - const directory = Path.stored(input.directory) + const directory = StoredPath.parse(input.directory) Log.Default.info("reloading instance", { directory }) await Promise.all([State.dispose(directory), disposeInstance(directory)]) cache.delete(directory) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 89823aa3ba5a..cfecf2528a90 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -15,33 +15,35 @@ import { git } from "../util/git" import { Glob } from "../util/glob" import { which } from "../util/which" import { ProjectID } from "./schema" -import { Path, type StoredPath } from "@/path/path" +import { StoredPath } from "@/path/path" export namespace Project { const log = Log.create({ service: "project" }) function preserve(curr: StoredPath, next: string) { - const full = Path.stored(next) - if (Path.same(curr, full)) return curr + const full = StoredPath.parse(next) + if (StoredPath.same(curr, full)) return curr return full } function gitpath(cwd: string, name: string): StoredPath { - if (!name) return Path.stored(cwd) + if (!name) return StoredPath.parse(cwd) // git output includes trailing newlines; keep path whitespace intact. name = name.replace(/[\r\n]+$/, "") - if (!name) return Path.stored(cwd) + if (!name) return StoredPath.parse(cwd) name = Filesystem.windowsPath(name) - if (path.isAbsolute(name)) return Path.stored(name) - return Path.stored(path.resolve(cwd, name)) + if (path.isAbsolute(name)) return StoredPath.parse(name) + return StoredPath.parse(path.resolve(cwd, name)) } + const PathValue = z.custom() + export const Info = z .object({ id: ProjectID.zod, - worktree: z.string(), + worktree: PathValue, vcs: z.literal("git").optional(), name: z.string().optional(), icon: z @@ -61,7 +63,7 @@ export namespace Project { updated: z.number(), initialized: z.number().optional(), }), - sandboxes: z.array(z.string()), + sandboxes: z.array(PathValue), }) .meta({ ref: "Project", @@ -81,7 +83,7 @@ export namespace Project { : undefined return { id: ProjectID.make(row.id), - worktree: row.worktree, + worktree: StoredPath.unsafe(row.worktree), vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, name: row.name ?? undefined, icon, @@ -90,7 +92,7 @@ export namespace Project { updated: row.time_updated, initialized: row.time_initialized ?? undefined, }, - sandboxes: row.sandboxes, + sandboxes: row.sandboxes.map(StoredPath.unsafe), commands: row.commands ?? undefined, } } @@ -103,7 +105,7 @@ export namespace Project { } export async function fromDirectory(directory: string) { - directory = Path.stored(directory) + directory = StoredPath.parse(directory) log.info("fromDirectory", { directory }) const data = await iife(async () => { @@ -111,7 +113,7 @@ export namespace Project { const dotgit = await matches.next().then((x) => x.value) await matches.return() if (dotgit) { - let sandbox = Path.stored(path.dirname(dotgit)) + let sandbox = StoredPath.parse(path.dirname(dotgit)) const gitBinary = which("git") @@ -219,20 +221,20 @@ export namespace Project { return { id: ProjectID.global, - worktree: "/", - sandbox: "/", + worktree: StoredPath.unsafe("/"), + sandbox: StoredPath.unsafe("/"), vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), } }) const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get()) - const existing = row + const existing: Info = row ? fromRow(row) : { id: data.id, worktree: data.worktree, vcs: data.vcs as Info["vcs"], - sandboxes: [] as string[], + sandboxes: [] as StoredPath[], time: { created: Date.now(), updated: Date.now(), @@ -418,7 +420,7 @@ export namespace Project { export async function addSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - directory = Path.stored(directory) + directory = StoredPath.parse(directory) const sandboxes = [...row.sandboxes] if (!sandboxes.includes(directory)) sandboxes.push(directory) const result = Database.use((db) => @@ -443,7 +445,7 @@ export namespace Project { export async function removeSandbox(id: ProjectID, directory: string) { const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) if (!row) throw new Error(`Project not found: ${id}`) - directory = Path.stored(directory) + directory = StoredPath.parse(directory) const sandboxes = row.sandboxes.filter((s) => s !== directory) const result = Database.use((db) => db diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 95d22fb1b762..b3a8409d9640 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -38,7 +38,7 @@ import { websocket } from "hono/bun" import { HTTPException } from "hono/http-exception" import { errors } from "./error" import { Filesystem } from "@/util/filesystem" -import { Path } from "@/path/path" +import { StoredPath } from "@/path/path" import { QuestionRoutes } from "./routes/question" import { PermissionRoutes } from "./routes/permission" import { GlobalRoutes } from "./routes/global" @@ -194,7 +194,7 @@ export namespace Server { if (c.req.path === "/log") return next() const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace") const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd() - const directory = Path.stored( + const directory = StoredPath.parse( (() => { try { return decodeURIComponent(raw) diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 699ce5167349..a5bb783f8e76 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -25,7 +25,7 @@ import { WorkspaceContext } from "../control-plane/workspace-context" import { ProjectID } from "../project/schema" import { WorkspaceID } from "../control-plane/schema" import { SessionID, MessageID, PartID } from "./schema" -import { Path } from "@/path/path" +import { StoredPath } from "@/path/path" import type { Provider } from "@/provider/provider" import { ModelID, ProviderID } from "@/provider/schema" @@ -52,6 +52,8 @@ export namespace Session { type SessionRow = typeof SessionTable.$inferSelect + const PathValue = z.custom() + export function fromRow(row: SessionRow): Info { const summary = row.summary_additions !== null || row.summary_deletions !== null || row.summary_files !== null @@ -69,7 +71,7 @@ export namespace Session { slug: row.slug, projectID: row.project_id, workspaceID: row.workspace_id ?? undefined, - directory: row.directory, + directory: StoredPath.unsafe(row.directory), parentID: row.parent_id ?? undefined, title: row.title, version: row.version, @@ -93,7 +95,7 @@ export namespace Session { workspace_id: info.workspaceID, parent_id: info.parentID, slug: info.slug, - directory: Path.stored(info.directory), + directory: info.directory, title: info.title, version: info.version, share_url: info.share?.url, @@ -126,7 +128,7 @@ export namespace Session { slug: z.string(), projectID: ProjectID.zod, workspaceID: WorkspaceID.zod.optional(), - directory: z.string(), + directory: PathValue, parentID: SessionID.zod.optional(), summary: z .object({ @@ -168,7 +170,7 @@ export namespace Session { .object({ id: ProjectID.zod, name: z.string().optional(), - worktree: z.string(), + worktree: PathValue, }) .meta({ ref: "ProjectSummary", @@ -308,7 +310,7 @@ export namespace Session { slug: Slug.create(), version: Installation.VERSION, projectID: Instance.project.id, - directory: Path.stored(input.directory), + directory: StoredPath.parse(input.directory), workspaceID: input.workspaceID, parentID: input.parentID, title: input.title ?? createDefaultTitle(!!input.parentID), @@ -553,7 +555,7 @@ export namespace Session { conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID)) } if (input?.directory) { - conditions.push(eq(SessionTable.directory, Path.stored(input.directory))) + conditions.push(eq(SessionTable.directory, StoredPath.parse(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -593,7 +595,7 @@ export namespace Session { const conditions: SQL[] = [] if (input?.directory) { - conditions.push(eq(SessionTable.directory, Path.stored(input.directory))) + conditions.push(eq(SessionTable.directory, StoredPath.parse(input.directory))) } if (input?.roots) { conditions.push(isNull(SessionTable.parent_id)) @@ -639,7 +641,7 @@ export namespace Session { projects.set(item.id, { id: item.id, name: item.name ?? undefined, - worktree: item.worktree, + worktree: StoredPath.unsafe(item.worktree), }) } } diff --git a/packages/opencode/test/control-plane/workspace-sync.test.ts b/packages/opencode/test/control-plane/workspace-sync.test.ts index 4763fc438ebb..36b21489d344 100644 --- a/packages/opencode/test/control-plane/workspace-sync.test.ts +++ b/packages/opencode/test/control-plane/workspace-sync.test.ts @@ -9,6 +9,7 @@ import { GlobalBus } from "../../src/bus/global" import { resetDatabase } from "../fixture/db" import * as adaptors from "../../src/control-plane/adaptors" import type { Adaptor } from "../../src/control-plane/types" +import { StoredPath } from "../../src/path/path" const bash = (input: string) => { const drive = input[0].toLowerCase() @@ -114,7 +115,7 @@ describe("control-plane/workspace.startSyncing", () => { configure(config) { return { ...config, - directory: dir, + directory: StoredPath.parse(dir), name: "remote-b", } }, @@ -131,8 +132,8 @@ describe("control-plane/workspace.startSyncing", () => { const loaded = await Workspace.get(created.id) const listed = Workspace.list(project) - expect(created.directory).toBe(tmp.path) - expect(loaded?.directory).toBe(tmp.path) - expect(listed.find((item) => item.id === created.id)?.directory).toBe(tmp.path) + expect(created.directory).toBe(StoredPath.unsafe(tmp.path)) + expect(loaded?.directory).toBe(StoredPath.unsafe(tmp.path)) + expect(listed.find((item) => item.id === created.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) }) }) diff --git a/packages/opencode/test/path/path.test.ts b/packages/opencode/test/path/path.test.ts index 08d23e2dce0f..99a349b563e7 100644 --- a/packages/opencode/test/path/path.test.ts +++ b/packages/opencode/test/path/path.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { dlopen, ptr } from "bun:ffi" import fs from "fs/promises" import path from "path" -import { Path } from "../../src/path/path" +import { StoredPath } from "../../src/path/path" import { tmpdir } from "../fixture/fixture" const k32 = @@ -36,14 +36,14 @@ describe("path", () => { } test("keeps sentinel storage paths unchanged", () => { - expect(String(Path.stored(""))).toBe("") - expect(String(Path.stored("/"))).toBe("/") + expect(String(StoredPath.parse(""))).toBe("") + expect(String(StoredPath.parse("/"))).toBe("/") }) test("keeps missing paths on the chosen route", async () => { await using tmp = await tmpdir() const dir = path.join(tmp.path, "missing", "child") - expect(String(Path.stored(dir))).toBe(path.resolve(dir)) + expect(String(StoredPath.parse(dir))).toBe(path.resolve(dir)) }) test("preserves symlink routes in stored paths", async () => { @@ -55,10 +55,10 @@ describe("path", () => { const alias = path.join(tmp.path, "Alias") await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") - expect(String(Path.stored(alias))).toBe(alias) - expect(String(Path.stored(path.join(alias, "Leaf")))).toBe(path.join(alias, "Leaf")) - expect(String(Path.stored(alias))).not.toBe(real) - expect(String(Path.stored(path.join(alias, "Leaf")))).not.toBe(path.join(real, "Leaf")) + expect(String(StoredPath.parse(alias))).toBe(alias) + expect(String(StoredPath.parse(path.join(alias, "Leaf")))).toBe(path.join(alias, "Leaf")) + expect(String(StoredPath.parse(alias))).not.toBe(real) + expect(String(StoredPath.parse(path.join(alias, "Leaf")))).not.toBe(path.join(real, "Leaf")) }) test("keeps missing descendants on a chosen symlink route", async () => { @@ -69,8 +69,8 @@ describe("path", () => { const alias = path.join(tmp.path, "Alias") await fs.symlink(real, alias, process.platform === "win32" ? "junction" : "dir") - expect(String(Path.stored(path.join(alias, "missing", "child")))).toBe(path.join(alias, "missing", "child")) - expect(String(Path.stored(path.join(alias, "missing", "child")))).not.toBe(path.join(real, "missing", "child")) + expect(String(StoredPath.parse(path.join(alias, "missing", "child")))).toBe(path.join(alias, "missing", "child")) + expect(String(StoredPath.parse(path.join(alias, "missing", "child")))).not.toBe(path.join(real, "missing", "child")) }) test("keeps native posix routes without introducing Windows key forms", async () => { @@ -79,8 +79,8 @@ describe("path", () => { const dir = path.join(tmp.path, "Thing") await fs.mkdir(dir, { recursive: true }) - expect(String(Path.stored(dir))).toBe(dir) - expect(String(Path.stored(dir))).not.toContain("\\") + expect(String(StoredPath.parse(dir))).toBe(dir) + expect(String(StoredPath.parse(dir))).not.toContain("\\") }) test("normalizes Windows bash-style paths to native routes", async () => { @@ -91,11 +91,11 @@ describe("path", () => { const drive = tmp.path[0].toLowerCase() const rest = tmp.path.slice(2).replaceAll("\\", "/") - expect(String(Path.stored(root))).toBe(tmp.path) - expect(String(Path.stored(`/cygdrive/${drive}${rest}`))).toBe(tmp.path) - expect(String(Path.stored(`/mnt/${drive}${rest}`))).toBe(tmp.path) - expect(String(Path.stored(root))).not.toContain("/cygdrive/") - expect(String(Path.stored(root))).not.toContain(`/mnt/${drive}`) + expect(String(StoredPath.parse(root))).toBe(tmp.path) + expect(String(StoredPath.parse(`/cygdrive/${drive}${rest}`))).toBe(tmp.path) + expect(String(StoredPath.parse(`/mnt/${drive}${rest}`))).toBe(tmp.path) + expect(String(StoredPath.parse(root))).not.toContain("/cygdrive/") + expect(String(StoredPath.parse(root))).not.toContain(`/mnt/${drive}`) }) test("canonicalizes lowercase drive roots to the stored drive casing", async () => { @@ -103,7 +103,7 @@ describe("path", () => { await using tmp = await tmpdir() const raw = tmp.path.replace(/^[A-Z]:/, (x) => x.toLowerCase()) - expect(String(Path.stored(raw))).toBe(tmp.path) + expect(String(StoredPath.parse(raw))).toBe(tmp.path) }) test("expands Windows short-name aliases when one exists", async () => { @@ -114,13 +114,13 @@ describe("path", () => { if (!raw || raw === tmp.path) return expect(raw).toContain("~") - expect(String(Path.stored(raw))).toBe(tmp.path) - expect(String(Path.stored(raw))).not.toContain("~") + expect(String(StoredPath.parse(raw))).toBe(tmp.path) + expect(String(StoredPath.parse(raw))).not.toContain("~") }) test("preserves UNC routes for network-style paths", () => { if (process.platform !== "win32") return const dir = "\\\\server\\share\\Repo\\folder" - expect(String(Path.stored(dir))).toBe(dir) + expect(String(StoredPath.parse(dir))).toBe(dir) }) }) diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 2fb486ea03db..dea3055ebe89 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -7,10 +7,13 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Filesystem } from "../../src/util/filesystem" import { GlobalBus } from "../../src/bus/global" +import { StoredPath } from "../../src/path/path" import { ProjectID } from "../../src/project/schema" Log.init({ print: false }) +const trust = StoredPath.unsafe + const gitModule = await import("../../src/util/git") const originalGit = gitModule.git @@ -78,7 +81,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) @@ -94,7 +97,7 @@ describe("Project.fromDirectory", () => { expect(project).toBeDefined() expect(project.id).not.toBe(ProjectID.global) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) const opencodeFile = path.join(tmp.path, ".git", "opencode") const fileExists = await Filesystem.exists(opencodeFile) @@ -110,9 +113,9 @@ describe("Project.fromDirectory", () => { const { project, sandbox } = await p.fromDirectory(alias) - expect(project.worktree).toBe(alias) - expect(sandbox).toBe(alias) - expect(project.worktree).not.toBe(tmp.path) + expect(project.worktree).toBe(trust(alias)) + expect(sandbox).toBe(trust(alias)) + expect(project.worktree).not.toBe(trust(tmp.path)) }) test("keeps git vcs when rev-list exits non-zero with empty output", async () => { @@ -124,7 +127,7 @@ describe("Project.fromDirectory", () => { const { project } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") expect(project.id).toBe(ProjectID.global) - expect(project.worktree).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) }) }) @@ -135,8 +138,8 @@ describe("Project.fromDirectory", () => { await withMode("top-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) }) }) @@ -147,8 +150,8 @@ describe("Project.fromDirectory", () => { await withMode("common-dir-fail", async () => { const { project, sandbox } = await p.fromDirectory(tmp.path) expect(project.vcs).toBe("git") - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) }) }) }) @@ -160,9 +163,9 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.fromDirectory(tmp.path) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(tmp.path) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(tmp.path)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) }) test("should set worktree to root when called from a worktree", async () => { @@ -175,10 +178,10 @@ describe("Project.fromDirectory with worktrees", () => { const { project, sandbox } = await p.fromDirectory(worktreePath) - expect(project.worktree).toBe(tmp.path) - expect(sandbox).toBe(worktreePath) - expect(project.sandboxes).toContain(worktreePath) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(sandbox).toBe(trust(worktreePath)) + expect(project.sandboxes).toContain(trust(worktreePath)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) } finally { await $`git worktree remove ${worktreePath}` .cwd(tmp.path) @@ -246,10 +249,10 @@ describe("Project.fromDirectory with worktrees", () => { await p.fromDirectory(worktree1) const { project } = await p.fromDirectory(worktree2) - expect(project.worktree).toBe(tmp.path) - expect(project.sandboxes).toContain(worktree1) - expect(project.sandboxes).toContain(worktree2) - expect(project.sandboxes).not.toContain(tmp.path) + expect(project.worktree).toBe(trust(tmp.path)) + expect(project.sandboxes).toContain(trust(worktree1)) + expect(project.sandboxes).toContain(trust(worktree2)) + expect(project.sandboxes).not.toContain(trust(tmp.path)) } finally { await $`git worktree remove ${worktree1}` .cwd(tmp.path) @@ -308,7 +311,7 @@ describe("Project sandboxes", () => { await Project.addSandbox(project.id, tmp.path) const updated = await Project.addSandbox(project.id, raw) - expect(updated.sandboxes).toEqual([tmp.path]) + expect(updated.sandboxes).toEqual([trust(tmp.path)]) }) test("removes a sandbox across equivalent path spellings", async () => { diff --git a/packages/opencode/test/server/session-list.test.ts b/packages/opencode/test/server/session-list.test.ts index 80d8c39a5d55..af0df068860b 100644 --- a/packages/opencode/test/server/session-list.test.ts +++ b/packages/opencode/test/server/session-list.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import path from "path" import { Instance } from "../../src/project/instance" +import { StoredPath } from "../../src/path/path" import { Session } from "../../src/session" import { Log } from "../../src/util/log" import { tmpdir } from "../fixture/fixture" @@ -106,7 +107,7 @@ describe("Session.list", () => { const sessions = [...Session.list({ directory: bash(tmp.path) })] expect(sessions.map((item) => item.id)).toContain(session.id) - expect(sessions.find((item) => item.id === session.id)?.directory).toBe(tmp.path) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) }, }) }) @@ -124,7 +125,7 @@ describe("Session.list", () => { const sessions = [...Session.list({ directory: raw })] expect(sessions.map((item) => item.id)).toContain(session.id) - expect(sessions.find((item) => item.id === session.id)?.directory).toBe(tmp.path) + expect(sessions.find((item) => item.id === session.id)?.directory).toBe(StoredPath.unsafe(tmp.path)) }, }) }) From 9a8dfd806cc63a43c85d32244236ebffd71345df Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:02:32 +1000 Subject: [PATCH 13/14] refactor(core): simplify tui stored path handoff --- packages/opencode/src/cli/cmd/tui/thread.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index 8f3cd98e3fdc..fa49a460ae26 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -127,7 +127,7 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = StoredPath.parse(Filesystem.resolve(process.cwd())) + const cwd = StoredPath.unsafe(Filesystem.resolve(process.cwd())) const worker = new Worker(file, { env: Object.fromEntries( From 12a113e67951443df2e2588de5e03f6c00dff4dd Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Mon, 23 Mar 2026 16:07:36 +1000 Subject: [PATCH 14/14] fix(core): keep stored path schemas exportable --- packages/opencode/src/cli/cmd/tui/thread.ts | 4 +++- packages/opencode/src/control-plane/types.ts | 2 +- packages/opencode/src/project/project.ts | 2 +- packages/opencode/src/session/index.ts | 2 +- 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/thread.ts b/packages/opencode/src/cli/cmd/tui/thread.ts index fa49a460ae26..d151cdc89228 100644 --- a/packages/opencode/src/cli/cmd/tui/thread.ts +++ b/packages/opencode/src/cli/cmd/tui/thread.ts @@ -127,7 +127,9 @@ export const TuiThreadCommand = cmd({ UI.error("Failed to change directory to " + next) return } - const cwd = StoredPath.unsafe(Filesystem.resolve(process.cwd())) + // After chdir we intentionally collapse to the physical cwd so the thread, + // worker, and downstream session keys agree on one target directory. + const cwd = StoredPath.parse(Filesystem.resolve(process.cwd())) const worker = new Worker(file, { env: Object.fromEntries( diff --git a/packages/opencode/src/control-plane/types.ts b/packages/opencode/src/control-plane/types.ts index 503eafe46aee..1e6f029103b0 100644 --- a/packages/opencode/src/control-plane/types.ts +++ b/packages/opencode/src/control-plane/types.ts @@ -8,7 +8,7 @@ export const WorkspaceInfo = z.object({ type: z.string(), branch: z.string().nullable(), name: z.string().nullable(), - directory: z.custom().nullable(), + directory: (z.string() as unknown as z.ZodType).nullable(), extra: z.unknown().nullable(), projectID: ProjectID.zod, }) diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index cfecf2528a90..1ad5f0c0c680 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -38,7 +38,7 @@ export namespace Project { return StoredPath.parse(path.resolve(cwd, name)) } - const PathValue = z.custom() + const PathValue = z.string() as unknown as z.ZodType export const Info = z .object({ diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index a5bb783f8e76..717bd8106dc5 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -52,7 +52,7 @@ export namespace Session { type SessionRow = typeof SessionTable.$inferSelect - const PathValue = z.custom() + const PathValue = z.string() as unknown as z.ZodType export function fromRow(row: SessionRow): Info { const summary =