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
commit f0c7febb02
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1993 additions and 17 deletions

View file

@ -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

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[]

View file

@ -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

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))))
},
})

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 }>(),
})

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(),

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* () {