feat(agents): add isolated database rehearsal contract

This commit is contained in:
Peter Steinberger
2026-08-11 11:00:10 -07:00
parent edb7a1692e
commit 8a0afb423b
6 changed files with 1104 additions and 0 deletions
+23
View File
@@ -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",
+16
View File
@@ -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<AgentsDeleteModule["agentsDele
return (await import("../../commands/agents.commands.delete.js")).agentsDeleteCommand;
}
async function loadAgentsDatabaseRehearsalCommand(): Promise<
AgentsDatabaseRehearsalModule["agentsDatabaseRehearsalCommand"]
> {
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 <file|->", "Read the bounded rehearsal JSON request")
.action(async (opts): Promise<void> => {
const { defaultRuntime } = await loadAgentsActionRuntime();
const agentsDatabaseRehearsalCommand = await loadAgentsDatabaseRehearsalCommand();
await agentsDatabaseRehearsalCommand({ request: String(opts.request) }, defaultRuntime);
});
agents.action(async (): Promise<void> => {
await runAgentsCommandAction(async (runtime) => {
const agentsListCommand = await loadAgentsListCommand();
+318
View File
@@ -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<ReturnType<typeof runAgentDatabaseRehearsal>>;
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<string> {
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<RehearsalSuccess, { mode: "inventory" }>;
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<RehearsalSuccess, { mode: "inventory" }>;
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<RehearsalSuccess, { mode: "migrate" }>;
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<RehearsalSuccess, { mode: "read-only" }>;
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" });
});
});
+688
View File
@@ -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<string, unknown>, 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<string>();
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<string, InventoryReference>();
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<string>();
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<unknown>): Promise<unknown> {
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<unknown> } = {},
): Promise<void> {
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 });
}
}
@@ -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, {
+57
View File
@@ -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<OpenClawAgentRegistryDatabase>(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) {