From 446d293ef02593d2ea743b94fd39451cf2413afd Mon Sep 17 00:00:00 2001 From: 0xmrmasry Date: Sun, 18 Oct 2026 00:09:32 +0300 Subject: [PATCH 1/3] fix: build valid SQLite file URIs on Windows --- internal/automation/store.go | 4 +-- internal/plandb/preview.go | 11 +++++-- internal/sqliteuri/path.go | 16 +++++++++++ internal/sqliteuri/path_test.go | 51 +++++++++++++++++++++++++++++++++ internal/store/store.go | 4 +-- internal/workspace/store.go | 4 +-- 6 files changed, 82 insertions(+), 8 deletions(-) create mode 100644 internal/sqliteuri/path.go create mode 100644 internal/sqliteuri/path_test.go diff --git a/internal/automation/store.go b/internal/automation/store.go index 6224fd85e..f322c155d 100644 --- a/internal/automation/store.go +++ b/internal/automation/store.go @@ -19,13 +19,13 @@ import ( "encoding/json" "errors" "fmt" - "net/url" "os" "path/filepath" "strconv" "strings" "time" + "github.com/Agent-Field/codeaf/internal/sqliteuri" _ "modernc.org/sqlite" ) @@ -79,7 +79,7 @@ func Open(root string) (*Store, error) { } else if err := f.Close(); err != nil { return nil, fmt.Errorf("automations: %w", err) } - u := url.URL{Scheme: "file", Path: path} + u := sqliteuri.File(path) q := u.Query() q.Set("mode", "rw") q.Add("_pragma", fmt.Sprintf("busy_timeout(%d)", busyTimeout.Milliseconds())) diff --git a/internal/plandb/preview.go b/internal/plandb/preview.go index cc1c9f30b..7197b5add 100644 --- a/internal/plandb/preview.go +++ b/internal/plandb/preview.go @@ -2,8 +2,10 @@ package plandb import ( "database/sql" - "net/url" + "path/filepath" "time" + + "github.com/Agent-Field/codeaf/internal/sqliteuri" ) // RootPreview is the original request behind a saved run. Listing a conversation @@ -18,7 +20,12 @@ type RootPreview struct { // creates a database, migrates its schema or settles a task left running by a // process that exited, so a conversation picker can safely read live work too. func ReadRootPreview(path string) (RootPreview, error) { - uri := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro"} + absolute, err := filepath.Abs(path) + if err != nil { + return RootPreview{}, err + } + uri := sqliteuri.File(absolute) + uri.RawQuery = "mode=ro" db, err := sql.Open("sqlite", uri.String()) if err != nil { return RootPreview{}, err diff --git a/internal/sqliteuri/path.go b/internal/sqliteuri/path.go new file mode 100644 index 000000000..3f42d2d3f --- /dev/null +++ b/internal/sqliteuri/path.go @@ -0,0 +1,16 @@ +package sqliteuri + +import ( + "net/url" + "path/filepath" + "strings" +) + +// File builds a SQLite file URI from an absolute filesystem path. +func File(path string) url.URL { + path = filepath.ToSlash(path) + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return url.URL{Scheme: "file", Path: path} +} diff --git a/internal/sqliteuri/path_test.go b/internal/sqliteuri/path_test.go new file mode 100644 index 000000000..ee1e92ada --- /dev/null +++ b/internal/sqliteuri/path_test.go @@ -0,0 +1,51 @@ +package sqliteuri + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func TestFileOpensTheNamedDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "memory #1 & 100%.db") + u := File(path) + q := u.Query() + q.Add("_pragma", "foreign_keys(1)") + u.RawQuery = q.Encode() + db, err := sql.Open("sqlite", u.String()) + if err != nil { + t.Fatal(err) + } + var foreignKeys int + if err := db.QueryRow("PRAGMA foreign_keys").Scan(&foreignKeys); err != nil || foreignKeys != 1 { + db.Close() + t.Fatalf("connection pragma was lost: foreign_keys=%d, err=%v", foreignKeys, err) + } + if _, err := db.Exec("CREATE TABLE probe(value TEXT); INSERT INTO probe VALUES ('persisted')"); err != nil { + db.Close() + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } + q.Set("mode", "ro") + u.RawQuery = q.Encode() + db, err = sql.Open("sqlite", u.String()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + var value string + if err := db.QueryRow("SELECT value FROM probe").Scan(&value); err != nil || value != "persisted" { + t.Fatalf("wrong database: value=%q, err=%v", value, err) + } + if _, err := db.Exec("INSERT INTO probe VALUES ('unexpected')"); err == nil { + t.Fatal("mode=ro opened a writable database") + } +} diff --git a/internal/store/store.go b/internal/store/store.go index f759f4801..d716b5c13 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -12,13 +12,13 @@ import ( "errors" "fmt" "io/fs" - "net/url" "os" "path/filepath" "strings" "time" "github.com/Agent-Field/codeaf/internal/cas" + "github.com/Agent-Field/codeaf/internal/sqliteuri" _ "modernc.org/sqlite" ) @@ -697,7 +697,7 @@ func Open(path string) (*Store, error) { return nil, fmt.Errorf("could not open %s: %w", storePathForPerson(absolute), pathReason(err)) } - u := url.URL{Scheme: "file", Path: absolute} + u := sqliteuri.File(absolute) query := u.Query() // busy_timeout is SQLite's own patience and nothing else's: no caller waits // it out any more, because every write goes through [Store.beginWrite] and diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 92fb4aba0..2d22293bb 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -8,13 +8,13 @@ import ( "errors" "fmt" "io/fs" - "net/url" "os" "path/filepath" "strings" "sync" "time" + "github.com/Agent-Field/codeaf/internal/sqliteuri" sqlite "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" ) @@ -78,7 +78,7 @@ func Open(path string) (*Store, error) { return nil, collectionsOpenFailure(absolute, err) } } - u := url.URL{Scheme: "file", Path: absolute} + u := sqliteuri.File(absolute) q := u.Query() // The private file was created above. SQLite must not create another file // with its default permissions if the path is a dangling link or disappears. From 09308d10ffd190aafb42884ca7737dd076ce51b2 Mon Sep 17 00:00:00 2001 From: 0xmrmasry Date: Sun, 18 Oct 2026 00:12:53 +0300 Subject: [PATCH 2/3] docs: record Windows SQLite URI fix for #1868 --- .../unreleased/1868-windows-sqlite-file-uris.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/changes/unreleased/1868-windows-sqlite-file-uris.md diff --git a/docs/changes/unreleased/1868-windows-sqlite-file-uris.md b/docs/changes/unreleased/1868-windows-sqlite-file-uris.md new file mode 100644 index 000000000..f78bb056c --- /dev/null +++ b/docs/changes/unreleased/1868-windows-sqlite-file-uris.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: Windows database paths open without a misleading SQLite out-of-memory error +pr: 1868 +surface: [chat, engine] +invalidates: [] +--- + +Graph memory, automation storage, collections, and read-only plan previews now +construct rooted SQLite file URIs. Windows drive paths use `file:///C:/...` +instead of `file://C:%5C...`, which SQLite rejects before opening the database. +The shared constructor preserves URL escaping and each caller's query settings. From a7bd2d11ffc08864f41e22441d4db2ef063b226f Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 20 Oct 2026 09:29:08 -0400 Subject: [PATCH 3/3] test: preserve POSIX SQLite URIs and cover Windows path forms Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/sqliteuri/path_test.go | 74 +++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/internal/sqliteuri/path_test.go b/internal/sqliteuri/path_test.go index ee1e92ada..048510fc5 100644 --- a/internal/sqliteuri/path_test.go +++ b/internal/sqliteuri/path_test.go @@ -2,18 +2,70 @@ package sqliteuri import ( "database/sql" + "net/url" "os" "path/filepath" + "runtime" "testing" _ "modernc.org/sqlite" ) +func TestFilePreservesPOSIXURIs(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX path spelling is a contract on POSIX hosts") + } + for _, path := range []string{ + "/tmp/memory.db", "/tmp/with spaces.db", "/tmp/hash#1.db", + "/tmp/a&b.db", "/tmp/100%.db", "/tmp/literal%20.db", + "/tmp/query?mode=ro.db", "/tmp/記憶-é.db", "/tmp/back\\slash.db", + "//server/share/db", "/tmp/../tmp/db", + } { + t.Run(path, func(t *testing.T) { + before := url.URL{Scheme: "file", Path: path} + after := File(path) + q := url.Values{"mode": {"rw"}, "_pragma": {"busy_timeout(5000)", "foreign_keys(1)"}, "_txlock": {"immediate"}} + before.RawQuery, after.RawQuery = q.Encode(), q.Encode() + if after.String() != before.String() { + t.Fatalf("URI changed: got %q, dev produced %q", after.String(), before.String()) + } + }) + } +} + +func TestFileWindowsPaths(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("filepath.ToSlash follows the host filesystem") + } + for _, test := range []struct{ path, want string }{ + {`C:\x\y.db`, "file:///C:/x/y.db"}, + {`C:/x/y.db`, "file:///C:/x/y.db"}, + {`C:\x\with space #&%.db`, "file:///C:/x/with%20space%20%23&%25.db"}, + {`C:\記憶\é.db`, "file:///C:/%E8%A8%98%E6%86%B6/%C3%A9.db"}, + {`\\server\share\db`, "file:////server/share/db"}, + {`\\server\share\with space #&%.db`, "file:////server/share/with%20space%20%23&%25.db"}, + } { + t.Run(test.path, func(t *testing.T) { + u := File(test.path) + if u.String() != test.want || u.Host != "" { + t.Fatalf("URI = %q, host = %q; want %q with no authority", u.String(), u.Host, test.want) + } + q := url.Values{"mode": {"ro"}, "_pragma": {"busy_timeout(5000)", "foreign_keys(1)"}, "_txlock": {"immediate"}} + u.RawQuery = q.Encode() + if u.String() != test.want+"?"+q.Encode() { + t.Fatalf("caller query changed: %q", u.String()) + } + }) + } +} + func TestFileOpensTheNamedDatabase(t *testing.T) { path := filepath.Join(t.TempDir(), "memory #1 & 100%.db") u := File(path) q := u.Query() q.Add("_pragma", "foreign_keys(1)") + q.Add("_pragma", "busy_timeout(5000)") + q.Set("_txlock", "immediate") u.RawQuery = q.Encode() db, err := sql.Open("sqlite", u.String()) if err != nil { @@ -24,6 +76,11 @@ func TestFileOpensTheNamedDatabase(t *testing.T) { db.Close() t.Fatalf("connection pragma was lost: foreign_keys=%d, err=%v", foreignKeys, err) } + var busyTimeout int + if err := db.QueryRow("PRAGMA busy_timeout").Scan(&busyTimeout); err != nil || busyTimeout != 5000 { + db.Close() + t.Fatalf("connection pragma was lost: busy_timeout=%d, err=%v", busyTimeout, err) + } if _, err := db.Exec("CREATE TABLE probe(value TEXT); INSERT INTO probe VALUES ('persisted')"); err != nil { db.Close() t.Fatal(err) @@ -49,3 +106,20 @@ func TestFileOpensTheNamedDatabase(t *testing.T) { t.Fatal("mode=ro opened a writable database") } } + +func TestFileReadOnlyDoesNotCreateAMissingDatabase(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing #&%.db") + u := File(path) + u.RawQuery = "mode=ro" + db, err := sql.Open("sqlite", u.String()) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := db.Ping(); err == nil { + t.Fatal("read-only open accepted a missing database") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("read-only open created the database: %v", err) + } +}