import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js"; import { planLegacyConfigForUpdateChannel } from "../../commands/doctor/legacy-config-repair.js"; import { createConfigIO } from "../../config/io.js"; import { withTempHome, writeOpenClawConfig } from "../../config/test-helpers.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { requireNodeSqlite } from "../../infra/node-sqlite.js"; import { OPENCLAW_AGENT_SCHEMA_VERSION } from "../../state/openclaw-agent-db-contract.js"; import { unregisterOpenClawAgentDatabase } from "../../state/openclaw-agent-db-registry.js"; import { closeOpenClawAgentDatabasesForTest, openOpenClawAgentDatabase, } from "../../state/openclaw-agent-db.js"; import { OPENCLAW_STATE_SCHEMA_VERSION } from "../../state/openclaw-state-db-contract.js"; import { closeOpenClawStateDatabaseForTest, openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; import { resolveOpenClawStateSqlitePath } from "../../state/openclaw-state-db.paths.js"; import { withEnvAsync } from "../../test-utils/env.js"; import { captureTargetDatabaseSchemaContext, checkTargetDatabaseSchemasForContexts, hasSchemaRefusal, } from "./schema-preflight.js"; import { inspectUpdateDatabaseContexts } from "./update-command-database-context.js"; import { captureOwnedManagedUpdatePreflightContext, revalidateUpdateDatabaseContext, } from "./update-command-managed-context.js"; const tempDirs = useAutoCleanupTempDirTracker(afterEach); afterEach(() => { closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); }); describe("target-release database schema preflight", () => { it.each([ { outcome: "accepts compatible", version: OPENCLAW_AGENT_SCHEMA_VERSION, refusal: false }, { outcome: "refuses newer", version: OPENCLAW_AGENT_SCHEMA_VERSION + 1, refusal: true }, ])("$outcome agent schemas committed to active WAL", async ({ version, refusal }) => { const stateDir = fs.realpathSync.native(tempDirs.make("openclaw-update-wal-state-")); const env = { OPENCLAW_STATE_DIR: stateDir }; const config: OpenClawConfig = { agents: { list: [{ id: "main" }] } }; openOpenClawStateDatabase({ env }); const agentPath = openOpenClawAgentDatabase({ agentId: "main", env }).path; closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); const { DatabaseSync } = requireNodeSqlite(); // Capture before opening the writer: closing another main-file descriptor // in its process would release the writer's POSIX locks. const databaseBefore = fs.readFileSync(agentPath); const agent = new DatabaseSync(agentPath); try { agent.exec("PRAGMA journal_mode = WAL; PRAGMA wal_autocheckpoint = 0;"); agent.exec("PRAGMA wal_checkpoint(TRUNCATE);"); // Keep the committed schema change in WAL while the connection remains open. agent.exec(`PRAGMA user_version = ${version};`); const walBefore = fs.readFileSync(`${agentPath}-wal`); expect(databaseBefore.readUInt32BE(60)).toBe(OPENCLAW_AGENT_SCHEMA_VERSION); expect(walBefore.length).toBeGreaterThan(32); const result = await checkTargetDatabaseSchemasForContexts( { state: OPENCLAW_STATE_SCHEMA_VERSION, agent: OPENCLAW_AGENT_SCHEMA_VERSION }, [{ config, env }], ); expect(result.indeterminate).toEqual([]); expect(result.incompatible).toEqual( refusal ? [ expect.objectContaining({ kind: "agent", path: agentPath, foundVersion: version, supportedVersion: OPENCLAW_AGENT_SCHEMA_VERSION, }), ] : [], ); expect(hasSchemaRefusal(result)).toBe(refusal); expect(fs.readFileSync(agentPath)).toEqual(databaseBefore); expect(fs.readFileSync(`${agentPath}-wal`)).toEqual(walBefore); } finally { agent.close(); } }); it.runIf(process.platform !== "win32")( "deduplicates caller and managed aliases of one physical database", async () => { const stateDir = fs.realpathSync.native(tempDirs.make("openclaw-update-union-state-")); const aliasRoot = tempDirs.make("openclaw-update-union-alias-"); const stateAlias = path.join(aliasRoot, "state-link"); fs.symlinkSync(stateDir, stateAlias, "dir"); const statePath = openOpenClawStateDatabase({ env: { OPENCLAW_STATE_DIR: stateDir }, }).path; closeOpenClawStateDatabaseForTest(); const { DatabaseSync } = requireNodeSqlite(); const state = new DatabaseSync(statePath); state.exec("PRAGMA user_version = 9;"); state.close(); const config: OpenClawConfig = {}; const result = await checkTargetDatabaseSchemasForContexts({ state: 3, agent: 11 }, [ { config, env: { OPENCLAW_STATE_DIR: stateDir } }, { config, env: { OPENCLAW_STATE_DIR: stateAlias } }, ]); expect(result.incompatible).toEqual([ expect.objectContaining({ kind: "state", path: statePath, foundVersion: 9 }), ]); expect(result.indeterminate).toEqual([]); }, ); it("refuses v2026.8.1 before mutating v2026.7.1-2 shared state when an agent store is unreadable", async () => { const stateDir = fs.realpathSync.native(tempDirs.make("openclaw-update-7-to-8-state-")); const env = { OPENCLAW_STATE_DIR: stateDir }; const config: OpenClawConfig = { agents: { list: [{ id: "main" }, { id: "worker" }] } }; const statePath = openOpenClawStateDatabase({ env }).path; const agentPath = openOpenClawAgentDatabase({ agentId: "worker", env }).path; closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); unregisterOpenClawAgentDatabase({ agentId: "worker", env, path: agentPath }); const { DatabaseSync } = requireNodeSqlite(); const state = new DatabaseSync(statePath); state.exec("PRAGMA user_version = 1; UPDATE schema_meta SET schema_version = 1;"); state.close(); fs.writeFileSync(agentPath, "damaged v2026.7.1-2 agent store\n"); const stateBefore = fs.readFileSync(statePath); const result = await checkTargetDatabaseSchemasForContexts( // Published v2026.8.1 supports state schema 15 and agent schema 19. { state: 15, agent: 19 }, [{ config, env }], ); expect(result.incompatible).toEqual([]); expect(result.indeterminate).toEqual([ expect.objectContaining({ kind: "agent", path: agentPath }), ]); expect(hasSchemaRefusal(result)).toBe(true); expect(fs.readFileSync(statePath)).toEqual(stateBefore); const inspectedState = new DatabaseSync(statePath, { readOnly: true }); try { expect(inspectedState.prepare("PRAGMA user_version").get()).toEqual({ user_version: 1 }); } finally { inspectedState.close(); } }); it("finds every multi-agent store before refusing a v2026.7.1-2 target", async () => { const stateDir = fs.realpathSync.native(tempDirs.make("openclaw-update-preflight-state-")); const customDir = fs.realpathSync.native(tempDirs.make("openclaw-update-preflight-custom-")); const env = { OPENCLAW_STATE_DIR: stateDir }; const config: OpenClawConfig = { agents: { list: [{ id: "main" }, { id: "configured" }] }, }; openOpenClawStateDatabase({ env }); const configuredPath = openOpenClawAgentDatabase({ agentId: "configured", env }).path; const unregisteredPath = openOpenClawAgentDatabase({ agentId: "retired", env }).path; const registeredCustomPath = openOpenClawAgentDatabase({ agentId: "registered-custom", env, path: path.join(customDir, "registered", "openclaw-agent.sqlite"), }).path; closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); unregisterOpenClawAgentDatabase({ agentId: "retired", env, path: unregisteredPath }); const before = [configuredPath, unregisteredPath, registeredCustomPath].map((pathname) => ({ pathname, bytes: fs.readFileSync(pathname), mtimeNs: fs.statSync(pathname, { bigint: true }).mtimeNs, })); const result = await checkTargetDatabaseSchemasForContexts( // v2026.7.1-2 supports state/agent schema 1. The reported upgrade to // v2026.8.1 advances them to state 15 and agent 19. { state: 1, agent: 1 }, [{ config, env }], ); const incompatibleAgentPaths = result.incompatible .filter((database) => database.kind === "agent") .map((database) => database.path) .toSorted(); expect(incompatibleAgentPaths).toEqual( [configuredPath, unregisteredPath, registeredCustomPath].toSorted(), ); expect(result.indeterminate).toEqual([]); expect(hasSchemaRefusal(result)).toBe(true); expect( before.map(({ pathname }) => ({ pathname, bytes: fs.readFileSync(pathname), mtimeNs: fs.statSync(pathname, { bigint: true }).mtimeNs, })), ).toEqual(before); }); it("finds configured custom stores without registry rows", async () => { const stateDir = fs.realpathSync.native(tempDirs.make("openclaw-update-custom-state-")); const customDir = fs.realpathSync.native(tempDirs.make("openclaw-update-custom-root-")); const env = { OPENCLAW_STATE_DIR: stateDir }; const config: OpenClawConfig = { agents: { list: [{ id: "main" }, { id: "ops" }] }, session: { store: path.join(customDir, "{agentId}", "sessions.json") }, }; openOpenClawStateDatabase({ env }); const customPaths = ["main", "ops"].map( (agentId) => openOpenClawAgentDatabase({ agentId, env, path: path.join(customDir, agentId, "openclaw-agent.sqlite"), }).path, ); closeOpenClawAgentDatabasesForTest(); closeOpenClawStateDatabaseForTest(); for (const [index, pathname] of customPaths.entries()) { unregisterOpenClawAgentDatabase({ agentId: index === 0 ? "main" : "ops", env, path: pathname, }); } const result = await checkTargetDatabaseSchemasForContexts({ state: 1, agent: 1 }, [ { config, env }, ]); expect(result.incompatible.filter((database) => database.kind === "agent")).toEqual( expect.arrayContaining( customPaths.map((pathname) => expect.objectContaining({ path: pathname })), ), ); expect(result.indeterminate).toEqual([]); }); }); describe("planned legacy configuration admission", () => { it.each(["unchanged", "root edit", "include edit", "different profile"] as const)( "preserves original config and fences %s", async (scenario) => { await withTempHome(async (home) => { await withEnvAsync({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () => { const configPath = await writeOpenClawConfig(home, { gateway: { $include: "gateway.json" }, }); const includePath = path.join(path.dirname(configPath), "gateway.json"); fs.writeFileSync(includePath, '{"mode":"local","bind":"localhost"}\n'); const env = { ...process.env, OPENCLAW_CONFIG_PATH: configPath }; const original = fs.readFileSync(configPath); const originalInclude = fs.readFileSync(includePath); const { snapshot, writeOptions } = await createConfigIO({ env, observe: false, }).readConfigFileSnapshotForWrite(); expect(snapshot.valid).toBe(false); const legacyConfigPlan = planLegacyConfigForUpdateChannel(snapshot, writeOptions); expect(legacyConfigPlan).toBeDefined(); await expect( captureTargetDatabaseSchemaContext(env).then(() => true), ).rejects.toMatchObject({ reason: "invalid-config", message: expect.stringMatching(/gateway\.bind:[\s\S]*openclaw doctor --fix/), }); // Exercise the real caller admission forwarding, without inspecting a live service. const { contexts } = await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, () => inspectUpdateDatabaseContexts({ roots: [], updateInstallKind: "package", shouldRestart: false, jsonMode: true, timeoutMs: 1_000, managedServiceRootRedirect: null, legacyConfigPlan, }), ); const context = contexts[0]!; expect(context.config.gateway?.bind).toBe("loopback"); expect(context.configSnapshot.valid).toBe(false); expect(context.configSnapshot.raw).toBe(original.toString()); expect(fs.readFileSync(configPath)).toEqual(original); expect(fs.readFileSync(includePath)).toEqual(originalInclude); expect(fs.existsSync(resolveOpenClawStateSqlitePath(env))).toBe(false); if (scenario === "unchanged") { expect((await revalidateUpdateDatabaseContext(context)).config).toEqual(context.config); } else if (scenario === "different profile") { const otherPath = path.join(path.dirname(configPath), "other.json"); fs.writeFileSync(otherPath, original); await expect( captureTargetDatabaseSchemaContext( { ...env, OPENCLAW_CONFIG_PATH: otherPath }, { legacyConfigPlan }, ).then(() => true), ).rejects.toMatchObject({ reason: "invalid-config" }); } else { fs.appendFileSync(scenario === "root edit" ? configPath : includePath, "\n"); await expect( revalidateUpdateDatabaseContext(context).then(() => true), ).rejects.toMatchObject({ reason: "database-schema-preflight", }); } }); }); }, ); }); describe("planned migration managed profile isolation", () => { it("reports invalid model policy paths without disclosing their values", async () => { await withTempHome(async (home) => { const rejectedValue = "synthetic-private-config-value"; const configPath = await writeOpenClawConfig(home, { agents: { defaults: { modelPolicy: { allow: [rejectedValue] } } }, }); const env = { ...process.env, OPENCLAW_CONFIG_PATH: configPath }; const before = fs.readFileSync(configPath); const inspected = captureTargetDatabaseSchemaContext(env); await expect(inspected).rejects.toMatchObject({ reason: "invalid-config" }); await expect(inspected).rejects.toThrow("agents.defaults.modelPolicy.allow.0:"); await expect(inspected).rejects.toThrow("openclaw doctor --fix"); await expect(inspected).rejects.not.toThrow(rejectedValue); expect(fs.readFileSync(configPath)).toEqual(before); }); }); it("refuses a newly valid replacement of the planned source before admission", async () => { await withTempHome(async (home) => { const configPath = await writeOpenClawConfig(home, { gateway: { mode: "local", bind: "localhost" }, }); const env = { ...process.env, OPENCLAW_CONFIG_PATH: configPath }; const { snapshot, writeOptions } = await createConfigIO({ env, observe: false, }).readConfigFileSnapshotForWrite(); const legacyConfigPlan = planLegacyConfigForUpdateChannel(snapshot, writeOptions); expect(legacyConfigPlan).toBeDefined(); const replacement = JSON.stringify({ gateway: { mode: "local", bind: "lan" } }); fs.writeFileSync(configPath, replacement); await expect( captureTargetDatabaseSchemaContext(env, { legacyConfigPlan }), ).rejects.toMatchObject({ reason: "database-schema-preflight", }); expect(fs.readFileSync(configPath, "utf8")).toBe(replacement); expect(fs.existsSync(resolveOpenClawStateSqlitePath(env))).toBe(false); }); }); it.each([ "same source", "other valid source", "other legacy source", "other invalid source", ] as const)("admits only the owned service's config: %s", async (scenario) => { await withTempHome(async (home) => { await withEnvAsync({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () => { const callerPath = await writeOpenClawConfig(home, { gateway: { mode: "local", bind: "localhost" }, }); const callerEnv = { ...process.env, OPENCLAW_CONFIG_PATH: callerPath }; const { snapshot, writeOptions } = await createConfigIO({ env: callerEnv, observe: false, }).readConfigFileSnapshotForWrite(); const legacyConfigPlan = planLegacyConfigForUpdateChannel(snapshot, writeOptions); expect(legacyConfigPlan).toBeDefined(); const servicePath = scenario === "same source" ? callerPath : path.join(path.dirname(callerPath), "service.json"); if (servicePath !== callerPath) { fs.writeFileSync( servicePath, JSON.stringify({ gateway: { mode: "local", bind: scenario === "other legacy source" ? "localhost" : "lan", ...(scenario === "other invalid source" ? { port: "invalid" } : {}), }, }), ); } const before = fs.readFileSync(servicePath); const serviceEnv = { ...callerEnv, OPENCLAW_CONFIG_PATH: servicePath }; const originalEnv = { ...process.env }; const inspected = captureOwnedManagedUpdatePreflightContext({ processEnv: callerEnv, legacyConfigPlan, stopState: { stopped: false, inspected: true, runtimeInspected: true, running: true, serviceEnv, serviceDefinitionEnv: serviceEnv, serviceUpdateVerdict: { kind: "owned", root: "/synthetic/openclaw", fingerprint: "owned", refreshDefinition: false, }, }, }); if (scenario === "other legacy source" || scenario === "other invalid source") { await expect(inspected).rejects.toMatchObject({ reason: "invalid-config" }); } else { const context = await inspected; expect(context?.config.gateway?.bind).toBe( scenario === "same source" ? "loopback" : "lan", ); expect(context?.configSnapshot.path).toBe(servicePath); expect(context?.configSnapshot.valid).toBe(scenario !== "same source"); if (scenario === "other valid source") { expect(context?.legacyConfigPlan).toBeUndefined(); } } expect(fs.readFileSync(servicePath)).toEqual(before); expect(fs.existsSync(resolveOpenClawStateSqlitePath(serviceEnv))).toBe(false); expect({ ...process.env }).toEqual(originalEnv); }); }); }); it("reports invalid fields and repair guidance without admitting unrelated invalid settings", async () => { await withTempHome(async (home) => { await withEnvAsync({ OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }, async () => { const configPath = await writeOpenClawConfig(home, { gateway: { mode: "local", bind: "localhost", port: "invalid" }, session: { store: 42 }, }); const env = { ...process.env, OPENCLAW_CONFIG_PATH: configPath }; const before = fs.readFileSync(configPath); const { snapshot, writeOptions } = await createConfigIO({ env, observe: false, }).readConfigFileSnapshotForWrite(); const legacyConfigPlan = planLegacyConfigForUpdateChannel(snapshot, writeOptions); expect(legacyConfigPlan).toBeUndefined(); const inspected = captureTargetDatabaseSchemaContext(env, { legacyConfigPlan }); await expect(inspected).rejects.toMatchObject({ reason: "invalid-config", message: expect.stringContaining(configPath), }); await expect(inspected).rejects.toThrow("gateway.port:"); await expect(inspected).rejects.toThrow("session.store:"); await expect(inspected).rejects.toThrow("openclaw doctor --fix"); expect(fs.readFileSync(configPath)).toEqual(before); }); }); }); });