diff --git a/scripts/check-kysely-guardrails.mjs b/scripts/check-kysely-guardrails.mjs index 4725ee899709..e231479ae249 100644 --- a/scripts/check-kysely-guardrails.mjs +++ b/scripts/check-kysely-guardrails.mjs @@ -62,7 +62,10 @@ const rawSqliteAllowPathGroups = { "src/snapshot/local-repository.ts", ], "agent auth profile read-only bootstrap": ["src/agents/auth-profiles/sqlite.ts"], - "read-only shared state database access": ["src/state/openclaw-state-db-readonly.ts"], + "read-only shared state database access": [ + "src/state/openclaw-agent-db-readonly.ts", + "src/state/openclaw-state-db-readonly.ts", + ], "read-only schema preflight and integrity verification access": [ "src/state/openclaw-database-preflight.ts", "src/state/openclaw-database-verify.worker.ts", diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 1c6992c62cd8..924f7b9616f8 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -181,6 +181,7 @@ describe("registry race safety", () => { await expect(readRegistry()).resolves.toEqual({ entries: [] }); await expect(readRegistryEntry("legacy-container")).resolves.toBeNull(); await expect(fs.access(SANDBOX_REGISTRY_PATH)).resolves.toBeUndefined(); + await expectPathMissing(path.join(TEST_STATE_DIR, "state", "openclaw.sqlite")); }); it("normalizes legacy registry entries after explicit migration", async () => { diff --git a/src/agents/sandbox/registry.ts b/src/agents/sandbox/registry.ts index 533662bcf096..eef2ffa7adb0 100644 --- a/src/agents/sandbox/registry.ts +++ b/src/agents/sandbox/registry.ts @@ -3,16 +3,17 @@ * * Tracks runtime and browser containers in the shared state DB plus migration support for legacy registries. */ +import fsSync from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import type { Insertable, Selectable, Updateable } from "kysely"; import { z } from "zod"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../../infra/kysely-sync.js"; +import { withOpenClawStateDatabaseReadOnly } from "../../state/openclaw-state-db-readonly.js"; +import { tableExists } from "../../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../../state/openclaw-state-db.generated.js"; -import { - openOpenClawStateDatabase, - runOpenClawStateWriteTransaction, -} from "../../state/openclaw-state-db.js"; +import { runOpenClawStateWriteTransaction } from "../../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { safeParseJsonWithSchema } from "../../utils/zod-parse.js"; import { acquireSessionWriteLock } from "../session-write-lock.js"; import { @@ -231,35 +232,51 @@ function rowToUpdate(row: SandboxRegistryInsert): SandboxRegistryUpdate { } function readRegistryRows(kind: SandboxRegistryKind): SandboxRegistryRow[] { - const { db } = openOpenClawStateDatabase(); - const stateDb = getSandboxRegistryKysely(db); - return executeSqliteQuerySync( - db, - stateDb - .selectFrom("sandbox_registry_entries") - .selectAll() - .where("registry_kind", "=", kind) - .orderBy("container_name", "asc"), - ).rows; + if (!fsSync.existsSync(resolveOpenClawStateSqlitePath(process.env))) { + return []; + } + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "sandbox_registry_entries")) { + return []; + } + const stateDb = getSandboxRegistryKysely(db); + return executeSqliteQuerySync( + db, + stateDb + .selectFrom("sandbox_registry_entries") + .selectAll() + .where("registry_kind", "=", kind) + .orderBy("container_name", "asc"), + ).rows; + }); } function readRegistryRow( kind: SandboxRegistryKind, containerName: string, ): SandboxRegistryRow | null { - const { db } = openOpenClawStateDatabase(); - const stateDb = getSandboxRegistryKysely(db); - return ( - executeSqliteQuerySync( - db, - stateDb - .selectFrom("sandbox_registry_entries") - .selectAll() - .where("registry_kind", "=", kind) - .where("container_name", "=", containerName) - .limit(1), - ).rows[0] ?? null - ); + if (!fsSync.existsSync(resolveOpenClawStateSqlitePath(process.env))) { + return null; + } + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "sandbox_registry_entries")) { + return null; + } + const stateDb = getSandboxRegistryKysely(db); + return ( + executeSqliteQuerySync( + db, + stateDb + .selectFrom("sandbox_registry_entries") + .selectAll() + .where("registry_kind", "=", kind) + .where("container_name", "=", containerName) + .limit(1), + ).rows[0] ?? null + ); + }); } function insertRegistryRowIfMissing(row: SandboxRegistryInsert): void { diff --git a/src/commands/export-trajectory.test.ts b/src/commands/export-trajectory.test.ts index 585f20ff06d1..ee48d90784a4 100644 --- a/src/commands/export-trajectory.test.ts +++ b/src/commands/export-trajectory.test.ts @@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({ exportTrajectoryForCommand: vi.fn(), formatTrajectoryCommandExportSummary: vi.fn(), getRuntimeConfig: vi.fn(), - loadSessionEntry: vi.fn(), + loadSessionEntryReadOnly: vi.fn(), resolveStorePath: vi.fn(), })); @@ -19,7 +19,7 @@ vi.mock("../config/sessions/session-accessor.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - loadSessionEntry: mocks.loadSessionEntry, + loadSessionEntryReadOnly: mocks.loadSessionEntryReadOnly, }; }); @@ -49,7 +49,7 @@ describe("exportTrajectoryCommand", () => { vi.clearAllMocks(); mocks.getRuntimeConfig.mockReturnValue({}); mocks.resolveStorePath.mockReturnValue("/tmp/openclaw/sessions.json"); - mocks.loadSessionEntry.mockReturnValue(undefined); + mocks.loadSessionEntryReadOnly.mockReturnValue(undefined); mocks.exportTrajectoryForCommand.mockResolvedValue({ outputDir: "/tmp/workspace/.openclaw/trajectory-exports/export", displayPath: ".openclaw/trajectory-exports/export", @@ -101,7 +101,7 @@ describe("exportTrajectoryCommand", () => { expect(runtime.error).toHaveBeenCalledWith( "Failed to decode trajectory export request: Encoded trajectory export request is invalid", ); - expect(mocks.loadSessionEntry).not.toHaveBeenCalled(); + expect(mocks.loadSessionEntryReadOnly).not.toHaveBeenCalled(); expect(runtime.exit).toHaveBeenCalledWith(1); }, ); @@ -127,7 +127,7 @@ describe("exportTrajectoryCommand", () => { expect(mocks.resolveStorePath).toHaveBeenCalledWith("/tmp/direct-store.json", { agentId: "main", }); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith({ + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ agentId: "main", sessionKey: "agent:main:telegram:direct:123", storePath: "/tmp/direct-store.json", @@ -158,7 +158,7 @@ describe("exportTrajectoryCommand", () => { expect(mocks.getRuntimeConfig).not.toHaveBeenCalled(); expect(mocks.resolveStorePath).toHaveBeenCalledWith(store, { agentId: "work" }); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith({ + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ agentId: "work", sessionKey: "agent:work:telegram:direct:123", storePath: resolvedStore, @@ -183,7 +183,7 @@ describe("exportTrajectoryCommand", () => { "/tmp/openclaw/agents/{agentId}/sessions/sessions.json", { agentId: "work" }, ); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith({ + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ agentId: "work", sessionKey: "agent:work:telegram:direct:123", storePath: "/tmp/openclaw/agents/work/sessions/sessions.json", @@ -200,7 +200,7 @@ describe("exportTrajectoryCommand", () => { await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime); expect(mocks.resolveStorePath).toHaveBeenCalledWith(undefined, { agentId: "main" }); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith({ + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ agentId: "main", sessionKey: "agent:main:telegram:direct:123", storePath: "/tmp/openclaw/sessions.json", @@ -218,7 +218,7 @@ describe("exportTrajectoryCommand", () => { await exportTrajectoryCommand({ sessionKey: "agent:main:telegram:direct:123" }, runtime); expect(mocks.resolveStorePath).toHaveBeenCalledWith("", { agentId: "main" }); - expect(mocks.loadSessionEntry).toHaveBeenCalledWith({ + expect(mocks.loadSessionEntryReadOnly).toHaveBeenCalledWith({ agentId: "main", sessionKey: "agent:main:telegram:direct:123", storePath: "/tmp/openclaw/sessions.json", @@ -232,7 +232,7 @@ describe("exportTrajectoryCommand", () => { it("exports SQLite marker sessions without probing a transcript JSONL file", async () => { const runtime = createRuntime(); const sessionFile = "sqlite:main:session-1:/tmp/openclaw/sessions.json"; - mocks.loadSessionEntry.mockReturnValue({ + mocks.loadSessionEntryReadOnly.mockReturnValue({ sessionId: "session-1", sessionFile, updatedAt: 1, diff --git a/src/commands/export-trajectory.ts b/src/commands/export-trajectory.ts index 5a48f0a71e5c..092c2b6e1ead 100644 --- a/src/commands/export-trajectory.ts +++ b/src/commands/export-trajectory.ts @@ -4,7 +4,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { getRuntimeConfig } from "../config/config.js"; import { resolveStorePath } from "../config/sessions/paths.js"; import { - loadSessionEntry, + loadSessionEntryReadOnly, resolveSessionTranscriptReadTarget, } from "../config/sessions/session-accessor.js"; import { parseSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js"; @@ -122,7 +122,8 @@ export async function exportTrajectoryCommand( const storePath = resolvedOpts.store ? resolveStorePath(resolvedOpts.store, { agentId: targetAgentId }) : resolveStorePath(getRuntimeConfig().session?.store, { agentId: targetAgentId }); - const entry = loadSessionEntry({ + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + const entry = loadSessionEntryReadOnly({ agentId: targetAgentId, sessionKey, storePath, diff --git a/src/commands/sandbox-explain.test.ts b/src/commands/sandbox-explain.test.ts index 52b300e369e1..cfde31705a8d 100644 --- a/src/commands/sandbox-explain.test.ts +++ b/src/commands/sandbox-explain.test.ts @@ -5,6 +5,8 @@ import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { replaceSessionEntry } from "../config/sessions/session-accessor.js"; import type { SessionEntry } from "../config/sessions/types.js"; +import { openOpenClawStateDatabase } from "../state/openclaw-state-db.js"; +import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { sandboxExplainCommand } from "./sandbox-explain.js"; const SANDBOX_EXPLAIN_TEST_TIMEOUT_MS = process.platform === "win32" ? 45_000 : 30_000; @@ -21,6 +23,34 @@ vi.mock("../config/config.js", async () => { }); describe("sandbox explain command", () => { + it("reads a missing session without creating or registering an agent database", async () => { + await withOpenClawTestState({ label: "sandbox-explain-readonly" }, async (state) => { + const agentDatabasePath = state.statePath( + "agents", + "readonly", + "agent", + "openclaw-agent.sqlite", + ); + mockCfg = { + agents: { + defaults: { sandbox: { mode: "off" } }, + list: [{ id: "readonly", workspace: state.workspaceDir }], + }, + session: { store: agentDatabasePath }, + }; + const stateDatabase = openOpenClawStateDatabase({ env: state.env }); + + await sandboxExplainCommand({ json: true, agent: "readonly" }, { + log: () => {}, + error: () => {}, + exit: () => {}, + } as unknown as Parameters[1]); + + expect(stateDatabase.db.prepare("SELECT agent_id FROM agent_databases").all()).toEqual([]); + await expect(fs.stat(agentDatabasePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + }); + it("prints JSON shape + fix-it keys", { timeout: SANDBOX_EXPLAIN_TEST_TIMEOUT_MS }, async () => { mockCfg = { agents: { diff --git a/src/commands/sandbox-explain.ts b/src/commands/sandbox-explain.ts index 2dcd92e38278..6a5380a888ec 100644 --- a/src/commands/sandbox-explain.ts +++ b/src/commands/sandbox-explain.ts @@ -30,7 +30,7 @@ import { resolveStorePath, type SessionEntry, } from "../config/sessions.js"; -import { loadSessionEntry } from "../config/sessions/session-accessor.js"; +import { loadSessionEntryReadOnly } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { buildAgentMainSessionKey, @@ -187,7 +187,8 @@ export async function sandboxExplainCommand( const storePath = resolveStorePath(cfg.session?.store, { agentId: resolvedAgentId, }); - const sessionEntry = loadSessionEntry({ + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + const sessionEntry = loadSessionEntryReadOnly({ agentId: resolvedAgentId, sessionKey, storePath, diff --git a/src/config/sessions/session-accessor.entry.ts b/src/config/sessions/session-accessor.entry.ts index b99258a4b122..7cf1e8d5f155 100644 --- a/src/config/sessions/session-accessor.entry.ts +++ b/src/config/sessions/session-accessor.entry.ts @@ -13,6 +13,7 @@ import { listSqliteSessionEntries, loadExactSqliteSessionEntry, loadSqliteSessionEntry, + loadSqliteSessionEntryReadOnly, patchSqliteSessionEntry, patchSqliteSessionEntryTarget, readSqliteSessionUpdatedAt, @@ -296,6 +297,11 @@ export function loadSessionEntry(scope: SessionAccessScope): SessionEntry | unde return loadSqliteSessionEntry(scope); } +/** Returns one session entry without joining the agent database writable lifecycle. */ +export function loadSessionEntryReadOnly(scope: SessionAccessScope): SessionEntry | undefined { + return loadSqliteSessionEntryReadOnly(scope); +} + /** * Returns only the row persisted under the exact key provided. * Use this for authorization-sensitive routing where alias canonicalization diff --git a/src/config/sessions/session-accessor.sqlite-entry-store.ts b/src/config/sessions/session-accessor.sqlite-entry-store.ts index e45ad928c74c..ba80bd185682 100644 --- a/src/config/sessions/session-accessor.sqlite-entry-store.ts +++ b/src/config/sessions/session-accessor.sqlite-entry-store.ts @@ -39,6 +39,8 @@ import type { SessionEntry } from "./types.js"; // Canonical owner for session_entries row selection, alias snapshots, and writes. +type OpenClawAgentDatabaseReader = Pick; + type SessionEntryRow = Selectable; export type ResolvedSessionEntryRow = { entry: SessionEntry; @@ -82,7 +84,7 @@ export function createSqliteSessionIdentitySnapshot( } export function readSessionEntryRow( - database: OpenClawAgentDatabase, + database: OpenClawAgentDatabaseReader, sessionKey: string, ): ResolvedSessionEntryRow | undefined { const db = getSessionKysely(database.db); @@ -157,7 +159,7 @@ export function assertSqliteSessionEntrySelectionUnchanged( } export function collectSessionEntryLookupKeys( - database: OpenClawAgentDatabase, + database: OpenClawAgentDatabaseReader, sessionKey: string, ): string[] { const trimmedKey = sessionKey.trim(); @@ -184,7 +186,7 @@ export function collectSessionEntryLookupKeys( } export function readExactSessionEntryRow( - database: OpenClawAgentDatabase, + database: OpenClawAgentDatabaseReader, sessionKey: string, ): ResolvedSessionEntryRow | undefined { const db = getSessionKysely(database.db); diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index 71e01a1f7d73..d0c8edfca577 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -3,6 +3,7 @@ import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, } from "../../infra/kysely-sync.js"; +import { withOpenClawAgentDatabaseReadOnly } from "../../state/openclaw-agent-db-readonly.js"; import { openOpenClawAgentDatabase, resolveOpenClawAgentSqlitePath, @@ -78,6 +79,18 @@ export function loadSqliteSessionEntry(scope: SessionAccessScope): SessionEntry return readSessionEntryRow(database, resolved.sessionKey)?.entry; } +/** Loads one session entry without opening its agent database writable. */ +export function loadSqliteSessionEntryReadOnly( + scope: SessionAccessScope, +): SessionEntry | undefined { + const resolved = resolveSqliteScope(scope); + const result = withOpenClawAgentDatabaseReadOnly( + (database) => readSessionEntryRow(database, resolved.sessionKey)?.entry, + toDatabaseOptions(resolved), + ); + return result.found ? result.value : undefined; +} + /** Loads one exact persisted-key entry from the additive SQLite session store. */ export function loadExactSqliteSessionEntry( scope: SessionAccessScope, diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index dc9b1634013d..96756e01d0cd 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -5,6 +5,7 @@ export { listSqliteSessionTranscriptInstances, loadExactSqliteSessionEntry, loadSqliteSessionEntry, + loadSqliteSessionEntryReadOnly, patchSqliteSessionEntry, patchSqliteSessionEntryTarget, readSqliteSessionUpdatedAt, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 2ee49f499dae..5bfcff857801 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -114,6 +114,7 @@ export { listSessionEntries, loadExactSessionEntry, loadSessionEntry, + loadSessionEntryReadOnly, openSessionEntryReadView, patchSessionEntry, patchSessionEntryTarget, diff --git a/src/fleet/registry.test.ts b/src/fleet/registry.test.ts index c9f7e84f491c..b11076ba5b09 100644 --- a/src/fleet/registry.test.ts +++ b/src/fleet/registry.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; @@ -45,6 +46,17 @@ describe("fleet cell registry", () => { }; } + it("returns empty reads without creating state on a fresh install", () => { + if (!root) { + throw new Error("test root not initialized"); + } + const databasePath = path.join(root, "state", "openclaw.sqlite"); + + expect(listFleetCells(env)).toEqual([]); + expect(getFleetCell(env, "missing")).toBeUndefined(); + expect(fs.existsSync(databasePath)).toBe(false); + }); + it("persists, orders, updates, and deletes cells", () => { const zulu = reserveFleetCell(env, { ...params("zulu", 19_250), diff --git a/src/fleet/registry.ts b/src/fleet/registry.ts index f1ca8fcd446d..064f55248022 100644 --- a/src/fleet/registry.ts +++ b/src/fleet/registry.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import fs from "node:fs"; import type { DatabaseSync } from "node:sqlite"; import type { Insertable, Selectable } from "kysely"; import { @@ -6,11 +7,11 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; +import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; -import { - openOpenClawStateDatabase, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import { allocateHostPort } from "./cell-profile.js"; export type FleetCellRecord = { @@ -86,24 +87,46 @@ function recordToRow(record: FleetCellRecord): Insertable { } export function listFleetCells(env: NodeJS.ProcessEnv = process.env): FleetCellRecord[] { - const db = openOpenClawStateDatabase({ env }).db; - const rows = executeSqliteQuerySync( - db, - kyselyFor(db).selectFrom("fleet_cells").selectAll().orderBy("tenant_id", "asc"), - ).rows; - return rows.map(rowToRecord); + if (!fs.existsSync(resolveOpenClawStateSqlitePath(env))) { + return []; + } + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly( + ({ db }) => { + if (!tableExists(db, "fleet_cells")) { + return []; + } + const rows = executeSqliteQuerySync( + db, + kyselyFor(db).selectFrom("fleet_cells").selectAll().orderBy("tenant_id", "asc"), + ).rows; + return rows.map(rowToRecord); + }, + { env }, + ); } export function getFleetCell( env: NodeJS.ProcessEnv, tenantId: string, ): FleetCellRecord | undefined { - const db = openOpenClawStateDatabase({ env }).db; - const row = executeSqliteQueryTakeFirstSync( - db, - kyselyFor(db).selectFrom("fleet_cells").selectAll().where("tenant_id", "=", tenantId), + if (!fs.existsSync(resolveOpenClawStateSqlitePath(env))) { + return undefined; + } + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly( + ({ db }) => { + if (!tableExists(db, "fleet_cells")) { + return undefined; + } + const row = executeSqliteQueryTakeFirstSync( + db, + kyselyFor(db).selectFrom("fleet_cells").selectAll().where("tenant_id", "=", tenantId), + ); + return row ? rowToRecord(row) : undefined; + }, + { env }, ); - return row ? rowToRecord(row) : undefined; } export function reserveFleetCell( diff --git a/src/state/onboarding-recommendations.test.ts b/src/state/onboarding-recommendations.test.ts index 3b4974eac64e..7952b2e13a8d 100644 --- a/src/state/onboarding-recommendations.test.ts +++ b/src/state/onboarding-recommendations.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import { afterEach, describe, expect, it } from "vitest"; import { withOpenClawTestState } from "../test-utils/openclaw-test-state.js"; import { @@ -35,6 +36,7 @@ describe("onboarding recommendations store", () => { const inventory = [{ label: "Chat", bundleId: "com.example.chat" }]; expect(readOnboardingRecommendations(database)).toBeNull(); + expect(fs.existsSync(state.statePath("state", "openclaw.sqlite"))).toBe(false); const written = writeOnboardingRecommendationsOffer({ inventory, matches, diff --git a/src/state/onboarding-recommendations.ts b/src/state/onboarding-recommendations.ts index 18e27ea2ef6a..3b32f25eb944 100644 --- a/src/state/onboarding-recommendations.ts +++ b/src/state/onboarding-recommendations.ts @@ -6,9 +6,10 @@ import { executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; +import { withOpenClawStateDatabaseReadOnly } from "./openclaw-state-db-readonly.js"; +import { tableExists } from "./openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { - openOpenClawStateDatabase, runOpenClawStateWriteTransaction, type OpenClawStateDatabaseOptions, } from "./openclaw-state-db.js"; @@ -80,31 +81,36 @@ export function readOnboardingRecommendations( if (!existsSync(pathname)) { return null; } - const database = openOpenClawStateDatabase(options); - const db = getNodeSqliteKysely(database.db); - const row = executeSqliteQueryTakeFirstSync( - database.db, - db - .selectFrom("onboarding_recommendations") - .select([ - "inventory_hash", - "matches_json", - "offered_at_ms", - "accepted_at_ms", - "updated_at_ms", - ]) - .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), - ); - if (!row) { - return null; - } - return { - inventoryHash: row.inventory_hash, - matches: OnboardingRecommendationMatchesSchema.parse(JSON.parse(row.matches_json)), - offeredAt: row.offered_at_ms, - acceptedAt: row.accepted_at_ms, - updatedAt: row.updated_at_ms, - }; + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly(({ db: database }) => { + if (!tableExists(database, "onboarding_recommendations")) { + return null; + } + const db = getNodeSqliteKysely(database); + const row = executeSqliteQueryTakeFirstSync( + database, + db + .selectFrom("onboarding_recommendations") + .select([ + "inventory_hash", + "matches_json", + "offered_at_ms", + "accepted_at_ms", + "updated_at_ms", + ]) + .where("config_key", "=", ONBOARDING_RECOMMENDATIONS_KEY), + ); + if (!row) { + return null; + } + return { + inventoryHash: row.inventory_hash, + matches: OnboardingRecommendationMatchesSchema.parse(JSON.parse(row.matches_json)), + offeredAt: row.offered_at_ms, + acceptedAt: row.accepted_at_ms, + updatedAt: row.updated_at_ms, + }; + }, options); } export function writeOnboardingRecommendationsOffer(params: { diff --git a/src/state/openclaw-agent-db-readonly.ts b/src/state/openclaw-agent-db-readonly.ts new file mode 100644 index 000000000000..b88d73017b08 --- /dev/null +++ b/src/state/openclaw-agent-db-readonly.ts @@ -0,0 +1,65 @@ +import fs from "node:fs"; +import type { DatabaseSync } from "node:sqlite"; +import { clearNodeSqliteKyselyCacheForDatabase } from "../infra/kysely-sync.js"; +import { requireNodeSqlite } from "../infra/node-sqlite.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { OpenClawAgentDatabaseOptions } from "./openclaw-agent-db-contract.js"; +import { + assertExistingAgentSchemaOwner, + assertSupportedAgentSchemaVersion, + readExistingAgentSchemaMeta, +} from "./openclaw-agent-db-schema-helpers.js"; +import { resolveOpenClawAgentSqlitePath } from "./openclaw-agent-db.paths.js"; +import { OPENCLAW_SQLITE_BUSY_TIMEOUT_MS } from "./openclaw-state-db.js"; + +type OpenClawAgentReadOnlyDatabase = { + agentId: string; + db: DatabaseSync; + path: string; +}; + +type OpenClawAgentDatabaseReadOnlyResult = + | { found: true; value: T } + | { found: false; reason: "database-missing" | "schema-missing" | "table-missing" }; + +function isMissingTableError(error: unknown): boolean { + return ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === "ERR_SQLITE_ERROR" && + /\bno such table:/iu.test(error.message) + ); +} + +/** Read agent state without creating, registering, migrating, or joining its writable lifecycle. */ +export function withOpenClawAgentDatabaseReadOnly( + operation: (database: OpenClawAgentReadOnlyDatabase) => T, + options: OpenClawAgentDatabaseOptions, +): OpenClawAgentDatabaseReadOnlyResult { + const agentId = normalizeAgentId(options.agentId); + const pathname = resolveOpenClawAgentSqlitePath({ ...options, agentId }); + if (!fs.existsSync(pathname)) { + return { found: false, reason: "database-missing" }; + } + const sqlite = requireNodeSqlite(); + const db = new sqlite.DatabaseSync(pathname, { readOnly: true }); + try { + db.exec(`PRAGMA busy_timeout = ${OPENCLAW_SQLITE_BUSY_TIMEOUT_MS};`); + assertSupportedAgentSchemaVersion(db, pathname); + const schemaMeta = readExistingAgentSchemaMeta(db); + if (!schemaMeta) { + return { found: false, reason: "schema-missing" }; + } + assertExistingAgentSchemaOwner(schemaMeta, agentId, pathname); + try { + return { found: true, value: operation({ agentId, db, path: pathname }) }; + } catch (error) { + if (isMissingTableError(error)) { + return { found: false, reason: "table-missing" }; + } + throw error; + } + } finally { + clearNodeSqliteKyselyCacheForDatabase(db); + db.close(); + } +} diff --git a/src/state/openclaw-agent-db.test.ts b/src/state/openclaw-agent-db.test.ts index ea6c81156a9e..ca934e3bb2e1 100644 --- a/src/state/openclaw-agent-db.test.ts +++ b/src/state/openclaw-agent-db.test.ts @@ -14,6 +14,7 @@ import { requireNodeSqlite } from "../infra/node-sqlite.js"; import { listOpenFileDescriptorsForPath } from "../infra/open-file-descriptors.test-support.js"; import { readSqliteNumberPragma } from "../infra/sqlite-pragma.test-support.js"; import { VERSION } from "../version.js"; +import { withOpenClawAgentDatabaseReadOnly } from "./openclaw-agent-db-readonly.js"; import type { DB as OpenClawAgentKyselyDatabase } from "./openclaw-agent-db.generated.js"; import { assertOpenClawAgentDatabaseForMaintenance, @@ -434,6 +435,60 @@ describe("openclaw agent database", () => { ); }); + it("returns typed not-found without creating a missing read-only database", () => { + const stateDir = createTempStateDir(); + const options = { + agentId: "worker-1", + env: { OPENCLAW_STATE_DIR: stateDir }, + }; + const databasePath = resolveOpenClawAgentSqlitePath(options); + + expect(withOpenClawAgentDatabaseReadOnly(() => "unused", options)).toEqual({ + found: false, + reason: "database-missing", + }); + expect(fs.existsSync(databasePath)).toBe(false); + }); + + it("refuses a newer schema from the read-only database helper", () => { + const stateDir = createTempStateDir(); + const options = { + agentId: "worker-1", + env: { OPENCLAW_STATE_DIR: stateDir }, + }; + const databasePath = resolveOpenClawAgentSqlitePath(options); + fs.mkdirSync(path.dirname(databasePath), { recursive: true }); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + database.exec(`PRAGMA user_version = ${OPENCLAW_AGENT_SCHEMA_VERSION + 1};`); + database.close(); + + expect(() => withOpenClawAgentDatabaseReadOnly(() => "unused", options)).toThrow( + `newer schema version ${OPENCLAW_AGENT_SCHEMA_VERSION + 1}`, + ); + }); + + it("returns typed not-found when a read-only query targets a missing table", () => { + const stateDir = createTempStateDir(); + const options = { + agentId: "worker-1", + env: { OPENCLAW_STATE_DIR: stateDir }, + }; + const databasePath = openOpenClawAgentDatabase(options).path; + closeOpenClawAgentDatabasesForTest(); + const { DatabaseSync } = requireNodeSqlite(); + const database = new DatabaseSync(databasePath); + database.exec("DROP TABLE session_entries;"); + database.close(); + + expect( + withOpenClawAgentDatabaseReadOnly( + ({ db }) => db.prepare("SELECT * FROM session_entries").all(), + options, + ), + ).toEqual({ found: false, reason: "table-missing" }); + }); + it("lists a missing registry without creating the shared state database", () => { const stateDir = createTempStateDir(); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/tui/tui-last-session.test.ts b/src/tui/tui-last-session.test.ts index 404c4174704d..b332da6fcb1c 100644 --- a/src/tui/tui-last-session.test.ts +++ b/src/tui/tui-last-session.test.ts @@ -26,6 +26,15 @@ afterEach(async () => { }); describe("tui last session state", () => { + it("returns no remembered session without creating state on a fresh install", async () => { + const stateDir = await makeTempStateDir(); + + await expect(readTuiLastSessionKey({ scopeKey: "missing", stateDir })).resolves.toBeNull(); + await expect(fs.stat(path.join(stateDir, "state", "openclaw.sqlite"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("persists the last session under a scoped hashed key", async () => { const stateDir = await makeTempStateDir(); const scopeKey = buildTuiLastSessionScopeKey({ diff --git a/src/tui/tui-last-session.ts b/src/tui/tui-last-session.ts index 406dd270ea11..1a0683451341 100644 --- a/src/tui/tui-last-session.ts +++ b/src/tui/tui-last-session.ts @@ -1,16 +1,17 @@ // Stores and resolves the last TUI session per workspace. import { createHash } from "node:crypto"; +import fs from "node:fs"; import { executeSqliteQuerySync, executeSqliteQueryTakeFirstSync, getNodeSqliteKysely, } from "../infra/kysely-sync.js"; import { normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js"; +import { withOpenClawStateDatabaseReadOnly } from "../state/openclaw-state-db-readonly.js"; +import { tableExists } from "../state/openclaw-state-db-schema-helpers.js"; import type { DB as OpenClawStateKyselyDatabase } from "../state/openclaw-state-db.generated.js"; -import { - openOpenClawStateDatabase, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; +import { runOpenClawStateWriteTransaction } from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; import type { TuiSessionList } from "./tui-backend.js"; import type { SessionScope } from "./tui-types.js"; @@ -66,16 +67,25 @@ export async function readTuiLastSessionKey(params: { scopeKey: string; stateDir?: string; }): Promise { - const database = openOpenClawStateDatabase(stateDatabaseOptions(params.stateDir)); - const row = executeSqliteQueryTakeFirstSync( - database.db, - getNodeSqliteKysely(database.db) - .selectFrom("tui_last_sessions") - .select("session_key") - .where("scope_key", "=", params.scopeKey), - ); - const sessionKey = row?.session_key.trim() ?? ""; - return sessionKey && !isHeartbeatSessionKey(sessionKey) ? sessionKey : null; + const options = stateDatabaseOptions(params.stateDir); + if (!fs.existsSync(resolveOpenClawStateSqlitePath(options.env))) { + return null; + } + // CLI reads must not join the Gateway's writable SQLite lifecycle (#101290). + return withOpenClawStateDatabaseReadOnly(({ db }) => { + if (!tableExists(db, "tui_last_sessions")) { + return null; + } + const row = executeSqliteQueryTakeFirstSync( + db, + getNodeSqliteKysely(db) + .selectFrom("tui_last_sessions") + .select("session_key") + .where("scope_key", "=", params.scopeKey), + ); + const sessionKey = row?.session_key.trim() ?? ""; + return sessionKey && !isHeartbeatSessionKey(sessionKey) ? sessionKey : null; + }, options); } /** Writes the remembered session key unless it is empty, unknown, or heartbeat-owned. */