| 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(); |
| |
| |
| 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);"); |
| |
| 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( |
| |
| { 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( |
| |
| |
| { 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/), |
| }); |
| |
| 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); |
| }); |
| }); |
| }); |
| }); |
|
|