Files
openclaw/src/state/openclaw-state-ownership.ts
T
Peter Steinberger f31d9d8fa9 fix: prevent externally supervised state schema drift (#121069)
* fix: prevent externally supervised state schema drift

* refactor: isolate schema ownership support code

* test: follow canonical additive column order

* test: keep older schema fixture valid

* fix: preserve additive schema compatibility

* chore: remove release-owned changelog entry

* test: follow schema compatibility owner

* style: format schema compatibility test

* refactor: split sqlite schema sql helpers

* fix: preserve desktop schema compatibility

* fix: preserve ownership gates across platforms

* fix: preserve detached updater long paths

* fix: close external state ownership races

* chore: refresh plugin sdk api baseline
2026-08-09 14:04:40 -07:00

149 lines
5.0 KiB
TypeScript

import { existsSync } from "node:fs";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { isGatewayExternallySupervised } from "../infra/gateway-supervision.js";
import { openNodeSqliteDatabase, resolveImmutableSqliteFileUri } from "../infra/node-sqlite.js";
import { tableExists } from "./openclaw-state-db-schema-helpers.js";
export const STATE_SUPERVISION_KEY = "gateway.supervision";
const MAX_OWNERSHIP_TIMESTAMP_MS = 8_640_000_000_000_000;
const MANAGER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
export type OpenClawExternalStateOwnership = {
claimedAt: number;
managerId: string;
mode: "external";
version: 1;
};
export class OpenClawStateOwnershipError extends Error {}
export class OpenClawStateOwnershipMetadataError extends OpenClawStateOwnershipError {
constructor(
readonly databasePath: string,
message: string,
) {
super(
`OpenClaw shared state ownership metadata is invalid at ${databasePath}: ${message}. ` +
"Repair it with OPENCLAW_SUPERVISOR_MODE=external openclaw database ownership claim --manager <manager-id>.",
);
this.name = "OpenClawStateOwnershipMetadataError";
}
}
class OpenClawStateExternalOwnershipError extends OpenClawStateOwnershipError {
constructor(
readonly databasePath: string,
readonly managerId: string,
) {
super(
`OpenClaw shared state database ${databasePath} is externally supervised by ${managerId}. ` +
"Use that external supervisor with OPENCLAW_SUPERVISOR_MODE=external for writable operations.",
);
this.name = "OpenClawStateExternalOwnershipError";
}
}
export function normalizeOpenClawStateManagerId(managerId: string): string {
const normalized = managerId.trim();
if (!MANAGER_ID_PATTERN.test(normalized)) {
throw new Error(
"External state ownership manager id must be a 1-128 character ASCII identifier.",
);
}
return normalized;
}
function parseExternalOwnership(
valueJson: string,
databasePath: string,
): OpenClawExternalStateOwnership {
let value: unknown;
try {
value = JSON.parse(valueJson) as unknown;
} catch {
throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not valid JSON");
}
const record = isRecord(value) ? value : undefined;
const keys = record ? Object.keys(record).toSorted().join(",") : "";
const managerId = record?.managerId;
const claimedAt = record?.claimedAt;
if (
keys !== "claimedAt,managerId,mode,version" ||
record?.version !== 1 ||
record?.mode !== "external" ||
typeof managerId !== "string" ||
!MANAGER_ID_PATTERN.test(managerId) ||
typeof claimedAt !== "number" ||
!Number.isSafeInteger(claimedAt) ||
claimedAt < 0 ||
claimedAt > MAX_OWNERSHIP_TIMESTAMP_MS
) {
throw new OpenClawStateOwnershipMetadataError(
databasePath,
"reserved value does not match the version 1 external ownership contract",
);
}
return {
version: 1,
mode: "external",
managerId,
claimedAt,
};
}
/** Inspect the reserved ownership row without entering the shared-state lifecycle. */
export function inspectOpenClawStateOwnershipFromDatabase(
database: DatabaseSync,
databasePath: string,
): OpenClawExternalStateOwnership | null {
if (!tableExists(database, "config_machine_state")) {
return null;
}
const row = database
.prepare("SELECT value_json FROM config_machine_state WHERE state_key = ? LIMIT 1")
.get(STATE_SUPERVISION_KEY) as { value_json?: unknown } | undefined;
if (!row) {
return null;
}
if (typeof row.value_json !== "string") {
throw new OpenClawStateOwnershipMetadataError(databasePath, "reserved value is not text");
}
return parseExternalOwnership(row.value_json, databasePath);
}
/** Inspect one resolved state database path through a read-only connection. */
export function inspectOpenClawStateOwnershipAtPath(
databasePath: string,
): OpenClawExternalStateOwnership | null {
const resolvedPath = path.resolve(databasePath);
if (!existsSync(resolvedPath)) {
return null;
}
const database = openNodeSqliteDatabase(resolveImmutableSqliteFileUri(resolvedPath), {
readOnly: true,
});
try {
database.exec("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;");
return inspectOpenClawStateOwnershipFromDatabase(database, resolvedPath);
} finally {
database.close();
}
}
/** Fence shared-state writes once an external manager has claimed ownership. */
export function assertOpenClawStateWriteAllowed(options: {
database?: DatabaseSync;
databasePath: string;
env?: NodeJS.ProcessEnv;
}): void {
const resolvedPath = path.resolve(options.databasePath);
const status = options.database
? inspectOpenClawStateOwnershipFromDatabase(options.database, resolvedPath)
: inspectOpenClawStateOwnershipAtPath(resolvedPath);
if (status && !isGatewayExternallySupervised(options.env ?? process.env)) {
throw new OpenClawStateExternalOwnershipError(resolvedPath, status.managerId);
}
}