fix: schema-version refusal blames a downgrade that never happened and cannot identify the stale install (#115232)

* fix(state): name the refusing install in schema and exec-approval refusals

The newer-schema refusal told operators not to downgrade and to upgrade
OpenClaw, neither of which is actionable when two builds share one release
version string. It now names the install root that refused, both schema
versions, and warns that a linked source checkout reports its git HEAD even
when its built dist is older.

The exec-approvals gate told operators to run `openclaw doctor --fix` without
naming the state directory, so a bare invocation repaired the default root
while the scoped install stayed blocked. Both the TypeScript gate and its
Swift sibling now scope the command to the blocked state directory.

Refs #115008

* fix(gateway): name the refusing install in the startup schema refusal

* fix(mac): keep the exec-approvals gate message buildable on iOS

* fix(exec-approvals): shell-quote the state directory in the repair command

* fix(exec-approvals): state the repair directory in prose so every shell can follow it
This commit is contained in:
Peter Steinberger
2026-07-28 12:21:43 -04:00
committed by GitHub
parent 4d9588dfed
commit 40fb1ca27e
8 changed files with 111 additions and 7 deletions

View File

@@ -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.",
])
}
}

View File

@@ -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))
}
}
}

View File

@@ -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

View File

@@ -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<string, boolean>();
/**
* 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";
}

View File

@@ -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],

View File

@@ -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 ");
});
});

View File

@@ -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;

View File

@@ -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<OpenClawStateKyselyDatabase, "agent_databases"
export class OpenClawDatabaseSchemaPreflightError extends Error {
constructor(readonly incompatibleDatabases: readonly IncompatibleOpenClawDatabase[]) {
super(
`Gateway refused startup because ${incompatibleDatabases.length} OpenClaw database schema(s) are newer than this build. See ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.`,
`Gateway refused startup because ${incompatibleDatabases.length} OpenClaw database schema(s) are newer than this build. ` +
`Refused by ${describeRunningOpenClawBuild()}. See ${OPENCLAW_DATABASE_SCHEMA_DOCS_URL}.`,
);
this.name = "OpenClawDatabaseSchemaPreflightError";
}