diff --git a/docs/channels/matrix-migration.md b/docs/channels/matrix-migration.md index da8aca62f91f..317f5c17d60a 100644 --- a/docs/channels/matrix-migration.md +++ b/docs/channels/matrix-migration.md @@ -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 diff --git a/extensions/matrix/doctor-contract-api.import.test.ts b/extensions/matrix/doctor-contract-api.import.test.ts index 1190d8898de8..0864ab84f427 100644 --- a/extensions/matrix/doctor-contract-api.import.test.ts +++ b/extensions/matrix/doctor-contract-api.import.test.ts @@ -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"); diff --git a/extensions/matrix/src/matrix/account-state-schema-doctor.ts b/extensions/matrix/src/matrix/account-state-schema-doctor.ts index 1da4e82bfc86..e5b08bf49bd4 100644 --- a/extensions/matrix/src/matrix/account-state-schema-doctor.ts +++ b/extensions/matrix/src/matrix/account-state-schema-doctor.ts @@ -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 { 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(); } diff --git a/extensions/matrix/src/matrix/client/storage.test.ts b/extensions/matrix/src/matrix/client/storage.test.ts index c310126c5eb2..fd5a950e8b2a 100644 --- a/extensions/matrix/src/matrix/client/storage.test.ts +++ b/extensions/matrix/src/matrix/client/storage.test.ts @@ -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): 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({ diff --git a/extensions/matrix/src/matrix/client/storage.ts b/extensions/matrix/src/matrix/client/storage.ts index 63d962c86ee1..9e301db30aeb 100644 --- a/extensions/matrix/src/matrix/client/storage.ts +++ b/extensions/matrix/src/matrix/client/storage.ts @@ -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( @@ -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], diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts index 1bdbcb27b742..7375c7441f20 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.test.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.test.ts @@ -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(); diff --git a/extensions/matrix/src/matrix/monitor/startup-verification.ts b/extensions/matrix/src/matrix/monitor/startup-verification.ts index f230ffb8219c..eac1d862f8d4 100644 --- a/extensions/matrix/src/matrix/monitor/startup-verification.ts +++ b/extensions/matrix/src/matrix/monitor/startup-verification.ts @@ -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({ - 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 { - const value = await tryReadJson(filePath); - return value && typeof value === "object" ? value : null; -} - async function readStartupVerificationState(params: { auth: MatrixAuth; env?: NodeJS.ProcessEnv; stateDir?: string; - legacyFilePath: string; }): Promise { - 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 { 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 { 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") { diff --git a/extensions/matrix/src/matrix/retired-state.ts b/extensions/matrix/src/matrix/retired-state.ts new file mode 100644 index 000000000000..2b471686a992 --- /dev/null +++ b/extensions/matrix/src/matrix/retired-state.ts @@ -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 { + try { + await fs.lstat(filePath); + } catch (error) { + if (hasNodeErrorCode(error, "ENOENT")) { + return; + } + throw error; + } + throw new Error(describeRetiredMatrixState(filePath)); +} diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index 76d96a821df8..9c2b4e441586 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -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(); diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 3a9d92210401..5d9190208a26 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -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({ - 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(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) {