mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-08 02:14:37 +00:00
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:
+40
-9
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user