mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor(channels): drop pre-July-2026 Matrix state migrations (#163292)
## What Problem This Solves
Retires Matrix state imports whose last shipped JSON writers predate the July 2026 migration support window.
## User Impact
Upgrades from pre-July-2026 versions are no longer migrated: Matrix `thread-bindings.json` and `startup-verification.json`. Doctor and the affected runtime preserve those files and explain how to upgrade through OpenClaw 2026.9.5: run Doctor, start Matrix once to import them, then install the latest release.
July and newer SQLite bindings, verification cooldowns, device metadata, token-root selection, and schema upgrades remain supported. Public plugin SDK compatibility and other Matrix migrations are unchanged.
## Why This Change Was Made
Both file writers ended in `84a965a1a2` on May 30. The last confirmed stable JSON writer, `v2026.5.28`, was published May 30; `v2026.5.30-beta.1` first shipped SQLite on May 31, followed by stable `v2026.6.1` on June 3. Inspection of `v2026.7.1` confirms the same SQLite namespaces and keys, with no JSON rewrite or restoration path. The 2026.9.5 bridge retains both runtime imports.
Deletes JSON decoding, merging, import-marker stores, cleanup writes, and the obsolete thread-binding JSON root score. The plugin owns the source-preserving refusal; existing Doctor ordering and schema migration ownership stay intact. Net production reduction: 140 lines.
Other channel config aliases remain: supported July config rewrites can still preserve them. Separate owner follow-ups will cover independently proven formats.
## Evidence
- Independent isolated review: no actionable findings through P2.
- Matrix runtime/storage/Doctor boundary tests passed, including July-shaped binding keys and verification cooldowns, unchanged retired-file bytes, and archive exclusion.
- The intact shipped `v2026.7.1` account SQLite fixture upgraded through the actual Doctor CLI; canonical rows and archived database bytes remained intact.
- Doctor state/config migration suites passed. Plugin contracts: **1,132 tests / 48 files passed**.
- `node scripts/check-changed.mjs` passed; both `check-madge-import-cycles.ts` and `check-import-cycles.ts` report **0**.
- Linux single-worker wall measurements (`pnpm test <file> --maxWorkers=1`): Doctor import contract **2.89s**, storage **29.49s**, bindings **15.10s**, startup verification **13.73s**. No new process boot, timer, retry, or weakened assertion was added.
Proof used an isolated AWS checkout pinned to the base and byte-verified candidate patch. Hosted CI will validate the pushed head. Release history and retention decisions were checked separately from test execution.
This commit is contained in:
parent
8095d778b0
commit
db793644e0
10 changed files with 257 additions and 230 deletions
|
|
@ -34,6 +34,16 @@ Doctor migration covers:
|
|||
- importing file-based sidecar state (`bot-storage.json` sync cache, `recovery-key.json`, `legacy-crypto-migration.json`, IndexedDB snapshots) into Matrix SQLite state; migrated files are archived with a `.migrated` suffix
|
||||
- reusing the most complete existing token-hash storage root for the same Matrix account, homeserver, user, and device when the access token changes later
|
||||
|
||||
## Retired pre-July state files
|
||||
|
||||
Current releases support state formats written on or after July 1, 2026.
|
||||
Matrix thread bindings and startup-verification cooldowns already used SQLite
|
||||
by then. Their older `thread-bindings.json` and `startup-verification.json`
|
||||
imports are retired. Doctor and the affected runtime refuse these files and
|
||||
leave them unchanged. If they remain in an active Matrix storage root, first
|
||||
install OpenClaw `2026.9.5`, run `openclaw doctor --fix`, and start the Matrix
|
||||
channel once to complete the import. Then upgrade to the latest release.
|
||||
|
||||
## Upgrading from OpenClaw releases older than 2026.4
|
||||
|
||||
Releases through the 2026.6 train also migrated the original flat single-store
|
||||
|
|
|
|||
|
|
@ -22,6 +22,59 @@ vi.mock("./src/account-selection.js", () => {
|
|||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
it("refuses retired JSON state without changing it or inspecting token-root archives", async () => {
|
||||
const stateDir = tempDirs.make("matrix-retired-state-");
|
||||
const sources = [
|
||||
path.join(stateDir, "matrix", "accounts", "default", "thread-bindings.json"),
|
||||
path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"ops",
|
||||
"matrix.example.org__bot",
|
||||
"0123456789abcdef",
|
||||
"startup-verification.json",
|
||||
),
|
||||
];
|
||||
const archive = path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"ops",
|
||||
"matrix.example.org__bot",
|
||||
"sync-cache-backup",
|
||||
"thread-bindings.json",
|
||||
);
|
||||
for (const file of [...sources, archive]) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, '{"retained":true}\n');
|
||||
}
|
||||
const { stateMigrations } = await import("./doctor-contract-api.js");
|
||||
const migration = stateMigrations.find((entry) => entry.id === "matrix-account-sqlite-schema")!;
|
||||
const openPluginStateKeyedStore = vi.fn(() => {
|
||||
throw new Error("retired state must not open a store");
|
||||
});
|
||||
const params = {
|
||||
config: {},
|
||||
env: { HOME: stateDir, OPENCLAW_STATE_DIR: stateDir },
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: { openPluginStateKeyedStore },
|
||||
};
|
||||
for (const run of [migration.detectLegacyState, migration.migrateLegacyState]) {
|
||||
const result = run(params);
|
||||
await expect(result).rejects.toThrow("Install OpenClaw 2026.9.5");
|
||||
for (const file of sources) {
|
||||
await expect(result).rejects.toThrow(file);
|
||||
}
|
||||
await expect(result).rejects.not.toThrow(archive);
|
||||
}
|
||||
expect(openPluginStateKeyedStore).not.toHaveBeenCalled();
|
||||
for (const file of [...sources, archive]) {
|
||||
expect(fs.readFileSync(file, "utf8")).toBe('{"retained":true}\n');
|
||||
}
|
||||
});
|
||||
|
||||
it("completes absent legacy-state checks without loading client runtimes", async () => {
|
||||
const stateDir = tempDirs.make("openclaw-matrix-doctor-import-");
|
||||
const { stateMigrations } = await import("./doctor-contract-api.js");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
import path from "node:path";
|
||||
import type { OpenClawStateDatabaseSchemaMigration } from "openclaw/plugin-sdk/doctor-repair-runtime";
|
||||
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor-migrations";
|
||||
import { describeRetiredMatrixState } from "./retired-state.js";
|
||||
import { resolveMatrixSqliteStateEnv } from "./sqlite-state.js";
|
||||
import { walkMatrixStateFiles } from "./state-layout-walk.js";
|
||||
|
||||
|
|
@ -10,12 +11,19 @@ const STATE_DATABASE_FILENAME = "openclaw.sqlite";
|
|||
async function collectMatrixAccountStateRoots(stateDir: string): Promise<string[]> {
|
||||
const { entries, failedDirs } = await walkMatrixStateFiles(
|
||||
stateDir,
|
||||
(name, depth) => depth === 5 && name === STATE_DATABASE_FILENAME,
|
||||
(name, depth) =>
|
||||
(depth === 5 && name === STATE_DATABASE_FILENAME) ||
|
||||
((depth === 2 || depth === 4) &&
|
||||
(name === "thread-bindings.json" || name === "startup-verification.json")),
|
||||
[2, 4],
|
||||
);
|
||||
if (failedDirs.length > 0) {
|
||||
throw failedDirs[0]!.error;
|
||||
}
|
||||
const retired = entries.filter((entry) => path.basename(entry.path) !== STATE_DATABASE_FILENAME);
|
||||
if (retired.length > 0) {
|
||||
throw new Error(retired.map((entry) => describeRetiredMatrixState(entry.path)).join("\n"));
|
||||
}
|
||||
return entries.map((entry) => path.dirname(path.dirname(entry.path))).toSorted();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
|
@ -116,8 +117,8 @@ describe("matrix client storage paths", () => {
|
|||
}
|
||||
|
||||
async function setupCurrentTokenBackfillScenario(params: {
|
||||
currentRootFiles: "thread-bindings" | "startup-verification";
|
||||
oldRootFiles: "crypto-only" | "thread-bindings";
|
||||
currentRootState: "thread-bindings" | "startup-verification";
|
||||
oldRootState: "crypto-only" | "thread-bindings";
|
||||
}) {
|
||||
const stateDir = setupStateDir();
|
||||
const canonicalPaths = resolveMatrixAccountStorageRoot({
|
||||
|
|
@ -134,29 +135,15 @@ describe("matrix client storage paths", () => {
|
|||
accessTokenHash: canonicalPaths.tokenHash,
|
||||
deviceId: null,
|
||||
});
|
||||
if (params.currentRootFiles === "thread-bindings") {
|
||||
writeJson(canonicalPaths.rootDir, "thread-bindings.json", {
|
||||
version: 1,
|
||||
bindings: [
|
||||
{
|
||||
accountId: "default",
|
||||
conversationId: "$thread-new",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:ops:subagent:new",
|
||||
boundAt: 1,
|
||||
lastActivityAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (params.currentRootState === "thread-bindings") {
|
||||
seedThreadBinding(canonicalPaths.rootDir, "new");
|
||||
expect(
|
||||
await claimCurrentTokenStorageState({
|
||||
rootDir: canonicalPaths.rootDir,
|
||||
}),
|
||||
).toBe(true);
|
||||
} else {
|
||||
writeJson(canonicalPaths.rootDir, "startup-verification.json", {
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
seedStartupVerification(canonicalPaths.rootDir, "DEVICE123");
|
||||
}
|
||||
|
||||
const oldStoragePaths = await seedExistingStorageRoot({
|
||||
|
|
@ -165,24 +152,10 @@ describe("matrix client storage paths", () => {
|
|||
storageMeta: await storageMetaFor("secret-token-old"),
|
||||
});
|
||||
fs.mkdirSync(oldStoragePaths.cryptoPath, { recursive: true });
|
||||
if (params.oldRootFiles === "thread-bindings") {
|
||||
writeJson(oldStoragePaths.rootDir, "thread-bindings.json", {
|
||||
version: 1,
|
||||
bindings: [
|
||||
{
|
||||
accountId: "default",
|
||||
conversationId: "$thread-old",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:ops:subagent:old",
|
||||
boundAt: 1,
|
||||
lastActivityAt: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
if (params.oldRootState === "thread-bindings") {
|
||||
seedThreadBinding(oldStoragePaths.rootDir, "old");
|
||||
} else {
|
||||
writeJson(oldStoragePaths.rootDir, "startup-verification.json", {
|
||||
deviceId: "DEVICE123",
|
||||
});
|
||||
seedStartupVerification(oldStoragePaths.rootDir, "DEVICE123");
|
||||
}
|
||||
|
||||
return { stateDir, canonicalPaths, oldStoragePaths };
|
||||
|
|
@ -243,6 +216,38 @@ describe("matrix client storage paths", () => {
|
|||
).register("current", value);
|
||||
}
|
||||
|
||||
function seedThreadBinding(rootDir: string, suffix: "new" | "old"): void {
|
||||
const conversationId = `$thread-${suffix}`;
|
||||
const digest = createHash("sha256").update(`default\0\0${conversationId}`).digest("hex");
|
||||
createPluginStateSyncKeyedStoreForTests("matrix", {
|
||||
namespace: "thread-bindings",
|
||||
maxEntries: 10_000,
|
||||
env: { OPENCLAW_STATE_DIR: rootDir },
|
||||
}).register(`default:${digest}`, {
|
||||
accountId: "default",
|
||||
conversationId,
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: `agent:ops:subagent:${suffix}`,
|
||||
boundAt: 1,
|
||||
lastActivityAt: 1,
|
||||
});
|
||||
}
|
||||
|
||||
function seedStartupVerification(rootDir: string, deviceId: string): void {
|
||||
createPluginStateSyncKeyedStoreForTests("matrix", {
|
||||
namespace: "startup-verification",
|
||||
maxEntries: 1_000,
|
||||
env: { OPENCLAW_STATE_DIR: rootDir },
|
||||
}).register("default", {
|
||||
userId: defaultStorageAuth.userId,
|
||||
deviceId,
|
||||
attemptedAt: "2026-07-13T12:00:00.000Z",
|
||||
outcome: "requested",
|
||||
requestId: "verification-1",
|
||||
transactionId: "txn-1",
|
||||
});
|
||||
}
|
||||
|
||||
function seedLegacyStorageMeta(rootDir: string, value: Record<string, unknown>): void {
|
||||
fs.mkdirSync(rootDir, { recursive: true });
|
||||
writeJson(rootDir, "storage-meta.json", value);
|
||||
|
|
@ -395,9 +400,7 @@ describe("matrix client storage paths", () => {
|
|||
seedStorageMeta(storagePaths.rootDir, params.storageMeta);
|
||||
}
|
||||
if (params.startupVerificationDeviceId) {
|
||||
writeJson(storagePaths.rootDir, "startup-verification.json", {
|
||||
deviceId: params.startupVerificationDeviceId,
|
||||
});
|
||||
seedStartupVerification(storagePaths.rootDir, params.startupVerificationDeviceId);
|
||||
}
|
||||
return storagePaths;
|
||||
}
|
||||
|
|
@ -779,7 +782,7 @@ describe("matrix client storage paths", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it.each(["thread-bindings.json", "recovery-key.json", "crypto-idb-snapshot.json"])(
|
||||
it.each(["recovery-key.json", "crypto-idb-snapshot.json"])(
|
||||
"keeps a legacy %s root selectable until its state migrates",
|
||||
async (legacyFilename) => {
|
||||
const stateDir = setupStateDir();
|
||||
|
|
@ -886,8 +889,8 @@ describe("matrix client storage paths", () => {
|
|||
|
||||
it("keeps the current-token storage root stable after deviceId backfill when startup claimed state there", async () => {
|
||||
const { stateDir, canonicalPaths } = await setupCurrentTokenBackfillScenario({
|
||||
currentRootFiles: "thread-bindings",
|
||||
oldRootFiles: "crypto-only",
|
||||
currentRootState: "thread-bindings",
|
||||
oldRootState: "crypto-only",
|
||||
});
|
||||
|
||||
await repairCurrentTokenStorageMetaDeviceId({
|
||||
|
|
@ -911,10 +914,10 @@ describe("matrix client storage paths", () => {
|
|||
expect(restartedPaths.rootDir).toBe(canonicalPaths.rootDir);
|
||||
});
|
||||
|
||||
it("does not keep the current-token storage root sticky when only marker files exist after backfill", async () => {
|
||||
it("does not keep the current-token storage root sticky when only startup verification state exists after backfill", async () => {
|
||||
const { stateDir, oldStoragePaths } = await setupCurrentTokenBackfillScenario({
|
||||
currentRootFiles: "startup-verification",
|
||||
oldRootFiles: "thread-bindings",
|
||||
currentRootState: "startup-verification",
|
||||
oldRootState: "thread-bindings",
|
||||
});
|
||||
|
||||
await repairCurrentTokenStorageMetaDeviceId({
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ import type { MatrixAuth, MatrixStoragePaths } from "./types.js";
|
|||
|
||||
const DEFAULT_ACCOUNT_KEY = "default";
|
||||
const STORAGE_META_FILENAME = "storage-meta.json";
|
||||
const THREAD_BINDINGS_FILENAME = "thread-bindings.json";
|
||||
|
||||
function openStorageMetaStore(rootDir: string) {
|
||||
return getMatrixRuntime().state.openKeyedStore<MatrixStorageMetadata>(
|
||||
|
|
@ -44,7 +43,6 @@ async function scoreStorageRoot(rootDir: string, metadata: MatrixStorageMetadata
|
|||
}
|
||||
for (const [filename, weight] of [
|
||||
["crypto", 8],
|
||||
[THREAD_BINDINGS_FILENAME, 4],
|
||||
[MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME, 3],
|
||||
[MATRIX_RECOVERY_KEY_FILENAME, 2],
|
||||
[MATRIX_IDB_SNAPSHOT_FILENAME, 2],
|
||||
|
|
|
|||
|
|
@ -199,26 +199,66 @@ describe("ensureMatrixStartupVerification", () => {
|
|||
expect(harness.client.crypto.requestVerification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("supports disabling startup verification requests", async () => {
|
||||
it.each([
|
||||
{ mode: "if-unverified" as const, expected: "cooldown" },
|
||||
{ mode: "off" as const, expected: "disabled" },
|
||||
])("honors July SQLite startup state in $mode mode", async ({ mode, expected }) => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
fs.writeFileSync(stateFilePath, JSON.stringify({ attemptedAt: "2026-03-08T12:00:00.000Z" }));
|
||||
const store = createPluginStateKeyedStoreForTests("matrix", {
|
||||
namespace: "startup-verification",
|
||||
maxEntries: 1_000,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: tempHome },
|
||||
});
|
||||
const state = {
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
attemptedAt: "2026-07-13T12:00:00.000Z",
|
||||
outcome: "requested",
|
||||
requestId: "verification-1",
|
||||
transactionId: "txn-1",
|
||||
};
|
||||
await store.register("default", state);
|
||||
|
||||
const result = await ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: createAuth(),
|
||||
accountConfig: {
|
||||
startupVerification: "off",
|
||||
},
|
||||
accountConfig: { startupVerification: mode },
|
||||
stateFilePath,
|
||||
nowMs: Date.parse("2026-07-13T12:01:00.000Z"),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("disabled");
|
||||
expect(result.kind).toBe(expected);
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
await expect(store.lookup("default")).resolves.toEqual(mode === "off" ? undefined : state);
|
||||
expect(fs.existsSync(stateFilePath)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(["if-unverified", "off"] as const)(
|
||||
"refuses retired startup JSON in %s mode without changing it",
|
||||
async (startupVerification) => {
|
||||
const tempHome = createTempStateDir();
|
||||
const stateFilePath = createStateFilePath(tempHome);
|
||||
const source = JSON.stringify({ attemptedAt: "2026-05-28T12:00:00.000Z" });
|
||||
fs.writeFileSync(stateFilePath, source);
|
||||
const harness = createHarness();
|
||||
|
||||
await expect(
|
||||
ensureMatrixStartupVerification({
|
||||
client: harness.client as never,
|
||||
auth: createAuth(),
|
||||
accountConfig: { startupVerification },
|
||||
stateFilePath,
|
||||
}),
|
||||
).rejects.toThrow(/2026\.9\.5/);
|
||||
|
||||
expect(fs.readFileSync(stateFilePath, "utf8")).toBe(source);
|
||||
expect(fs.existsSync(path.join(tempHome, "state"))).toBe(false);
|
||||
expect(harness.client.crypto.requestVerification).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("persists a successful startup verification request", async () => {
|
||||
const tempHome = createTempStateDir();
|
||||
const harness = createHarness();
|
||||
|
|
|
|||
|
|
@ -1,19 +1,16 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tryReadJson } from "@openclaw/fs-safe/json";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixConfig } from "../../types.js";
|
||||
import { recordCurrentStorageMetaDeviceId, resolveMatrixStoragePaths } from "../client/storage.js";
|
||||
import type { MatrixAuth } from "../client/types.js";
|
||||
import { assertMatrixSupportedStateFile } from "../retired-state.js";
|
||||
import type { MatrixClient, MatrixOwnDeviceVerificationStatus } from "../sdk.js";
|
||||
import { resolveMatrixSqliteStateEnv } from "../sqlite-state.js";
|
||||
|
||||
const STARTUP_VERIFICATION_STATE_FILENAME = "startup-verification.json";
|
||||
const STARTUP_VERIFICATION_NAMESPACE = "startup-verification";
|
||||
const STARTUP_VERIFICATION_MIGRATIONS_NAMESPACE = "startup-verification-migrations";
|
||||
const STARTUP_VERIFICATION_MAX_ENTRIES = 1_000;
|
||||
const DEFAULT_STARTUP_VERIFICATION_MODE = "if-unverified" as const;
|
||||
const DEFAULT_STARTUP_VERIFICATION_COOLDOWN_HOURS = 24;
|
||||
|
|
@ -29,10 +26,6 @@ type MatrixStartupVerificationState = {
|
|||
error?: string;
|
||||
};
|
||||
|
||||
type MatrixStartupVerificationMigrationMarker = {
|
||||
importedAt: number;
|
||||
};
|
||||
|
||||
export type MatrixStartupVerificationOutcome =
|
||||
| {
|
||||
kind: "disabled" | "verified" | "cooldown" | "pending" | "requested" | "request-failed";
|
||||
|
|
@ -83,124 +76,44 @@ function createStartupVerificationStore(params: { env?: NodeJS.ProcessEnv; state
|
|||
});
|
||||
}
|
||||
|
||||
function createStartupVerificationMigrationStore(params: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
}) {
|
||||
return getMatrixRuntime().state.openKeyedStore<MatrixStartupVerificationMigrationMarker>({
|
||||
namespace: STARTUP_VERIFICATION_MIGRATIONS_NAMESPACE,
|
||||
maxEntries: STARTUP_VERIFICATION_MAX_ENTRIES,
|
||||
env: resolveMatrixSqliteStateEnv(params),
|
||||
});
|
||||
}
|
||||
|
||||
function buildStartupVerificationImportKey(params: {
|
||||
auth: MatrixAuth;
|
||||
legacyFilePath: string;
|
||||
}): string {
|
||||
const accountId = buildStartupVerificationKey(params.auth);
|
||||
const digest = createHash("sha256")
|
||||
.update(accountId)
|
||||
.update("\0")
|
||||
.update(params.legacyFilePath)
|
||||
.digest("hex");
|
||||
return `${accountId}:${digest}`;
|
||||
}
|
||||
|
||||
async function readLegacyStartupVerificationState(
|
||||
filePath: string,
|
||||
): Promise<MatrixStartupVerificationState | null> {
|
||||
const value = await tryReadJson<MatrixStartupVerificationState>(filePath);
|
||||
return value && typeof value === "object" ? value : null;
|
||||
}
|
||||
|
||||
async function readStartupVerificationState(params: {
|
||||
auth: MatrixAuth;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
legacyFilePath: string;
|
||||
}): Promise<MatrixStartupVerificationState | null> {
|
||||
const store = createStartupVerificationStore(params);
|
||||
const key = buildStartupVerificationKey(params.auth);
|
||||
const value = await store.lookup(key);
|
||||
if (value && typeof value === "object") {
|
||||
return value;
|
||||
}
|
||||
const migrationStore = createStartupVerificationMigrationStore(params);
|
||||
const legacyImportKey = buildStartupVerificationImportKey({
|
||||
auth: params.auth,
|
||||
legacyFilePath: params.legacyFilePath,
|
||||
});
|
||||
if (await migrationStore.lookup(legacyImportKey)) {
|
||||
return null;
|
||||
}
|
||||
const legacy = await readLegacyStartupVerificationState(params.legacyFilePath);
|
||||
if (legacy) {
|
||||
await store
|
||||
.register(key, legacy)
|
||||
.then(async () => {
|
||||
if (typeof legacy.deviceId === "string" && legacy.deviceId.trim()) {
|
||||
await recordCurrentStorageMetaDeviceId({
|
||||
rootDir: path.dirname(params.legacyFilePath),
|
||||
deviceId: legacy.deviceId,
|
||||
});
|
||||
}
|
||||
await migrationStore.register(legacyImportKey, { importedAt: Date.now() });
|
||||
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
return legacy;
|
||||
const value = await createStartupVerificationStore(params).lookup(
|
||||
buildStartupVerificationKey(params.auth),
|
||||
);
|
||||
return value && typeof value === "object" ? value : null;
|
||||
}
|
||||
|
||||
async function writeStartupVerificationState(params: {
|
||||
auth: MatrixAuth;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
legacyFilePath: string;
|
||||
storageRootDir: string;
|
||||
state: MatrixStartupVerificationState;
|
||||
}): Promise<void> {
|
||||
await createStartupVerificationStore(params).register(
|
||||
buildStartupVerificationKey(params.auth),
|
||||
params.state,
|
||||
);
|
||||
await createStartupVerificationMigrationStore(params)
|
||||
.register(
|
||||
buildStartupVerificationImportKey({
|
||||
auth: params.auth,
|
||||
legacyFilePath: params.legacyFilePath,
|
||||
}),
|
||||
{ importedAt: Date.now() },
|
||||
)
|
||||
.catch(() => {});
|
||||
if (typeof params.state.deviceId === "string" && params.state.deviceId.trim()) {
|
||||
await recordCurrentStorageMetaDeviceId({
|
||||
rootDir: path.dirname(params.legacyFilePath),
|
||||
rootDir: params.storageRootDir,
|
||||
deviceId: params.state.deviceId,
|
||||
});
|
||||
}
|
||||
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
async function clearStartupVerificationState(params: {
|
||||
auth: MatrixAuth;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
stateDir?: string;
|
||||
legacyFilePath: string;
|
||||
}): Promise<void> {
|
||||
await createStartupVerificationStore(params)
|
||||
.delete(buildStartupVerificationKey(params.auth))
|
||||
.catch(() => {});
|
||||
await createStartupVerificationMigrationStore(params)
|
||||
.register(
|
||||
buildStartupVerificationImportKey({
|
||||
auth: params.auth,
|
||||
legacyFilePath: params.legacyFilePath,
|
||||
}),
|
||||
{ importedAt: Date.now() },
|
||||
)
|
||||
.catch(() => {});
|
||||
await fs.rm(params.legacyFilePath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
function resolveStateCooldownMs(
|
||||
|
|
@ -285,11 +198,12 @@ export async function ensureMatrixStartupVerification(params: {
|
|||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
}));
|
||||
await assertMatrixSupportedStateFile(statePath);
|
||||
const stateLocation = {
|
||||
auth: params.auth,
|
||||
env: params.env,
|
||||
stateDir: params.stateDir ?? path.dirname(statePath),
|
||||
legacyFilePath: statePath,
|
||||
storageRootDir: path.dirname(statePath),
|
||||
};
|
||||
const mode = params.accountConfig.startupVerification ?? DEFAULT_STARTUP_VERIFICATION_MODE;
|
||||
if (verification.verified || mode === "off") {
|
||||
|
|
|
|||
18
extensions/matrix/src/matrix/retired-state.ts
Normal file
18
extensions/matrix/src/matrix/retired-state.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import fs from "node:fs/promises";
|
||||
import { hasNodeErrorCode } from "@openclaw/fs-safe/path";
|
||||
|
||||
export function describeRetiredMatrixState(filePath: string): string {
|
||||
return `Retired pre-July Matrix state at ${filePath} was left unchanged. Install OpenClaw 2026.9.5, run "openclaw doctor --fix", and start the Matrix channel once to migrate it, then upgrade to latest. See https://docs.openclaw.ai/channels/matrix-migration.`;
|
||||
}
|
||||
|
||||
export async function assertMatrixSupportedStateFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
await fs.lstat(filePath);
|
||||
} catch (error) {
|
||||
if (hasNodeErrorCode(error, "ENOENT")) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error(describeRetiredMatrixState(filePath));
|
||||
}
|
||||
|
|
@ -212,6 +212,67 @@ describe("matrix thread bindings", () => {
|
|||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("loads and updates thread bindings written by the July SQLite store", async () => {
|
||||
const bindingsPath = await resolveBindingsFilePath();
|
||||
const store = createPluginStateKeyedStoreForTests("matrix", {
|
||||
namespace: "thread-bindings",
|
||||
maxEntries: 10_000,
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: path.dirname(bindingsPath) },
|
||||
});
|
||||
// v2026.7.1: account id plus sha256(account id, parent room, thread), NUL-separated.
|
||||
const key = "ops:df4863925b4d3d0c82df25928e615588bf4a6bd9544af82cf52100573eba9b8b";
|
||||
const stored = {
|
||||
accountId,
|
||||
conversationId: "$thread",
|
||||
parentConversationId: "!room:example",
|
||||
targetKind: "subagent",
|
||||
targetSessionKey: "agent:ops:subagent:child",
|
||||
agentId: "ops",
|
||||
boundBy: "system",
|
||||
boundAt: 1_783_944_000_000,
|
||||
lastActivityAt: 1_783_944_000_000,
|
||||
idleTimeoutMs,
|
||||
maxAgeMs: 0,
|
||||
};
|
||||
await store.register(key, stored);
|
||||
|
||||
const manager = await createBindingManager();
|
||||
const binding = getSessionBindingService().resolveByConversation(currentThreadConversation());
|
||||
expect(binding?.targetSessionKey).toBe(stored.targetSessionKey);
|
||||
expect(binding?.boundAt).toBe(stored.boundAt);
|
||||
if (!binding) {
|
||||
throw new Error("expected July Matrix thread binding");
|
||||
}
|
||||
getSessionBindingService().touch(binding.bindingId, stored.lastActivityAt + 1_000);
|
||||
await manager.stop();
|
||||
|
||||
expect((await store.entries()).map((entry) => entry.key)).toEqual([key]);
|
||||
await expect(store.lookup(key)).resolves.toMatchObject({
|
||||
...stored,
|
||||
lastActivityAt: stored.lastActivityAt + 1_000,
|
||||
});
|
||||
expect(fsSync.existsSync(bindingsPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("refuses retired thread-binding JSON without importing or deleting it", async () => {
|
||||
const bindingsPath = await resolveBindingsFilePath();
|
||||
const source = JSON.stringify({
|
||||
version: 1,
|
||||
bindings: [{ conversationId: "$thread", targetSessionKey: "agent:ops:subagent:child" }],
|
||||
});
|
||||
await fs.mkdir(path.dirname(bindingsPath), { recursive: true });
|
||||
await fs.writeFile(bindingsPath, source);
|
||||
|
||||
await expect(createBindingManager()).rejects.toThrow(/2026\.9\.5/);
|
||||
|
||||
expect(await fs.readFile(bindingsPath, "utf8")).toBe(source);
|
||||
expect(fsSync.existsSync(path.join(path.dirname(bindingsPath), "state"))).toBe(false);
|
||||
expect(
|
||||
getSessionBindingService().resolveByConversation(currentThreadConversation()),
|
||||
).toBeNull();
|
||||
expect(sendMessageMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("creates child Matrix thread bindings from a top-level room context", async () => {
|
||||
await createBindingManager();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { tryReadJson } from "@openclaw/fs-safe/json";
|
||||
import { resolveSessionAgentIdStrict } from "openclaw/plugin-sdk/agent-scope-runtime";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolveAgentIdFromSessionKey } from "openclaw/plugin-sdk/session-key-runtime";
|
||||
|
|
@ -16,6 +14,7 @@ import {
|
|||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import { claimCurrentTokenStorageState, resolveMatrixStateFilePath } from "./client/storage.js";
|
||||
import type { MatrixAuth } from "./client/types.js";
|
||||
import { assertMatrixSupportedStateFile } from "./retired-state.js";
|
||||
import type { MatrixClient } from "./sdk.js";
|
||||
import { sendMessageMatrix } from "./send.js";
|
||||
import { resolveMatrixSqliteStateEnv, resolveMatrixSqliteStateKey } from "./sqlite-state.js";
|
||||
|
|
@ -34,22 +33,11 @@ import {
|
|||
type MatrixThreadBindingRecord,
|
||||
} from "./thread-bindings-shared.js";
|
||||
|
||||
const STORE_VERSION = 1;
|
||||
const THREAD_BINDINGS_NAMESPACE = "thread-bindings";
|
||||
const THREAD_BINDINGS_MIGRATIONS_NAMESPACE = "thread-bindings-migrations";
|
||||
const THREAD_BINDINGS_MAX_ENTRIES = 10_000;
|
||||
const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000;
|
||||
const TOUCH_PERSIST_DELAY_MS = 30_000;
|
||||
|
||||
type StoredMatrixThreadBindingState = {
|
||||
version: number;
|
||||
bindings: MatrixThreadBindingRecord[];
|
||||
};
|
||||
|
||||
type MatrixThreadBindingMigrationMarker = {
|
||||
importedAt: number;
|
||||
};
|
||||
|
||||
async function resolveBindingsPath(params: {
|
||||
auth: MatrixAuth;
|
||||
accountId: string;
|
||||
|
|
@ -73,14 +61,6 @@ function createThreadBindingStore(params: { env?: NodeJS.ProcessEnv; stateDir?:
|
|||
});
|
||||
}
|
||||
|
||||
function createThreadBindingMigrationStore(params: { env?: NodeJS.ProcessEnv; stateDir?: string }) {
|
||||
return getMatrixRuntime().state.openKeyedStore<MatrixThreadBindingMigrationMarker>({
|
||||
namespace: THREAD_BINDINGS_MIGRATIONS_NAMESPACE,
|
||||
maxEntries: 1_000,
|
||||
env: resolveMatrixSqliteStateEnv(params),
|
||||
});
|
||||
}
|
||||
|
||||
function buildThreadBindingStoreKey(record: {
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
|
|
@ -96,18 +76,6 @@ function buildThreadBindingStoreKey(record: {
|
|||
return `${record.accountId}:${digest}`;
|
||||
}
|
||||
|
||||
function buildLegacyThreadBindingsImportKey(params: {
|
||||
accountId: string;
|
||||
legacyFilePath: string;
|
||||
}): string {
|
||||
const digest = createHash("sha256")
|
||||
.update(params.accountId)
|
||||
.update("\0")
|
||||
.update(params.legacyFilePath)
|
||||
.digest("hex");
|
||||
return `${params.accountId}:${digest}`;
|
||||
}
|
||||
|
||||
function normalizeBindingRecord(
|
||||
entry: unknown,
|
||||
accountId: string,
|
||||
|
|
@ -155,21 +123,6 @@ function normalizeBindingRecord(
|
|||
};
|
||||
}
|
||||
|
||||
async function loadBindingsFromLegacyDisk(filePath: string, accountId: string) {
|
||||
const value = await tryReadJson<StoredMatrixThreadBindingState>(filePath);
|
||||
if (value?.version !== STORE_VERSION || !Array.isArray(value.bindings)) {
|
||||
return [];
|
||||
}
|
||||
const loaded: MatrixThreadBindingRecord[] = [];
|
||||
for (const entry of value.bindings) {
|
||||
const record = normalizeBindingRecord(entry, accountId);
|
||||
if (record) {
|
||||
loaded.push(record);
|
||||
}
|
||||
}
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function loadBindingsFromPluginState(params: {
|
||||
accountId: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
|
|
@ -305,6 +258,7 @@ export async function createMatrixThreadBindingManager(params: {
|
|||
env: params.env,
|
||||
stateDir: params.stateDir,
|
||||
});
|
||||
await assertMatrixSupportedStateFile(legacyFilePath);
|
||||
const sqliteStateDir = path.dirname(legacyFilePath);
|
||||
const storageKey = resolveMatrixSqliteStateKey({ env: params.env, stateDir: sqliteStateDir });
|
||||
const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId);
|
||||
|
|
@ -314,32 +268,11 @@ export async function createMatrixThreadBindingManager(params: {
|
|||
}
|
||||
await existingEntry.manager.stop();
|
||||
}
|
||||
const pluginLoaded = await loadBindingsFromPluginState({
|
||||
const loaded = await loadBindingsFromPluginState({
|
||||
accountId: params.accountId,
|
||||
env: params.env,
|
||||
stateDir: sqliteStateDir,
|
||||
});
|
||||
const migrationStore = createThreadBindingMigrationStore({
|
||||
env: params.env,
|
||||
stateDir: sqliteStateDir,
|
||||
});
|
||||
const legacyImportKey = buildLegacyThreadBindingsImportKey({
|
||||
accountId: params.accountId,
|
||||
legacyFilePath,
|
||||
});
|
||||
const pluginLoadedKeys = new Set(
|
||||
pluginLoaded.map((record) => buildThreadBindingStoreKey(record)),
|
||||
);
|
||||
let legacyHadRows = false;
|
||||
let legacyLoaded: MatrixThreadBindingRecord[] = [];
|
||||
if (!(await migrationStore.lookup(legacyImportKey))) {
|
||||
const legacyCandidates = await loadBindingsFromLegacyDisk(legacyFilePath, params.accountId);
|
||||
legacyHadRows = legacyCandidates.length > 0;
|
||||
legacyLoaded = legacyCandidates.filter(
|
||||
(record) => !pluginLoadedKeys.has(buildThreadBindingStoreKey(record)),
|
||||
);
|
||||
}
|
||||
const loaded = [...pluginLoaded, ...legacyLoaded];
|
||||
for (const record of loaded) {
|
||||
setBindingRecord(record);
|
||||
}
|
||||
|
|
@ -373,17 +306,6 @@ export async function createMatrixThreadBindingManager(params: {
|
|||
idleTimeoutMs: params.idleTimeoutMs,
|
||||
maxAgeMs: params.maxAgeMs,
|
||||
};
|
||||
if (legacyHadRows) {
|
||||
if (legacyLoaded.length > 0) {
|
||||
await persist();
|
||||
}
|
||||
await migrationStore.register(legacyImportKey, { importedAt: Date.now() });
|
||||
await fs.rm(legacyFilePath, { force: true }).catch((err: unknown) => {
|
||||
params.logVerboseMessage?.(
|
||||
`matrix: failed removing migrated legacy thread bindings account=${params.accountId}: ${String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
let persistTimer: NodeJS.Timeout | null = null;
|
||||
const schedulePersist = (delayMs: number) => {
|
||||
if (persistTimer) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue