From b8b426ed75d0e70c37ab1d54d0e00db9324bc3ae Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Mon, 4 May 2026 13:34:20 +0200 Subject: [PATCH] fix(remote-backend): expand ~/ to $HOME so sqlite3 finds the DB MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Default-config remotes (Hetzner, Digital Ocean, anything where the user hasn't overridden remoteHome on the SSHConfig) have `paths.stateDB == "~/.hermes/state.db"`. The streaming backend was single-quoting that path, which suppresses tilde expansion, and sqlite3 itself doesn't expand `~` (that's a shell affordance). Result: "Error: unable to open database \"~/.hermes/state.db\": unable to open database file" — the path was reaching sqlite3 with a literal `~` that it tried to interpret as a directory name. Replace the single-quote-only `escape(_:)` with `quoteForRemoteShell(_:)` that mirrors `SSHTransport.remotePathArg`'s pattern: rewrite leading `~/` to `"$HOME/..."` (double-quoted so the shell expands `$HOME`, backslash-escaping any embedded `\\`, `"`, `$`, ` to keep the literal intact), bare `~` to `"$HOME"`, and absolute paths get the standard single-quote-with-`'\''`-escape treatment. Adds a regression test (`openWithDefaultTildeHomeExpands`) that exercises the tilde-rewrite end-to-end against a real /bin/sh: places a fixture state.db at `~/.hermes/state.db` (backing up the user's real DB if present) and verifies open() + a query both succeed through the streaming path. Refs #74 Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Backends/RemoteSQLiteBackend.swift | 49 ++++++++++--- .../RemoteSQLiteBackendTests.swift | 69 +++++++++++++++++++ 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift index 07b826b..fd16f77 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/Backends/RemoteSQLiteBackend.swift @@ -77,7 +77,7 @@ public actor RemoteSQLiteBackend: HermesQueryBackend { let preflight = """ set -e sqlite3 --version - sqlite3 -readonly -json '\(escape(dbPath))' "PRAGMA table_info(sessions); PRAGMA table_info(messages);" + sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) "PRAGMA table_info(sessions); PRAGMA table_info(messages);" """ do { @@ -131,7 +131,7 @@ public actor RemoteSQLiteBackend: HermesQueryBackend { let inlined = SQLValueInliner.inline(sql, params: params) let dbPath = context.paths.stateDB let script = """ - sqlite3 -readonly -json '\(escape(dbPath))' <<'__SCARF_SQL__' + sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__' \(inlined) __SCARF_SQL__ """ @@ -166,7 +166,7 @@ public actor RemoteSQLiteBackend: HermesQueryBackend { let combined = sqlBlocks.joined(separator: "\n") let dbPath = context.paths.stateDB let script = """ - sqlite3 -readonly -json '\(escape(dbPath))' <<'__SCARF_SQL__' + sqlite3 -readonly -json \(quoteForRemoteShell(dbPath)) <<'__SCARF_SQL__' \(combined) __SCARF_SQL__ """ @@ -501,12 +501,43 @@ public actor RemoteSQLiteBackend: HermesQueryBackend { // MARK: - Quoting + error mapping - /// Defensive escape for paths embedded in single-quoted shell - /// strings. Real Hermes paths never contain `'`, but doubling the - /// escape doesn't cost anything and keeps us safe against future - /// surprise. - private func escape(_ path: String) -> String { - path.replacingOccurrences(of: "'", with: "'\\''") + /// Build the shell argument that the remote `sh -c` will see for + /// the SQLite path. Two cases: + /// + /// 1. **Tilde-prefixed** (`~/.hermes/state.db`, `~`). sqlite3 + /// itself doesn't expand `~` — that's a shell affordance. The + /// snapshot pipeline used to handle this via SSHTransport's + /// `remotePathArg`, but the new streaming backend doesn't go + /// through that helper. Rewrite to `"$HOME/...rest..."` and + /// rely on the remote shell's $HOME expansion. Mirrors the + /// pattern that fixed snapshot-mode paths in the previous + /// architecture (and matches `SSHTransport.remotePathArg`). + /// 2. **Absolute** (`/home/agent/.hermes/state.db`). Single-quote + /// + double single-quote escape, same as the simple case. + /// + /// Without this rewrite, a default-config Digital Ocean / Hetzner + /// server with `paths.stateDB == "~/.hermes/state.db"` produces + /// `unable to open database "~/.hermes/state.db"` because sqlite3 + /// looks for a literal directory named `~`. + private func quoteForRemoteShell(_ path: String) -> String { + if path == "~" { + return "\"$HOME\"" + } + if path.hasPrefix("~/") { + let rest = String(path.dropFirst(2)) + // Defensively escape characters that have special meaning + // inside a double-quoted shell string. Hermes paths never + // contain these in practice but the cost is zero. + let escaped = rest + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "$", with: "\\$") + .replacingOccurrences(of: "`", with: "\\`") + return "\"$HOME/\(escaped)\"" + } + // Absolute path. Single-quote with the standard sh escape for + // any embedded single-quote (close, escape, reopen). + return "'" + path.replacingOccurrences(of: "'", with: "'\\''") + "'" } /// Translate a non-zero sqlite3 exit into a user-presentable diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/RemoteSQLiteBackendTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/RemoteSQLiteBackendTests.swift index cf434fe..d9bc05e 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/RemoteSQLiteBackendTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/RemoteSQLiteBackendTests.swift @@ -216,6 +216,49 @@ private struct LocalSQLite3Transport: ServerTransport { ) } + /// Construct a remote-shaped context that uses the default + /// `~/.hermes` remote home — exercises the tilde-expansion path + /// in `RemoteSQLiteBackend.quoteForRemoteShell`. The fixture DB + /// is symlinked at `$HOME/.hermes/state.db` so the shell-expanded + /// path resolves correctly. Cleanup restores anything we move. + /// Returns the original-symlink (or absent state) so the caller + /// can restore on teardown. + private struct DefaultHomeFixture { + let dbURL: URL + let stateLink: URL + let backupURL: URL? + let context: ServerContext + } + private func makeDefaultHomeFixtureContext(dbURL: URL) throws -> DefaultHomeFixture { + let homeURL = URL(fileURLWithPath: NSHomeDirectory()) + let hermesDir = homeURL.appendingPathComponent(".hermes", isDirectory: true) + try FileManager.default.createDirectory(at: hermesDir, withIntermediateDirectories: true) + let stateLink = hermesDir.appendingPathComponent("state.db") + // If something is already at ~/.hermes/state.db (the user's + // real Hermes install on dev machines), move it aside so we + // can put our fixture in its place. Restore on teardown. + var backupURL: URL? + if FileManager.default.fileExists(atPath: stateLink.path) { + let bak = hermesDir.appendingPathComponent("state.db.scarf-test-bak-\(UUID().uuidString)") + try FileManager.default.moveItem(at: stateLink, to: bak) + backupURL = bak + } + try FileManager.default.createSymbolicLink(at: stateLink, withDestinationURL: dbURL) + let ctx = ServerContext( + id: UUID(), + displayName: "fixture", + kind: .ssh(SSHConfig(host: "fake.invalid")) + // No remoteHome override → defaults to "~/.hermes". + ) + return DefaultHomeFixture(dbURL: dbURL, stateLink: stateLink, backupURL: backupURL, context: ctx) + } + private func cleanupDefaultHomeFixture(_ fixture: DefaultHomeFixture) { + try? FileManager.default.removeItem(at: fixture.stateLink) + if let bak = fixture.backupURL { + try? FileManager.default.moveItem(at: bak, to: fixture.stateLink) + } + } + /// Skip the test if /usr/bin/sqlite3 isn't available. Mirrors how /// other Apple-only tests gate on system tooling. private func requireSqlite3() throws { @@ -226,6 +269,32 @@ private struct LocalSQLite3Transport: ServerTransport { // MARK: - open() / schema detection + /// Regression: a default-config remote with `paths.stateDB == + /// "~/.hermes/state.db"` previously hit `unable to open database + /// "~/.hermes/state.db"` because the backend single-quoted the + /// path and sqlite3 doesn't expand `~` itself. Verify the + /// $HOME-rewrite path works against a real shell. + @Test func openWithDefaultTildeHomeExpands() async throws { + try requireSqlite3() + let dbURL = try makeFixtureStateDB() + let fixture = try makeDefaultHomeFixtureContext(dbURL: dbURL) + defer { + cleanupDefaultHomeFixture(fixture) + try? FileManager.default.removeItem(at: dbURL) + try? FileManager.default.removeItem(at: dbURL.deletingLastPathComponent()) + } + let backend = RemoteSQLiteBackend(context: fixture.context, transport: LocalSQLite3Transport()) + + let opened = await backend.open() + #expect(opened) + let err = await backend.lastOpenError + #expect(err == nil) + + // And actually run a query through the same expansion path. + let rows = try await backend.query("SELECT id FROM sessions", params: []) + #expect(rows.count == 1) + } + @Test func openProbesSchemaSuccessfully() async throws { try requireSqlite3() let dbURL = try makeFixtureStateDB()