import fs from "node:fs"; import path from "node:path"; import { isDeepStrictEqual } from "node:util"; import type { LegacyConfigUpdatePlan } from "../../commands/doctor/legacy-config-repair.js"; import { cloneEnvWithPlatformSemantics } from "../../config/env-vars.js"; import { createConfigIO } from "../../config/io.js"; import { formatConfigIssueLines } from "../../config/issue-format.js"; import { resolveConfiguredAgentDatabaseCandidatePaths } from "../../config/sessions/targets.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { OPENCLAW_DATABASE_SCHEMA_DOCS_URL, preflightOpenClawDatabaseSchemas, type IncompatibleOpenClawDatabase, type IndeterminateOpenClawDatabase, type OpenClawDatabaseSchemaPreflight, } from "../../state/openclaw-database-preflight.js"; import type { OpenClawSchemaVersions } from "../../state/openclaw-schema-versions.js"; import { UpdatePreMutationError } from "./shared.js"; type TargetDatabaseSchemaContext = { config: OpenClawConfig; env: NodeJS.ProcessEnv; }; export function formatSchemaRefusalLines( schemas: { incompatible: readonly IncompatibleOpenClawDatabase[]; indeterminate: readonly IndeterminateOpenClawDatabase[]; }, dryRun = false, ): string[] { const prefix = dryRun ? "Would refuse update" : "Update refused"; return [ ...schemas.incompatible.map((database) => { const agent = database.agentId ? ` (agent ${database.agentId})` : ""; return `${prefix}: ${database.kind} database${agent} ${database.path} has schema ${database.foundVersion}; target supports ${database.supportedVersion}; writer build ${database.writerAppVersion ?? "unknown"}.`; }), ...schemas.indeterminate.map( (database) => `${prefix}: could not inspect ${database.kind} database ${database.path}: ${database.reason}; retry once the gateway releases it.`, ), OPENCLAW_DATABASE_SCHEMA_DOCS_URL, "Installing manually via npm bypasses this guard; back up first and verify compatibility.", ]; } async function checkTargetDatabaseSchemas( supportedVersions: OpenClawSchemaVersions, context: TargetDatabaseSchemaContext, ): Promise { let configuredAgentDatabaseCandidatePaths: string[]; try { configuredAgentDatabaseCandidatePaths = resolveConfiguredAgentDatabaseCandidatePaths( context.config, { env: context.env }, ); } catch (error) { throw new UpdatePreMutationError( "database-schema-preflight", `Update refused: could not inspect configured database paths: ${formatErrorMessage(error)}`, ); } return preflightOpenClawDatabaseSchemas({ env: context.env, supportedVersions, // Include default on-disk stores that update-time Doctor can later touch, // without resolving configured candidates into writable migration owners. configuredAgentDatabaseTargets: [], // Inspection keeps registered paths without adopting their migration ownership. configuredAgentDatabaseCandidatePaths, }); } export async function captureTargetDatabaseSchemaContext( env: NodeJS.ProcessEnv, options?: { legacyConfigPlan?: LegacyConfigUpdatePlan }, ) { // Discover stores without plugins, recovery, observations, or caller environment changes. const inspectionEnv = cloneEnvWithPlatformSemantics(env); const readEnv = cloneEnvWithPlatformSemantics(env); const { snapshot, writeOptions } = await createConfigIO({ env: inspectionEnv, observe: false, pluginValidation: "core-only", }).readConfigFileSnapshotForWrite(); // A Doctor-validated projection is usable only for its exact authored source. // Keep the original snapshot intact for checkpointing and later revalidation; // a caller's plan must not authorize a different managed service's config. const planned = options?.legacyConfigPlan; const before = planned?.snapshot; const legacyConfigPlan = before && before.path === snapshot.path && before.exists === snapshot.exists && before.raw === snapshot.raw && before.hash === snapshot.hash && isDeepStrictEqual(before.includedPaths ?? [], snapshot.includedPaths ?? []) && isDeepStrictEqual(before.includeProvenance ?? [], snapshot.includeProvenance ?? []) && isDeepStrictEqual(before.sourceConfig, snapshot.sourceConfig) && isDeepStrictEqual( planned.includeIdentity.includeFileHashesForWrite ?? {}, writeOptions.includeFileHashesForWrite ?? {}, ) && isDeepStrictEqual( planned.includeIdentity.includeFileTargetsForWrite ?? {}, writeOptions.includeFileTargetsForWrite ?? {}, ) ? planned : undefined; if (before?.path === snapshot.path && !legacyConfigPlan) { throw new UpdatePreMutationError( "database-schema-preflight", `Update refused: planned configuration changed at ${snapshot.path}. Retry against the current source.`, ); } if ((!snapshot.valid && !legacyConfigPlan) || snapshot.readError) { throw new UpdatePreMutationError( "invalid-config", [ `Update refused: configuration is invalid or unreadable at ${snapshot.path}.`, ...formatConfigIssueLines( // Validator messages can contain config values, including misplaced secrets. snapshot.issues.map(({ path: issuePath, pathSegments }) => ({ path: issuePath, pathSegments, message: "Invalid configuration field", })), "-", { normalizeRoot: true }, ), "Run `openclaw doctor --fix` to repair retired or unrecognized configuration fields, then correct any remaining errors before retrying.", ].join("\n"), ); } return { env: inspectionEnv, config: legacyConfigPlan?.config ?? snapshot.sourceConfig ?? snapshot.config, configSnapshot: snapshot, readEnv, ...(legacyConfigPlan ? { legacyConfigPlan } : {}), }; } function canonicalDatabaseIdentity(database: { kind: "agent" | "state"; path: string }): string { let canonical: string; try { // Native traversal must see the original locator before any lexical // normalization: link/../file can name a different database from resolve(). canonical = fs.realpathSync.native(database.path); } catch { canonical = path.resolve(database.path); } const comparable = process.platform === "win32" ? canonical.toLowerCase() : canonical; return `${database.kind}\0${comparable}`; } /** Inspect the union of caller/service stores without granting migration ownership. */ export async function checkTargetDatabaseSchemasForContexts( supportedVersions: OpenClawSchemaVersions | undefined, contexts: readonly TargetDatabaseSchemaContext[], ): Promise { if (!supportedVersions) { return { incompatible: [], indeterminate: [] }; } const incompatible = new Map(); const indeterminate = new Map(); for (const context of contexts) { const result = await checkTargetDatabaseSchemas(supportedVersions, context); for (const database of result.incompatible) { const identity = canonicalDatabaseIdentity(database); incompatible.set(identity, incompatible.get(identity) ?? database); indeterminate.delete(identity); } for (const database of result.indeterminate) { const identity = canonicalDatabaseIdentity(database); if (!incompatible.has(identity) && !indeterminate.has(identity)) { indeterminate.set(identity, database); } } } return { incompatible: [...incompatible.values()], indeterminate: [...indeterminate.values()] }; } export function hasSchemaRefusal(schemas: OpenClawDatabaseSchemaPreflight): boolean { return schemas.incompatible.length > 0 || schemas.indeterminate.length > 0; }