fix(opencode): enforce storage path invariants (#29666)

This commit is contained in:
Luke Parker
2026-06-02 10:54:41 +10:00
committed by GitHub
parent a821029258
commit f0c7febb02
15 changed files with 1993 additions and 17 deletions
+2
View File
@@ -0,0 +1,2 @@
packages/core/migration/**/snapshot.json linguist-generated
packages/core/src/database/migration.gen.ts linguist-generated
@@ -0,0 +1,7 @@
UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');
--> statement-breakpoint
UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';
--> statement-breakpoint
UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');
File diff suppressed because it is too large Load Diff
+1
View File
@@ -23,5 +23,6 @@ export const migrations = (
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
@@ -0,0 +1,14 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration"
export default {
id: "20260601010001_normalize_storage_paths",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`UPDATE project SET worktree = REPLACE(worktree, char(92), '/') WHERE worktree GLOB '[A-Za-z]:' || char(92) || '*' OR worktree LIKE char(92) || char(92) || '%';`)
yield* tx.run(`UPDATE project SET sandboxes = REPLACE(sandboxes, char(92) || char(92), '/') WHERE instr(sandboxes, char(92)) > 0 AND (worktree GLOB '[A-Za-z]:*' OR worktree LIKE '//%');`)
yield* tx.run(`UPDATE session SET directory = REPLACE(directory, char(92), '/') WHERE directory GLOB '[A-Za-z]:' || char(92) || '*' OR directory LIKE char(92) || char(92) || '%';`)
yield* tx.run(`UPDATE session SET path = REPLACE(path, char(92), '/') WHERE path IS NOT NULL AND instr(path, char(92)) > 0 AND (directory GLOB '[A-Za-z]:*' OR directory LIKE '//%');`)
})
},
} satisfies DatabaseMigration.Migration
+91
View File
@@ -0,0 +1,91 @@
import nodePath from "path"
import { customType } from "drizzle-orm/sqlite-core"
import { AbsolutePath } from "../schema"
function storagePath(input: string) {
if (process.platform !== "win32") return input
return input.replaceAll("\\", "/")
}
function isWindowsStoragePath(input: string) {
return /^[A-Za-z]:\//.test(input) || input.startsWith("//")
}
function absolute(input: string) {
const result = storagePath(input)
if (!nodePath.posix.isAbsolute(result) && !(process.platform === "win32" && isWindowsStoragePath(result))) {
throw new Error(`Path is not absolute: ${input}`)
}
return result
}
function toPlatform(input: string) {
if (process.platform !== "win32" || !isWindowsStoragePath(input)) return input
return input.replaceAll("/", "\\")
}
export const absoluteColumn = customType<{
data: AbsolutePath
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return absolute(input)
},
fromDriver(input) {
return AbsolutePath.make(toPlatform(absolute(input)))
},
})
// Legacy sessions may persist an empty directory. Keep that existing value
// readable while normalizing and validating every real directory.
export const directoryColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return input ? absolute(input) : input
},
fromDriver(input) {
return input ? toPlatform(absolute(input)) : input
},
})
export const pathColumn = customType<{
data: string
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return storagePath(input)
},
fromDriver(input) {
return storagePath(input)
},
})
export const absoluteArrayColumn = customType<{
data: AbsolutePath[]
driverData: string
driverOutput: string
}>({
dataType() {
return "text"
},
toDriver(input) {
return JSON.stringify(input.map(absolute))
},
fromDriver(input) {
return (JSON.parse(input) as string[]).map((item) => AbsolutePath.make(toPlatform(absolute(item))))
},
})
+3 -2
View File
@@ -1,10 +1,11 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { Timestamps } from "../database/schema.sql"
import { ProjectV2 } from "../project"
export const ProjectTable = sqliteTable("project", {
id: text().$type<ProjectV2.ID>().primaryKey(),
worktree: text().notNull(),
worktree: DatabasePath.absoluteColumn().notNull(),
vcs: text(),
name: text(),
icon_url: text(),
@@ -12,6 +13,6 @@ export const ProjectTable = sqliteTable("project", {
icon_color: text(),
...Timestamps,
time_initialized: integer(),
sandboxes: text({ mode: "json" }).notNull().$type<string[]>(),
sandboxes: DatabasePath.absoluteArrayColumn().notNull(),
commands: text({ mode: "json" }).$type<{ start?: string }>(),
})
+3 -2
View File
@@ -1,4 +1,5 @@
import { sqliteTable, text, integer, index, primaryKey, real } from "drizzle-orm/sqlite-core"
import * as DatabasePath from "../database/path"
import { ProjectTable } from "../project/sql"
import type { SessionMessage } from "./message"
import type { Snapshot } from "../snapshot"
@@ -24,8 +25,8 @@ export const SessionTable = sqliteTable(
workspace_id: text().$type<WorkspaceV2.ID>(),
parent_id: text().$type<SessionSchema.ID>(),
slug: text().notNull(),
directory: text().notNull(),
path: text(),
directory: DatabasePath.directoryColumn().notNull(),
path: DatabasePath.pathColumn(),
title: text().notNull(),
version: text().notNull(),
share_url: text(),
+153 -2
View File
@@ -4,9 +4,15 @@ import { fileURLToPath } from "url"
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
import { Effect } from "effect"
import { sql } from "drizzle-orm"
import { eq, inArray, sql } from "drizzle-orm"
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionSchema } from "@opencode-ai/core/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
@@ -37,7 +43,7 @@ describe("DatabaseMigration", () => {
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
name: "session",
})
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 21 })
expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 })
}),
)
})
@@ -71,6 +77,151 @@ describe("DatabaseMigration", () => {
)
})
test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
// Windows-shaped rows (drive + backslash) must be normalized.
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
"C:\\Repo\\Thing\\sandbox",
])})`,
)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
)
// UNC worktrees and their sandboxes must normalize too (not just drive paths).
yield* db.run(
sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
"\\\\server\\share\\sandbox",
])})`,
)
// The "/" worktree sentinel and POSIX paths (including a pathological
// backslash in a POSIX filename) must survive byte-for-byte.
yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
yield* db.run(
sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
)
yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
worktree: "//server/share",
sandboxes: JSON.stringify(["//server/share/sandbox"]),
})
expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
directory: "/home/me/we\\ird",
path: "src\\weird",
})
}),
)
})
test("maps native Windows paths through database columns", async () => {
if (process.platform !== "win32") return
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* DatabaseMigration.apply(db)
const projectID = ProjectV2.ID.make("codec_project")
const worktree = AbsolutePath.make("C:\\Repo\\Thing")
const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
const directory = "C:\\Repo\\Thing\\packages\\api"
const sessionID = SessionSchema.ID.make("ses_codec")
expect(() =>
Effect.runSync(
db
.insert(ProjectTable)
.values({
id: ProjectV2.ID.make("invalid_path"),
worktree: AbsolutePath.make("not-absolute"),
sandboxes: [],
time_created: 1,
time_updated: 1,
})
.run(),
),
).toThrow()
yield* db
.insert(ProjectTable)
.values({
id: projectID,
worktree,
sandboxes: [sandbox],
time_created: 1,
time_updated: 1,
})
.run()
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: projectID,
slug: "codec",
directory,
path: "packages\\api",
title: "Codec",
version: "test",
time_created: 1,
time_updated: 1,
})
.run()
expect(yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`)).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(yield* db.get<{ directory: string; path: string }>(sql`SELECT directory, path FROM session WHERE id = ${sessionID}`)).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
expect(project?.worktree).toBe(worktree)
expect(project?.sandboxes).toEqual([sandbox])
expect(session?.directory).toBe(directory)
expect(session?.path).toBe("packages/api")
expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
sessionID,
)
const moved = AbsolutePath.make("D:\\Moved\\Thing")
const updated = yield* db
.update(ProjectTable)
.set({ worktree: moved, sandboxes: [moved] })
.where(eq(ProjectTable.id, projectID))
.returning()
.get()
expect(updated?.worktree).toBe(moved)
expect(updated?.sandboxes).toEqual([moved])
expect(
yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`),
).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
expect((yield* db.select().from(ProjectTable).where(inArray(ProjectTable.worktree, [moved])).get())?.id).toBe(
projectID,
)
yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow()
}),
)
})
test("imports existing drizzle migration state", async () => {
await run(
Effect.gen(function* () {
+8 -6
View File
@@ -297,7 +297,7 @@ export const layer = Layer.effect(
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
worktree: AbsolutePath.make(result.worktree),
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
@@ -306,13 +306,13 @@ export const layer = Layer.effect(
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
worktree: AbsolutePath.make(result.worktree),
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
@@ -320,7 +320,7 @@ export const layer = Layer.effect(
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
sandboxes: result.sandboxes.map((sandbox) => AbsolutePath.make(sandbox)),
commands: result.commands,
},
})
@@ -451,8 +451,9 @@ export const layer = Layer.effect(
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sandbox = AbsolutePath.make(directory)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
if (!sboxes.includes(sandbox)) sboxes.push(sandbox)
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
@@ -467,7 +468,8 @@ export const layer = Layer.effect(
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectV2.ID, directory: string) {
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get().pipe(Effect.orDie)
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const sandbox = AbsolutePath.make(directory)
const sboxes = row.sandboxes.filter((s) => s !== sandbox)
const result = yield* db
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
+5 -1
View File
@@ -19,6 +19,7 @@ import { gte } from "drizzle-orm"
import { isNull } from "drizzle-orm"
import { desc } from "drizzle-orm"
import { like } from "drizzle-orm"
import { sql } from "drizzle-orm"
import { inArray } from "drizzle-orm"
import { lt } from "drizzle-orm"
import { or } from "drizzle-orm"
@@ -1047,7 +1048,10 @@ function listByProject(
}
if (input.path !== undefined) {
if (input.path) {
const conds = [eq(SessionTable.path, input.path), like(SessionTable.path, `${input.path}/%`)]
const conds = [
eq(SessionTable.path, input.path),
like(SessionTable.path, sql.param(`${input.path}/%`, SessionTable.path)),
]
conditions.push(
input.directory
@@ -13,6 +13,7 @@ import { GlobalBus, type GlobalEvent } from "@/bus/global"
import { Database } from "@opencode-ai/core/database/database"
import { ProjectV2 } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session as SessionNs } from "@/session/session"
import { SessionID } from "@/session/schema"
import { SessionTable } from "@opencode-ai/core/session/sql"
@@ -304,7 +305,7 @@ function insertProject(id: ProjectV2.ID, worktree: string) {
.insert(ProjectTable)
.values({
id,
worktree,
worktree: AbsolutePath.make(worktree),
vcs: null,
name: null,
time_created: Date.now(),
@@ -4,6 +4,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { eq } from "drizzle-orm"
import { SessionTable } from "@opencode-ai/core/session/sql"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { ProjectV2 } from "@opencode-ai/core/project"
import { SessionID } from "../../src/session/schema"
import * as Log from "@opencode-ai/core/util/log"
@@ -48,7 +49,7 @@ function ensureGlobal() {
.insert(ProjectTable)
.values({
id: ProjectV2.ID.global,
worktree: "/",
worktree: AbsolutePath.make("/"),
time_created: Date.now(),
time_updated: Date.now(),
sandboxes: [],
@@ -99,6 +99,33 @@ describe("session.list", () => {
{ git: true },
)
it.instance(
"matches a session regardless of directory separator on Windows",
() =>
Effect.gen(function* () {
if (process.platform !== "win32") return
const test = yield* TestInstance
const dir = path.join(test.directory, "packages", "opencode")
yield* Effect.promise(() => mkdir(dir, { recursive: true }))
const created = yield* withSession({ title: "separator" }).pipe(provideInstance(dir))
// A forward-slash query (e.g. from the SDK/HTTP layer) must still find it —
// this is the regression: backslash-stored vs forward-slash-queried.
const forwardIDs = (yield* SessionNs.Service.use((session) =>
session.list({ directory: dir.replaceAll("\\", "/") }),
)).map((session) => session.id)
expect(forwardIDs).toContain(created.id)
// The native form must keep matching too.
const nativeIDs = (yield* SessionNs.Service.use((session) => session.list({ directory: dir }))).map(
(session) => session.id,
)
expect(nativeIDs).toContain(created.id)
}),
{ git: true },
)
it.instance(
"filters by path and ignores directory when path is provided",
() =>
@@ -132,6 +159,14 @@ describe("session.list", () => {
expect(pathIDs).toContain(current.id)
expect(pathIDs).toContain(deeper.id)
expect(pathIDs).not.toContain(sibling.id)
if (process.platform === "win32") {
const windowsPathIDs = (yield* SessionNs.Service.use((session) =>
session.list({ path: "packages\\opencode\\src" }),
)).map((session) => session.id)
expect(windowsPathIDs).toContain(current.id)
expect(windowsPathIDs).toContain(deeper.id)
}
}),
{ git: true },
)
@@ -9,6 +9,7 @@ import { JsonMigration } from "@/storage/json-migration"
import { Global } from "@opencode-ai/core/global"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { ProjectV2 } from "@opencode-ai/core/project"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "@opencode-ai/core/session/sql"
import { SessionShareTable } from "@opencode-ai/core/share/sql"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
@@ -128,9 +129,39 @@ describe("JSON to SQLite migration", () => {
const projects = db.select().from(ProjectTable).all()
expect(projects.length).toBe(1)
expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
expect(projects[0].worktree).toBe("/test/path")
expect(projects[0].worktree).toBe(AbsolutePath.make("/test/path"))
expect(projects[0].name).toBe("Test Project")
expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
expect(projects[0].sandboxes).toEqual([AbsolutePath.make("/test/sandbox")])
})
test("stores imported Windows project and session paths in storage form", async () => {
if (process.platform !== "win32") return
await writeProject(storageDir, {
id: "proj_test123abc",
worktree: "C:\\Repo\\Thing",
vcs: "git",
sandboxes: ["C:\\Repo\\Thing\\sandbox"],
})
await writeSession(storageDir, "proj_test123abc", {
id: "ses_test456def",
slug: "storage-path",
directory: "C:\\Repo\\Thing\\packages\\api",
path: "packages\\api",
title: "Storage Path",
version: "test",
})
await JsonMigration.run(db)
expect(sqlite.query("SELECT worktree, sandboxes FROM project WHERE id = ?").get("proj_test123abc")).toEqual({
worktree: "C:/Repo/Thing",
sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
})
expect(sqlite.query("SELECT directory, path FROM session WHERE id = ?").get("ses_test456def")).toEqual({
directory: "C:/Repo/Thing/packages/api",
path: "packages/api",
})
})
test("uses filename for project id when JSON has different value", async () => {