fix(remote-backend): expand ~/ to $HOME so sqlite3 finds the DB

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-05-04 13:34:20 +02:00
parent 593b4e62cb
commit b8b426ed75
2 changed files with 109 additions and 9 deletions
@@ -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
@@ -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()