diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift index 5e6e8cd48948..1cceaa4274a2 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/ExecApprovalsLegacyMigrationGate.swift @@ -29,7 +29,12 @@ enum ExecApprovalsLegacyMigrationGate { userInfo: [ NSLocalizedDescriptionKey: "Legacy exec approvals exist at \(sourceURL.path). " + - "Run `openclaw doctor --fix` before using exec approvals.", + // Doctor repairs whichever state directory its own environment resolves to, + // and an app store never shares the CLI's default root, so always name the + // directory; a bare `doctor --fix` would repair a different one. Prose, + // not a `VAR=value cmd` one-liner, so the path needs no shell quoting. + "Run `openclaw doctor --fix` with OPENCLAW_STATE_DIR set to " + + "\(stateDirectoryURL.path) before using exec approvals.", ]) } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift index f5c976361b93..933979fef7e8 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ExecApprovalsSQLiteStoreTests.swift @@ -213,7 +213,12 @@ struct ExecApprovalsSQLiteStoreTests { _ = try ExecApprovalsSQLiteStore.read(stateDirectoryURL: stateDirectoryURL) Issue.record("Expected pending legacy approvals to refuse SQLite access") } catch { - #expect(error.localizedDescription.contains("Run `openclaw doctor --fix`")) + // The blocked state directory must be named; a bare command repairs the + // default root, and an app store never shares the CLI's default root. + #expect( + error.localizedDescription.contains( + "Run `openclaw doctor --fix` with OPENCLAW_STATE_DIR set to " + + stateDirectoryURL.path)) } } } diff --git a/docs/reference/database-schemas.md b/docs/reference/database-schemas.md index 629a624f42c5..6061e4201233 100644 --- a/docs/reference/database-schemas.md +++ b/docs/reference/database-schemas.md @@ -82,7 +82,13 @@ Since 2026.7.2, `openclaw update` refuses to install a release that cannot open ### The Gateway refuses to start with a newer schema version error -A newer OpenClaw build wrote your databases, and the running build is older. The error and the Gateway startup log name the build that owns the database (`app_version`). Install that version or newer, or use one of the options above. Do not edit the database to silence the error. +A newer OpenClaw build wrote your databases, and the running build is older. The error names the refusing install — release version, commit, and install root — plus the schema it supports and the schema it found. + +Act on the install root, not the version. One release version string spans many `main` commits and several schema levels, so two installs can both call themselves `2026.7.2` and support different schemas. A prerelease version may not exist on the `latest` npm tag at all: check `npm view openclaw dist-tags` before reinstalling, because the tag carrying the schema you need may be `beta`, and reinstalling from `latest` can move you further away. + +A linked source checkout is the case where the commit misleads: `openclaw --version` reports the checkout's git HEAD, but the code actually executing is whatever `dist/` was last built. If the install root is a checkout, rebuild it (`pnpm build`) before concluding the version is wrong. + +Open the database with a build that supports its schema, or point the older build at a separate `OPENCLAW_STATE_DIR`. Do not edit the database to silence the error. ### A database is quarantined after integrity verification failed diff --git a/src/infra/exec-approvals-migration-gate.ts b/src/infra/exec-approvals-migration-gate.ts index 862f1364fb5c..325debf94768 100644 --- a/src/infra/exec-approvals-migration-gate.ts +++ b/src/infra/exec-approvals-migration-gate.ts @@ -1,14 +1,28 @@ // Blocks runtime use while retired exec approval state still awaits Doctor import. import fs from "node:fs"; +import path from "node:path"; import { resolveExecApprovalsPath } from "./exec-approvals-config.js"; const DOCTOR_CLAIM_SUFFIX = ".doctor-importing"; const legacyPresenceCache = new Map(); +/** + * Doctor repairs whichever state directory its own environment resolves to, so a bare + * `openclaw doctor --fix` repairs the default root while a scoped install stays blocked. + * Name the directory whenever this process is scoped to a non-default one. Say it in + * prose rather than as a `VAR=value cmd` one-liner, which no Windows shell accepts. + */ +function doctorFixInstruction(filePath: string): string { + const command = "Run `openclaw doctor --fix`"; + return process.env.OPENCLAW_STATE_DIR?.trim() + ? `${command} with OPENCLAW_STATE_DIR set to ${path.dirname(filePath)}` + : command; +} + export class ExecApprovalsMigrationRequiredError extends Error { constructor(filePath: string) { super( - `Legacy exec approvals exist at ${filePath}. Run \`openclaw doctor --fix\` before using exec approvals.`, + `Legacy exec approvals exist at ${filePath}. ${doctorFixInstruction(filePath)} before using exec approvals.`, ); this.name = "ExecApprovalsMigrationRequiredError"; } diff --git a/src/infra/exec-approvals-store.test.ts b/src/infra/exec-approvals-store.test.ts index e2dffea317c4..cfc2d204cb76 100644 --- a/src/infra/exec-approvals-store.test.ts +++ b/src/infra/exec-approvals-store.test.ts @@ -396,6 +396,24 @@ describe("exec approvals SQLite store", () => { expect(() => loadExecApprovals()).toThrow(ExecApprovalsMigrationRequiredError); }); + it("scopes the doctor command to the blocked state directory", () => { + // A bare `openclaw doctor --fix` repairs the default root, leaving a scoped + // install blocked by the same file it was told to repair (#115008). + const stateDir = process.env.OPENCLAW_STATE_DIR; + if (!stateDir) { + throw new Error("missing test state dir"); + } + const error = new ExecApprovalsMigrationRequiredError( + path.join(stateDir, "exec-approvals.json"), + ); + + // Prose, not `VAR=value cmd`: no Windows shell accepts that form, and a path + // containing spaces would need shell-specific quoting to survive a paste. + expect(error.message).toContain( + `Run \`openclaw doctor --fix\` with OPENCLAW_STATE_DIR set to ${stateDir}`, + ); + }); + it.each([ [true, false, false], [false, true, false], diff --git a/src/infra/sqlite-user-version.test.ts b/src/infra/sqlite-user-version.test.ts index 058d3b146a4f..66740ee23e5d 100644 --- a/src/infra/sqlite-user-version.test.ts +++ b/src/infra/sqlite-user-version.test.ts @@ -1,7 +1,9 @@ // Tests for SQLite user_version pragma helper. import { describe, expect, it } from "vitest"; +import { VERSION } from "../version.js"; import { createNewerSqliteSchemaVersionError, + describeRunningOpenClawBuild, readSqliteUserVersion, } from "./sqlite-user-version.js"; @@ -56,4 +58,31 @@ describe("createNewerSqliteSchemaVersionError", () => { expect(error.name).toBe("SqliteSchemaVersionError"); expect(error.message).toContain("https://docs.openclaw.ai/reference/database-schemas"); }); + + it("names the refusing install and both schema versions", () => { + const error = createNewerSqliteSchemaVersionError("test database", "/tmp/test.sqlite", 12, 11); + + expect(error.message).toContain("uses newer schema version 12"); + expect(error.message).toContain("this build supports 11"); + expect(error.message).toContain(describeRunningOpenClawBuild()); + expect(error.message).toContain("supports schema 12 or newer"); + }); + + it("does not assert a downgrade the operator never performed", () => { + // Two builds sharing one release version can support different schemas (#115008). + // Telling the operator to upgrade or stop downgrading is unactionable and often wrong. + const error = createNewerSqliteSchemaVersionError("test database", "/tmp/test.sqlite", 12, 11); + + expect(error.message).not.toContain("Do not downgrade"); + expect(error.message).not.toContain("Upgrade OpenClaw"); + }); +}); + +describe("describeRunningOpenClawBuild", () => { + it("reports the version and the install root operators can act on", () => { + const described = describeRunningOpenClawBuild(); + + expect(described).toContain(VERSION); + expect(described).toContain("installed at "); + }); }); diff --git a/src/infra/sqlite-user-version.ts b/src/infra/sqlite-user-version.ts index 6c5006a9eb64..af8bd75cb45b 100644 --- a/src/infra/sqlite-user-version.ts +++ b/src/infra/sqlite-user-version.ts @@ -1,3 +1,8 @@ +import { OPENCLAW_DATABASE_SCHEMA_DOCS_URL } from "../state/openclaw-state-db-contract.js"; +import { VERSION } from "../version.js"; +import { resolveCommitHash } from "./git-commit.js"; +import { resolveOpenClawPackageRootSync } from "./openclaw-root.js"; + type SqliteUserVersionReader = { prepare: (sql: string) => { get: () => unknown }; }; @@ -7,6 +12,20 @@ export function readSqliteUserVersion(db: SqliteUserVersionReader): number { return Number(row?.user_version ?? 0); } +/** + * Name the refusing install the way `--version` does, plus the root it runs from. + * The path is the only part an operator can always act on: one release version + * string spans many commits, and a linked source checkout reports its git HEAD + * even when the built output actually executing is older. + */ +export function describeRunningOpenClawBuild(): string { + const moduleUrl = import.meta.url; + const commit = resolveCommitHash({ moduleUrl }); + const root = resolveOpenClawPackageRootSync({ moduleUrl }); + const identity = commit ? `OpenClaw ${VERSION} (${commit})` : `OpenClaw ${VERSION}`; + return root ? `${identity} installed at ${root}` : identity; +} + export function createNewerSqliteSchemaVersionError( databaseLabel: string, pathname: string, @@ -14,7 +33,11 @@ export function createNewerSqliteSchemaVersionError( supportedVersion: number, ): Error { const error = new Error( - `${databaseLabel} ${pathname} uses newer schema version ${schemaVersion}; this OpenClaw build supports ${supportedVersion}. Upgrade OpenClaw before opening this database. Do not downgrade OpenClaw or modify the database. To run this older build, use a separate state directory or restore a compatible backup. See https://docs.openclaw.ai/reference/database-schemas.`, + `${databaseLabel} ${pathname} uses newer schema version ${schemaVersion}; this build supports ${supportedVersion}. ` + + `Refused by ${describeRunningOpenClawBuild()}. ` + + "Identify installs by that path: one version string spans many builds, and a linked source checkout reports its git HEAD even when its built output is older. " + + `Run a build that supports schema ${schemaVersion} or newer against this state directory — rebuild or update the install above — or point this build at a different OPENCLAW_STATE_DIR. ` + + `See ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.`, ); error.name = "SqliteSchemaVersionError"; return error; diff --git a/src/state/openclaw-database-preflight.ts b/src/state/openclaw-database-preflight.ts index 27213738f790..0ccbf6abfe95 100644 --- a/src/state/openclaw-database-preflight.ts +++ b/src/state/openclaw-database-preflight.ts @@ -7,7 +7,10 @@ import { getNodeSqliteKysely, } from "../infra/kysely-sync.js"; import { openNodeSqliteDatabase } from "../infra/node-sqlite.js"; -import { readSqliteUserVersion } from "../infra/sqlite-user-version.js"; +import { + describeRunningOpenClawBuild, + readSqliteUserVersion, +} from "../infra/sqlite-user-version.js"; import type { OpenClawSchemaVersions } from "./openclaw-schema-versions.js"; import type { DB as OpenClawStateKyselyDatabase } from "./openclaw-state-db.generated.js"; import { @@ -44,7 +47,8 @@ type AgentRegistryDatabase = Pick