diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index b522b3c29eac..049a37d11f1e 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({ agentsBindingsCommandMock: vi.fn(), agentsBindCommandMock: vi.fn(), agentsDeleteCommandMock: vi.fn(), + agentsDatabaseRehearsalCommandMock: vi.fn(), agentsListCommandMock: vi.fn(), agentsSetIdentityCommandMock: vi.fn(), agentsUnbindCommandMock: vi.fn(), @@ -28,6 +29,7 @@ const agentsAddCommandMock = mocks.agentsAddCommandMock; const agentsBindingsCommandMock = mocks.agentsBindingsCommandMock; const agentsBindCommandMock = mocks.agentsBindCommandMock; const agentsDeleteCommandMock = mocks.agentsDeleteCommandMock; +const agentsDatabaseRehearsalCommandMock = mocks.agentsDatabaseRehearsalCommandMock; const agentsListCommandMock = mocks.agentsListCommandMock; const agentsSetIdentityCommandMock = mocks.agentsSetIdentityCommandMock; const agentsUnbindCommandMock = mocks.agentsUnbindCommandMock; @@ -56,6 +58,10 @@ vi.mock("../../commands/agents.commands.delete.js", () => ({ agentsDeleteCommand: mocks.agentsDeleteCommandMock, })); +vi.mock("../../commands/agents.db-rehearsal.js", () => ({ + agentsDatabaseRehearsalCommand: mocks.agentsDatabaseRehearsalCommandMock, +})); + vi.mock("../../commands/agents.commands.identity.js", () => ({ agentsSetIdentityCommand: mocks.agentsSetIdentityCommandMock, })); @@ -89,6 +95,7 @@ describe("agent command registration", () => { agentsBindingsCommandMock.mockResolvedValue(undefined); agentsBindCommandMock.mockResolvedValue(undefined); agentsDeleteCommandMock.mockResolvedValue(undefined); + agentsDatabaseRehearsalCommandMock.mockResolvedValue(undefined); agentsListCommandMock.mockResolvedValue(undefined); agentsSetIdentityCommandMock.mockResolvedValue(undefined); agentsUnbindCommandMock.mockResolvedValue(undefined); @@ -378,6 +385,22 @@ describe("agent command registration", () => { expect(callRuntime).toBe(runtime); }); + it("keeps the operator database rehearsal hidden and forwards its request source", async () => { + const program = new Command(); + registerAgentsCommands(program); + const agents = program.commands.find((command) => command.name() === "agents"); + const rehearsal = agents?.commands.find((command) => command.name() === "db-rehearsal"); + expect(rehearsal).toBeDefined(); + expect(agents?.helpInformation()).not.toContain("db-rehearsal"); + + await runCli(["agents", "db-rehearsal", "--request", "rehearsal.json"]); + + expect(agentsDatabaseRehearsalCommandMock).toHaveBeenCalledWith( + { request: "rehearsal.json" }, + runtime, + ); + }); + it("forwards set-identity options", async () => { await runCli([ "agents", diff --git a/src/cli/program/register.agent.ts b/src/cli/program/register.agent.ts index 74665fe620db..a8416a506454 100644 --- a/src/cli/program/register.agent.ts +++ b/src/cli/program/register.agent.ts @@ -10,6 +10,7 @@ import { collectOption } from "./helpers.js"; type AgentsAddModule = typeof import("../../commands/agents.commands.add.js"); type AgentsBindModule = typeof import("../../commands/agents.commands.bind.js"); type AgentsDeleteModule = typeof import("../../commands/agents.commands.delete.js"); +type AgentsDatabaseRehearsalModule = typeof import("../../commands/agents.db-rehearsal.js"); type AgentsIdentityModule = typeof import("../../commands/agents.commands.identity.js"); type AgentsListModule = typeof import("../../commands/agents.commands.list.js"); type CliUtilsModule = typeof import("../cli-utils.js"); @@ -39,6 +40,12 @@ async function loadAgentsDeleteCommand(): Promise { + return (await import("../../commands/agents.db-rehearsal.js")).agentsDatabaseRehearsalCommand; +} + async function loadAgentsSetIdentityCommand(): Promise< AgentsIdentityModule["agentsSetIdentityCommand"] > { @@ -266,6 +273,15 @@ ${formatHelpExamples([ }); }); + agents + .command("db-rehearsal", { hidden: true }) + .requiredOption("--request ", "Read the bounded rehearsal JSON request") + .action(async (opts): Promise => { + const { defaultRuntime } = await loadAgentsActionRuntime(); + const agentsDatabaseRehearsalCommand = await loadAgentsDatabaseRehearsalCommand(); + await agentsDatabaseRehearsalCommand({ request: String(opts.request) }, defaultRuntime); + }); + agents.action(async (): Promise => { await runAgentsCommandAction(async (runtime) => { const agentsListCommand = await loadAgentsListCommand(); diff --git a/src/commands/agents.db-rehearsal.test.ts b/src/commands/agents.db-rehearsal.test.ts new file mode 100644 index 000000000000..0d6935a18365 --- /dev/null +++ b/src/commands/agents.db-rehearsal.test.ts @@ -0,0 +1,318 @@ +// Agent DB rehearsal tests cover isolated inventory, migration, and compatibility probes. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { listOpenClawRegisteredAgentDatabases } from "../state/openclaw-agent-db-registry.js"; +import { + closeOpenClawAgentDatabaseByPath, + closeOpenClawAgentDatabasesForTest, + OPENCLAW_AGENT_SCHEMA_VERSION, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; +import { + closeOpenClawStateDatabaseForTest, + OPENCLAW_STATE_SCHEMA_VERSION, +} from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { createSuiteTempRootTracker } from "../test-helpers/temp-dir.js"; +import { runAgentDatabaseRehearsal } from "./agents.db-rehearsal.js"; + +type RehearsalSuccess = Awaited>; + +describe("agents database rehearsal", () => { + const tempRoots = createSuiteTempRootTracker({ prefix: "openclaw-agent-db-rehearsal-" }); + + beforeAll(async () => { + await tempRoots.setup(); + }); + + afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + }); + + afterAll(async () => { + closeOpenClawAgentDatabasesForTest(); + closeOpenClawStateDatabaseForTest(); + await tempRoots.cleanup(); + }); + + async function makeRoot(): Promise { + return await tempRoots.make(); + } + + function createAgentDatabase(params: { + root: string; + agentId: string; + relativePath: string; + }): string { + const env = { OPENCLAW_STATE_DIR: params.root }; + const pathname = path.join(params.root, params.relativePath); + fs.mkdirSync(path.dirname(pathname), { recursive: true }); + const database = openOpenClawAgentDatabase({ + agentId: params.agentId, + env, + path: pathname, + }); + database.db.exec("PRAGMA wal_checkpoint(TRUNCATE);"); + closeOpenClawAgentDatabaseByPath(pathname); + return pathname; + } + + function setAgentSchemaVersion(pathname: string, version: number): void { + const database = new DatabaseSync(pathname); + try { + database.exec(`PRAGMA user_version = ${version};`); + database + .prepare("UPDATE schema_meta SET schema_version = ? WHERE meta_key = 'primary'") + .run(version); + } finally { + database.close(); + } + } + + function readAgentSchemaVersion(pathname: string): number { + const database = new DatabaseSync(pathname, { readOnly: true }); + try { + return (database.prepare("PRAGMA user_version").get() as { user_version: number }) + .user_version; + } finally { + database.close(); + } + } + + it("inventories effective roster, agent-dir, and fixed session-store claims without a database open", async () => { + const stateRoot = await makeRoot(); + const configuredAgentDir = path.join(stateRoot, "custom-auth", "main"); + const fixedStore = path.join(stateRoot, "fixed-sessions", "sessions.json"); + const configPath = path.join(stateRoot, "openclaw.json"); + await fsp.writeFile( + configPath, + JSON.stringify({ + agents: { + entries: { + main: { default: true, agentDir: configuredAgentDir }, + ops: {}, + }, + }, + session: { store: fixedStore }, + }), + ); + + const result = (await runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "inventory", + stateRoot, + configPath, + })) as Extract; + + expect(result.references).toEqual( + expect.arrayContaining([ + { + agentId: "main", + path: path.join(configuredAgentDir, "openclaw-agent.sqlite"), + claimKind: "agent-dir-database", + ownerClaim: "configured-agent-dir", + }, + { + agentId: "ops", + path: path.join(stateRoot, "agents", "ops", "agent", "openclaw-agent.sqlite"), + claimKind: "agent-dir-database", + ownerClaim: "default-agent-dir", + }, + { + agentId: "main", + path: path.join(stateRoot, "fixed-sessions", "openclaw-agent.sqlite"), + claimKind: "session-store-fixed-family", + ownerClaim: "configured-session-store", + }, + { + agentId: "ops", + path: path.join(stateRoot, "fixed-sessions", "openclaw-agent.sqlite"), + claimKind: "session-store-fixed-family", + ownerClaim: "configured-session-store", + }, + ]), + ); + expect(result.pluginPersistence).toEqual([ + expect.objectContaining({ pluginId: "*", kind: "indeterminate", copiedPath: null }), + ]); + expect(result.complete).toBe(false); + expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: stateRoot }))).toBe( + false, + ); + }); + + it("reports plugin inventory complete only when plugin loading is explicitly disabled", async () => { + const stateRoot = await makeRoot(); + const configPath = path.join(stateRoot, "openclaw.json"); + await fsp.writeFile(configPath, JSON.stringify({ plugins: { enabled: false } })); + + const result = (await runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "inventory", + stateRoot, + configPath, + })) as Extract; + + expect(result.pluginPersistence).toEqual([]); + expect(result.complete).toBe(true); + }); + + it("rejects unsupported plugin persistence before validating or opening any database", async () => { + const root = await makeRoot(); + await expect( + runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "migrate", + privateStateRoot: root, + agents: [{ agentId: "main", copiedPath: path.join(root, "missing.sqlite") }], + pluginPersistence: [ + { pluginId: "custom-store", kind: "sqlite", copiedPath: "/outside/plugin.sqlite" }, + ], + }), + ).rejects.toMatchObject({ code: "unsupported-plugin-persistence" }); + expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: root }))).toBe(false); + }); + + it("migrates every explicit composite row and replaces stale private registry rows", async () => { + const root = await makeRoot(); + const first = createAgentDatabase({ + root, + agentId: "main", + relativePath: "copies/first/openclaw-agent.sqlite", + }); + const second = createAgentDatabase({ + root, + agentId: "main", + relativePath: "copies/second/openclaw-agent.sqlite", + }); + createAgentDatabase({ + root, + agentId: "stale", + relativePath: "copies/stale/openclaw-agent.sqlite", + }); + closeOpenClawStateDatabaseForTest(); + setAgentSchemaVersion(first, OPENCLAW_AGENT_SCHEMA_VERSION - 1); + setAgentSchemaVersion(second, OPENCLAW_AGENT_SCHEMA_VERSION - 1); + + const result = (await runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "migrate", + privateStateRoot: root, + agents: [ + { agentId: "main", copiedPath: first }, + { agentId: "main", copiedPath: second }, + ], + pluginPersistence: [], + })) as Extract; + + expect(result.sharedState).toMatchObject({ + schemaVersionAfter: OPENCLAW_STATE_SCHEMA_VERSION, + role: "global", + readOnly: false, + }); + expect(result.agents).toHaveLength(2); + expect(result.agents).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + agentId: "main", + realPath: fs.realpathSync.native(first), + before: expect.objectContaining({ userVersion: OPENCLAW_AGENT_SCHEMA_VERSION - 1 }), + after: expect.objectContaining({ userVersion: OPENCLAW_AGENT_SCHEMA_VERSION }), + migrated: true, + }), + expect.objectContaining({ + agentId: "main", + realPath: fs.realpathSync.native(second), + migrated: true, + }), + ]), + ); + const registry = listOpenClawRegisteredAgentDatabases({ + env: { OPENCLAW_STATE_DIR: root }, + includeIncompatibleSchemaVersions: true, + }); + expect(registry.map(({ agentId, path: pathname }) => ({ agentId, path: pathname }))).toEqual([ + { agentId: "main", path: fs.realpathSync.native(first) }, + { agentId: "main", path: fs.realpathSync.native(second) }, + ]); + }); + + it("read-only compatibility verifies exact registry without migrating files", async () => { + const root = await makeRoot(); + const databasePath = createAgentDatabase({ + root, + agentId: "main", + relativePath: "copies/current/openclaw-agent.sqlite", + }); + closeOpenClawStateDatabaseForTest(); + const beforeVersion = readAgentSchemaVersion(databasePath); + const beforeMtime = fs.statSync(databasePath).mtimeMs; + + const result = (await runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "read-only", + privateStateRoot: root, + agents: [{ agentId: "main", copiedPath: databasePath }], + pluginPersistence: [], + })) as Extract; + + expect(result.sharedState.readOnly).toBe(true); + expect(result.agents[0]).toMatchObject({ + migrated: false, + before: { userVersion: beforeVersion }, + after: { userVersion: beforeVersion }, + }); + expect(readAgentSchemaVersion(databasePath)).toBe(beforeVersion); + expect(fs.statSync(databasePath).mtimeMs).toBe(beforeMtime); + }); + + it("rejects a hardlinked clone before writable shared state opens", async () => { + const outside = await makeRoot(); + const root = await makeRoot(); + const source = createAgentDatabase({ + root: outside, + agentId: "main", + relativePath: "source/openclaw-agent.sqlite", + }); + closeOpenClawStateDatabaseForTest(); + const alias = path.join(root, "copy", "openclaw-agent.sqlite"); + await fsp.mkdir(path.dirname(alias), { recursive: true }); + await fsp.link(source, alias); + + await expect( + runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "migrate", + privateStateRoot: root, + agents: [{ agentId: "main", copiedPath: alias }], + pluginPersistence: [], + }), + ).rejects.toMatchObject({ code: "path-not-private" }); + expect(fs.existsSync(resolveOpenClawStateSqlitePath({ OPENCLAW_STATE_DIR: root }))).toBe(false); + }); + + it("rejects copied paths that escape the private root", async () => { + const outside = await makeRoot(); + const root = await makeRoot(); + const databasePath = createAgentDatabase({ + root: outside, + agentId: "main", + relativePath: "outside/openclaw-agent.sqlite", + }); + closeOpenClawStateDatabaseForTest(); + + await expect( + runAgentDatabaseRehearsal({ + schemaVersion: 1, + mode: "read-only", + privateStateRoot: root, + agents: [{ agentId: "main", copiedPath: databasePath }], + pluginPersistence: [], + }), + ).rejects.toMatchObject({ code: "path-escape" }); + }); +}); diff --git a/src/commands/agents.db-rehearsal.ts b/src/commands/agents.db-rehearsal.ts new file mode 100644 index 000000000000..9f9dbcdf217e --- /dev/null +++ b/src/commands/agents.db-rehearsal.ts @@ -0,0 +1,688 @@ +// Hidden operator contract for isolated agent-database upgrade rehearsals. +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { readByteStreamWithLimit } from "@openclaw/media-core/read-byte-stream-with-limit"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { listAgentIds, resolveAgentConfig, resolveAgentDir } from "../agents/agent-scope.js"; +import { createConfigIO } from "../config/io.js"; +import { resolveStateDir } from "../config/paths.js"; +import { resolveStorePath } from "../config/sessions/paths.js"; +import { resolveUnsuffixedSqliteTargetFromSessionStorePath } from "../config/sessions/session-sqlite-target.js"; +import { + isPerAgentSessionStoreConfig, + listConfiguredSessionStoreAgentIds, +} from "../config/sessions/targets.js"; +import { readFileDescriptorBounded } from "../infra/boundary-file-read.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; +import { isPathInside } from "../infra/path-guards.js"; +import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; +import { readSqliteUserVersion } from "../infra/sqlite-user-version.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { writeRuntimeJson } from "../runtime.js"; +import { findOpenClawAgentDatabaseMediaMigrationRequiredError } from "../state/openclaw-agent-db-migration-required.js"; +import { withOpenClawAgentDatabaseReadOnly } from "../state/openclaw-agent-db-readonly.js"; +import { + listOpenClawRegisteredAgentDatabases, + replaceOpenClawAgentDatabaseRegistryForRehearsal, +} from "../state/openclaw-agent-db-registry.js"; +import { readExistingAgentSchemaMeta } from "../state/openclaw-agent-db-schema-helpers.js"; +import { + closeOpenClawAgentDatabaseByPath, + migrateOpenClawAgentDatabaseForMaintenance, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; +import { + closeOpenClawStateDatabaseByPath, + openExistingOpenClawStateDatabaseReadOnly, + openOpenClawStateDatabase, +} from "../state/openclaw-state-db.js"; +import { resolveOpenClawStateSqlitePath } from "../state/openclaw-state-db.paths.js"; +import { VERSION } from "../version.js"; + +const REHEARSAL_SCHEMA_VERSION = 1; +const REHEARSAL_REQUEST_MAX_BYTES = 256 * 1024; +const REHEARSAL_MAX_AGENT_DATABASES = 256; +const REHEARSAL_MAX_INVENTORY_REFERENCES = 256; + +type RehearsalMode = "inventory" | "migrate" | "read-only"; + +type PluginPersistenceDeclaration = { + pluginId: string; + kind: string; + copiedPath: string | null; + reason?: string; +}; + +type AgentDatabaseRequest = { + agentId: string; + copiedPath: string; +}; + +type InventoryRequest = { + schemaVersion: 1; + mode: "inventory"; + stateRoot: string; + configPath: string; +}; + +type RehearsalRequest = { + schemaVersion: 1; + mode: "migrate" | "read-only"; + privateStateRoot: string; + agents: AgentDatabaseRequest[]; + pluginPersistence: PluginPersistenceDeclaration[]; +}; + +type ParsedRequest = InventoryRequest | RehearsalRequest; + +type DatabaseSnapshot = { + userVersion: number; + metadataSchemaVersion: number | null; + role: string | null; + ownerAgentId: string | null; +}; + +type PreparedAgentDatabase = { + agentId: string; + requestedPath: string; + resolvedPath: string; + realPath: string; + before: DatabaseSnapshot; +}; + +type InventoryReference = { + agentId: string; + path: string; + claimKind: "agent-dir-database" | "session-store-database" | "session-store-fixed-family"; + ownerClaim: + | "configured-agent-dir" + | "default-agent-dir" + | "configured-session-store" + | "default-session-store"; +}; + +class AgentDatabaseRehearsalError extends Error { + constructor( + readonly code: string, + message: string, + readonly unsupportedPluginPersistence?: readonly PluginPersistenceDeclaration[], + ) { + super(message); + this.name = "AgentDatabaseRehearsalError"; + } +} + +function fail(code: string, message: string): never { + throw new AgentDatabaseRehearsalError(code, message); +} + +function requiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== "string" || !value.trim()) { + fail("invalid-request", `${key} must be a non-empty string.`); + } + return value.trim(); +} + +function parsePluginPersistence(value: unknown): PluginPersistenceDeclaration[] { + if (!Array.isArray(value)) { + fail("invalid-request", "pluginPersistence must be an array."); + } + return value.map((entry, index) => { + if (!isRecord(entry)) { + fail("invalid-request", `pluginPersistence[${index}] must be an object.`); + } + const copiedPath = entry.copiedPath; + if (copiedPath !== null && (typeof copiedPath !== "string" || !copiedPath.trim())) { + fail("invalid-request", `pluginPersistence[${index}].copiedPath must be a string or null.`); + } + const reason = entry.reason; + if (reason !== undefined && (typeof reason !== "string" || !reason.trim())) { + fail("invalid-request", `pluginPersistence[${index}].reason must be a non-empty string.`); + } + return { + pluginId: requiredString(entry, "pluginId"), + kind: requiredString(entry, "kind"), + copiedPath: copiedPath === null ? null : copiedPath.trim(), + ...(typeof reason === "string" ? { reason: reason.trim() } : {}), + }; + }); +} + +function parseRequest(value: unknown): ParsedRequest { + if (!isRecord(value)) { + fail("invalid-request", "request must be a JSON object."); + } + if (value.schemaVersion !== REHEARSAL_SCHEMA_VERSION) { + fail("unsupported-request-version", "schemaVersion must be 1."); + } + const mode = value.mode; + if (mode === "inventory") { + return { + schemaVersion: 1, + mode, + stateRoot: requiredString(value, "stateRoot"), + configPath: requiredString(value, "configPath"), + }; + } + if (mode !== "migrate" && mode !== "read-only") { + fail("invalid-request", "mode must be inventory, migrate, or read-only."); + } + if (!Array.isArray(value.agents)) { + fail("invalid-request", "agents must be an array."); + } + if (value.agents.length > REHEARSAL_MAX_AGENT_DATABASES) { + fail( + "agent-limit-exceeded", + `agents exceeds the ${REHEARSAL_MAX_AGENT_DATABASES}-entry limit.`, + ); + } + const agents = value.agents.map((entry, index) => { + if (!isRecord(entry)) { + fail("invalid-request", `agents[${index}] must be an object.`); + } + return { + agentId: normalizeAgentId(requiredString(entry, "agentId")), + copiedPath: requiredString(entry, "copiedPath"), + }; + }); + return { + schemaVersion: 1, + mode, + privateStateRoot: requiredString(value, "privateStateRoot"), + agents, + pluginPersistence: parsePluginPersistence(value.pluginPersistence), + }; +} + +function requireAbsoluteDirectory( + rawPath: string, + field: string, +): { resolved: string; real: string } { + if (!path.isAbsolute(rawPath)) { + fail("path-not-absolute", `${field} must be absolute.`); + } + const resolved = path.resolve(rawPath); + let stat: fs.Stats; + try { + stat = fs.lstatSync(resolved); + } catch (error) { + fail("path-unavailable", `${field} is unavailable: ${formatErrorMessage(error)}`); + } + if (!stat.isDirectory()) { + fail("path-not-directory", `${field} must be an existing directory.`); + } + return { resolved, real: fs.realpathSync.native(resolved) }; +} + +function requireAbsoluteConfigPath(rawPath: string): string { + if (!path.isAbsolute(rawPath)) { + fail("path-not-absolute", "configPath must be absolute."); + } + const resolved = path.resolve(rawPath); + let stat: fs.Stats; + try { + stat = fs.lstatSync(resolved); + } catch (error) { + fail("config-unavailable", `configPath is unavailable: ${formatErrorMessage(error)}`); + } + if (stat.isSymbolicLink() || !stat.isFile()) { + fail("config-unavailable", "configPath must be a regular non-symlink file."); + } + return resolved; +} + +function assertPathInsideRoot(root: string, candidate: string, label: string): void { + if (candidate !== root && !isPathInside(root, candidate)) { + fail("path-escape", `${label} must remain inside the private state root.`); + } +} + +function assertPrivateDatabaseFamily(params: { + root: string; + pathname: string; + requireMain: boolean; + label: string; +}): string { + const resolved = path.resolve(params.pathname); + assertPathInsideRoot(params.root, resolved, params.label); + const parentReal = fs.realpathSync.native(path.dirname(resolved)); + assertPathInsideRoot(params.root, parentReal, `${params.label} parent`); + let mainReal: string | undefined; + for (const candidate of resolveSqliteDatabaseFilePaths(resolved)) { + let stat: fs.Stats; + try { + stat = fs.lstatSync(candidate); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + continue; + } + fail("path-unavailable", `${params.label} is unavailable: ${formatErrorMessage(error)}`); + } + if (stat.isSymbolicLink() || !stat.isFile() || stat.nlink !== 1) { + fail( + "path-not-private", + `${params.label} and its SQLite sidecars must be private regular files without symlink or hardlink aliases.`, + ); + } + const real = fs.realpathSync.native(candidate); + assertPathInsideRoot(params.root, real, params.label); + if (candidate === resolved) { + mainReal = real; + } + } + if (params.requireMain && !mainReal) { + fail("database-missing", `${params.label} must be an existing SQLite file.`); + } + return mainReal ?? resolved; +} + +function inspectDatabaseSnapshot(pathname: string): DatabaseSnapshot { + if (!fs.existsSync(pathname)) { + return { + userVersion: 0, + metadataSchemaVersion: null, + role: null, + ownerAgentId: null, + }; + } + const database = openNodeSqliteDatabase(pathname, { readOnly: true }); + try { + const metadata = readExistingAgentSchemaMeta(database); + return { + userVersion: readSqliteUserVersion(database), + metadataSchemaVersion: metadata?.schemaVersion ?? null, + role: metadata?.role ?? null, + ownerAgentId: metadata?.agentId ?? null, + }; + } finally { + database.close(); + } +} + +function prepareAgentDatabases( + request: RehearsalRequest, + root: { resolved: string; real: string }, +): PreparedAgentDatabase[] { + const seen = new Set(); + return request.agents.map((entry, index) => { + if (!path.isAbsolute(entry.copiedPath)) { + fail("path-not-absolute", `agents[${index}].copiedPath must be absolute.`); + } + const requestedPath = path.resolve(entry.copiedPath); + assertPathInsideRoot(root.resolved, requestedPath, `agents[${index}].copiedPath`); + const realPath = assertPrivateDatabaseFamily({ + root: root.real, + pathname: fs.realpathSync.native(requestedPath), + requireMain: true, + label: `agents[${index}].copiedPath`, + }); + const compositeKey = `${entry.agentId}\0${realPath}`; + if (seen.has(compositeKey)) { + fail( + "duplicate-agent-database", + `agents[${index}] duplicates ${entry.agentId} at ${realPath}.`, + ); + } + seen.add(compositeKey); + return { + agentId: entry.agentId, + requestedPath, + resolvedPath: requestedPath, + realPath, + before: inspectDatabaseSnapshot(realPath), + }; + }); +} + +function compareRegistryExact( + expected: readonly { agentId: string; path: string; schemaVersion: number }[], + actual: readonly { agentId: string; path: string; schemaVersion: number }[], +): void { + const key = (entry: { agentId: string; path: string; schemaVersion: number }) => + `${normalizeAgentId(entry.agentId)}\0${path.resolve(entry.path)}\0${entry.schemaVersion}`; + const expectedKeys = expected.map(key).toSorted(); + const actualKeys = actual.map(key).toSorted(); + if ( + expectedKeys.length !== actualKeys.length || + expectedKeys.some((entry, index) => entry !== actualKeys[index]) + ) { + fail( + "registry-mismatch", + "private agent database registry does not exactly match the manifest.", + ); + } +} + +function referenceKey(reference: InventoryReference): string { + return [reference.agentId, reference.path, reference.claimKind, reference.ownerClaim].join("\0"); +} + +async function runInventory(request: InventoryRequest) { + const root = requireAbsoluteDirectory(request.stateRoot, "stateRoot"); + const configPath = requireAbsoluteConfigPath(request.configPath); + const env: NodeJS.ProcessEnv = { + ...process.env, + OPENCLAW_STATE_DIR: root.real, + OPENCLAW_CONFIG_PATH: configPath, + }; + const io = createConfigIO({ + configPath, + env, + logger: { error: () => undefined, warn: () => undefined }, + observe: false, + pluginValidation: "skip", + shellEnvFallback: "defer", + }); + const snapshot = await io.readConfigFileSnapshot(); + if (!snapshot.valid) { + fail("config-invalid", "configPath could not be resolved as a valid effective config."); + } + const config = snapshot.runtimeConfig; + const references = new Map(); + const addReference = (reference: InventoryReference) => { + references.set(referenceKey(reference), reference); + if (references.size > REHEARSAL_MAX_INVENTORY_REFERENCES) { + fail( + "inventory-limit-exceeded", + `inventory exceeds the ${REHEARSAL_MAX_INVENTORY_REFERENCES}-reference limit.`, + ); + } + }; + for (const agentId of listAgentIds(config)) { + const normalizedAgentId = normalizeAgentId(agentId); + const configuredAgentDir = Boolean( + resolveAgentConfig(config, normalizedAgentId)?.agentDir?.trim(), + ); + const agentDir = resolveAgentDir(config, normalizedAgentId, env); + addReference({ + agentId: normalizedAgentId, + path: path.resolve(agentDir, "openclaw-agent.sqlite"), + claimKind: "agent-dir-database", + ownerClaim: configuredAgentDir ? "configured-agent-dir" : "default-agent-dir", + }); + } + const storeConfig = config.session?.store; + const configuredSessionStore = Boolean(storeConfig?.trim()); + for (const agentId of listConfiguredSessionStoreAgentIds(config)) { + const normalizedAgentId = normalizeAgentId(agentId); + const storePath = resolveStorePath(storeConfig, { agentId: normalizedAgentId, env }); + const target = resolveUnsuffixedSqliteTargetFromSessionStorePath(storePath); + addReference({ + agentId: normalizedAgentId, + path: path.resolve(target.path), + claimKind: + isPerAgentSessionStoreConfig(storeConfig) || path.resolve(storePath).endsWith(".sqlite") + ? "session-store-database" + : "session-store-fixed-family", + ownerClaim: configuredSessionStore ? "configured-session-store" : "default-session-store", + }); + } + const pluginPersistence: PluginPersistenceDeclaration[] = + config.plugins?.enabled === false + ? [] + : [ + { + pluginId: "*", + kind: "indeterminate", + copiedPath: null, + reason: "enabled plugin persistence has no manifest declaration surface", + }, + ]; + return { + schemaVersion: 1 as const, + ok: true as const, + mode: "inventory" as const, + runtimeVersion: VERSION, + stateRoot: resolveStateDir(env), + configPath, + references: [...references.values()].toSorted( + (left, right) => + left.agentId.localeCompare(right.agentId) || + left.path.localeCompare(right.path) || + left.claimKind.localeCompare(right.claimKind), + ), + pluginPersistence, + complete: pluginPersistence.length === 0, + }; +} + +async function runReadOnly( + request: RehearsalRequest & { mode: "read-only" }, + root: { resolved: string; real: string }, + agents: PreparedAgentDatabase[], + env: NodeJS.ProcessEnv, +) { + const sharedPath = resolveOpenClawStateSqlitePath(env); + const stateBefore = inspectDatabaseSnapshot(sharedPath); + const state = await openExistingOpenClawStateDatabaseReadOnly({ env }); + if (!state) { + fail("shared-state-missing", "private shared state database is missing."); + } + try { + const results = agents.map((agent) => { + const result = withOpenClawAgentDatabaseReadOnly( + (database) => inspectDatabaseSnapshot(database.path), + { agentId: agent.agentId, env, path: agent.realPath }, + ); + if (!result.found) { + fail( + "agent-database-unreadable", + `${agent.agentId} at ${agent.realPath} is unavailable (${result.reason}).`, + ); + } + return { + ...agent, + after: result.value, + migrated: false, + registry: { + path: agent.realPath, + schemaVersion: result.value.userVersion, + }, + }; + }); + const registry = listOpenClawRegisteredAgentDatabases({ + env, + includeIncompatibleSchemaVersions: true, + }); + compareRegistryExact( + results.map((result) => ({ + agentId: result.agentId, + path: result.realPath, + schemaVersion: result.after.userVersion, + })), + registry, + ); + const stateAfter = inspectDatabaseSnapshot(sharedPath); + return { + schemaVersion: 1 as const, + ok: true as const, + mode: request.mode, + runtimeVersion: VERSION, + privateStateRoot: root.real, + sharedState: { + path: sharedPath, + schemaVersionBefore: stateBefore.userVersion, + schemaVersionAfter: stateAfter.userVersion, + role: stateAfter.role, + readOnly: true, + }, + agents: results, + pluginPersistence: request.pluginPersistence, + }; + } finally { + state.walMaintenance.close(); + } +} + +function runMigrate( + request: RehearsalRequest & { mode: "migrate" }, + root: { resolved: string; real: string }, + agents: PreparedAgentDatabase[], + env: NodeJS.ProcessEnv, +) { + const sharedPath = resolveOpenClawStateSqlitePath(env); + assertPrivateDatabaseFamily({ + root: root.real, + pathname: sharedPath, + requireMain: false, + label: "private shared state database", + }); + const stateBefore = inspectDatabaseSnapshot(sharedPath); + const openedPaths = new Set(); + const state = openOpenClawStateDatabase({ env }); + try { + const results = agents.map((agent) => { + try { + try { + openOpenClawAgentDatabase({ agentId: agent.agentId, env, path: agent.realPath }); + } catch (error) { + if (!findOpenClawAgentDatabaseMediaMigrationRequiredError(error)) { + throw error; + } + migrateOpenClawAgentDatabaseForMaintenance({ + agentId: agent.agentId, + env, + pathname: agent.realPath, + }); + openOpenClawAgentDatabase({ agentId: agent.agentId, env, path: agent.realPath }); + } + openedPaths.add(agent.realPath); + const after = inspectDatabaseSnapshot(agent.realPath); + return { + ...agent, + after, + migrated: + agent.before.userVersion !== after.userVersion || + agent.before.metadataSchemaVersion !== after.metadataSchemaVersion, + registry: { path: agent.realPath, schemaVersion: after.userVersion }, + }; + } finally { + closeOpenClawAgentDatabaseByPath(agent.realPath); + openedPaths.delete(agent.realPath); + } + }); + const expectedRegistry = results.map((result) => ({ + agentId: result.agentId, + path: result.realPath, + schemaVersion: result.after.userVersion, + })); + replaceOpenClawAgentDatabaseRegistryForRehearsal({ entries: expectedRegistry, env }); + const registry = listOpenClawRegisteredAgentDatabases({ + env, + includeIncompatibleSchemaVersions: true, + }); + compareRegistryExact(expectedRegistry, registry); + const stateAfter = inspectDatabaseSnapshot(sharedPath); + return { + schemaVersion: 1 as const, + ok: true as const, + mode: request.mode, + runtimeVersion: VERSION, + privateStateRoot: root.real, + sharedState: { + path: sharedPath, + schemaVersionBefore: stateBefore.userVersion, + schemaVersionAfter: stateAfter.userVersion, + role: stateAfter.role, + readOnly: false, + }, + agents: results, + pluginPersistence: request.pluginPersistence, + }; + } finally { + for (const pathname of openedPaths) { + closeOpenClawAgentDatabaseByPath(pathname); + } + closeOpenClawStateDatabaseByPath(state.path); + } +} + +export async function runAgentDatabaseRehearsal(requestValue: unknown) { + const request = parseRequest(requestValue); + if (request.mode === "inventory") { + return await runInventory(request); + } + // Unsupported plugin state must stop before any writable shared or agent open. + if (request.pluginPersistence.length > 0) { + throw new AgentDatabaseRehearsalError( + "unsupported-plugin-persistence", + "plugin-owned persistence is unsupported by rehearsal schema v1.", + request.pluginPersistence, + ); + } + const root = requireAbsoluteDirectory(request.privateStateRoot, "privateStateRoot"); + const agents = prepareAgentDatabases(request, root); + const env: NodeJS.ProcessEnv = { ...process.env, OPENCLAW_STATE_DIR: root.real }; + if (request.mode === "read-only") { + return await runReadOnly(request, root, agents, env); + } + return runMigrate(request, root, agents, env); +} + +async function readRequestSource(source: string, stdin: AsyncIterable): Promise { + let raw: Buffer; + if (source === "-") { + raw = await readByteStreamWithLimit(stdin, { + maxBytes: REHEARSAL_REQUEST_MAX_BYTES, + onOverflow: ({ maxBytes }) => + new AgentDatabaseRehearsalError("request-too-large", `request exceeds ${maxBytes} bytes.`), + }); + } else { + const file = await fsp.open(path.resolve(source), "r"); + try { + const stat = await file.stat(); + if (!stat.isFile()) { + fail("request-unavailable", "request source must be a regular file."); + } + if (stat.size > REHEARSAL_REQUEST_MAX_BYTES) { + fail("request-too-large", `request exceeds ${REHEARSAL_REQUEST_MAX_BYTES} bytes.`); + } + raw = await readFileDescriptorBounded(file.fd, REHEARSAL_REQUEST_MAX_BYTES); + } finally { + await file.close(); + } + } + try { + return JSON.parse(raw.toString("utf8")) as unknown; + } catch { + return fail("invalid-json", "request source is not valid JSON."); + } +} + +export async function agentsDatabaseRehearsalCommand( + options: { request: string }, + runtime: RuntimeEnv, + deps: { stdin?: AsyncIterable } = {}, +): Promise { + let mode: RehearsalMode | undefined; + try { + const value = await readRequestSource(options.request, deps.stdin ?? process.stdin); + mode = + isRecord(value) && typeof value.mode === "string" ? (value.mode as RehearsalMode) : undefined; + writeRuntimeJson(runtime, await runAgentDatabaseRehearsal(value), 0); + } catch (error) { + const typed = + error instanceof AgentDatabaseRehearsalError + ? error + : new AgentDatabaseRehearsalError("rehearsal-failed", formatErrorMessage(error)); + writeRuntimeJson( + runtime, + { + schemaVersion: 1, + ok: false, + ...(mode ? { mode } : {}), + code: typed.code, + message: typed.message, + ...(typed.unsupportedPluginPersistence + ? { unsupportedPluginPersistence: typed.unsupportedPluginPersistence } + : {}), + }, + 0, + ); + runtime.exit(1, { resetStream: process.stderr }); + } +} diff --git a/src/state/openclaw-agent-db-maintenance.ts b/src/state/openclaw-agent-db-maintenance.ts index ed9ddf2ae32c..3d53dcf88223 100644 --- a/src/state/openclaw-agent-db-maintenance.ts +++ b/src/state/openclaw-agent-db-maintenance.ts @@ -70,6 +70,7 @@ export function assertOpenClawAgentDatabaseForMaintenance( /** Upgrade or repair a supported owned schema before strict offline maintenance. */ export function migrateOpenClawAgentDatabaseForMaintenance(options: { agentId: string; + env?: NodeJS.ProcessEnv; pathname: string; }): void { const agentId = normalizeAgentId(options.agentId); @@ -99,6 +100,7 @@ export function migrateOpenClawAgentDatabaseForMaintenance(options: { } ensureOpenClawAgentDatabaseSchema(database, { agentId, + ...(options.env ? { env: options.env } : {}), path: options.pathname, }); assertOpenClawAgentDatabaseForMaintenance(database, { diff --git a/src/state/openclaw-agent-db-registry.ts b/src/state/openclaw-agent-db-registry.ts index df0dcdc547c1..1870f0d692de 100644 --- a/src/state/openclaw-agent-db-registry.ts +++ b/src/state/openclaw-agent-db-registry.ts @@ -4,6 +4,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import { executeSqliteQuerySync, getNodeSqliteKysely } from "../infra/kysely-sync.js"; import { isPathInside } from "../infra/path-guards.js"; +import { normalizeAgentId } from "../routing/session-key.js"; import { assertAgentDeletionPathFence, prepareAgentDeletionPathFence, @@ -642,6 +643,62 @@ export function registerOpenClawAgentDatabase(params: { invalidateRegisteredAgentDatabasesMemo({ env: params.env }); } +/** Atomically replace one state root's registry with an exact reviewed set. */ +export function replaceOpenClawAgentDatabaseRegistryForRehearsal(params: { + entries: readonly { + agentId: string; + path: string; + schemaVersion: number; + }[]; + env?: NodeJS.ProcessEnv; +}): void { + const prepared = params.entries.map((entry) => { + const agentId = normalizeAgentId(entry.agentId); + const pathname = path.resolve(entry.path); + const deletionFence = prepareAgentDeletionPathFence( + { agentId, path: pathname }, + { env: params.env }, + ); + const sizeBytes = (() => { + try { + return statSync(pathname).size; + } catch { + return null; + } + })(); + return { + agentId, + deletionFence, + path: pathname, + schemaVersion: entry.schemaVersion, + sizeBytes, + }; + }); + const lastSeenAt = Date.now(); + runOpenClawStateWriteTransaction( + (database) => { + const db = getNodeSqliteKysely(database.db); + executeSqliteQuerySync(database.db, db.deleteFrom("agent_databases")); + for (const entry of prepared) { + assertAgentDeletionPathFence(database.db, entry.deletionFence); + executeSqliteQuerySync( + database.db, + db.insertInto("agent_databases").values({ + agent_id: entry.agentId, + path: entry.path, + schema_version: entry.schemaVersion, + last_seen_at: lastSeenAt, + size_bytes: entry.sizeBytes, + }), + ); + } + }, + { env: params.env }, + { operationLabel: "agent.registry.replace" }, + ); + invalidateRegisteredAgentDatabasesMemo({ env: params.env }); +} + function canonicalPathForRegistryBoundary(pathname: string): string { const identity = resolveAgentDatabasePathIdentity(pathname); if (identity.realPath) {