Matrix: consolidate migration status routing (#64373)

Merged via squash.

Prepared head SHA: dfe29e36bb
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-10 12:20:13 -04:00
committed by GitHub
parent 2ccd1839f2
commit 0dd8ce72a2
14 changed files with 280 additions and 60 deletions

View File

@@ -112,6 +112,7 @@ Docs: https://docs.openclaw.ai
- Agents/exec: prevent gateway crash ("Agent listener invoked outside active run") when a subagent exec tool produces stdout/stderr after the agent run has ended or been aborted. (#62821) Thanks @openperf.
- Browser/tabs: route `/tabs/action` close/select through the same browser endpoint reachability and policy checks as list/new (including Playwright-backed remote tab operations), reject CDP HTTP redirects on probe requests, and sanitize blocked-endpoint error responses so tab list/focus/close flows fail closed without echoing raw policy details back to callers. (#63332)
- Gateway/OpenAI compat: return real `usage` for non-stream `/v1/chat/completions` responses, emit the final usage chunk when `stream_options.include_usage=true`, and bound usage-gated stream finalization after lifecycle end. (#62986) Thanks @Lellansin.
- Matrix/migration: keep packaged warning-only crypto migrations from being misclassified as actionable when only helper chunks are present, so startup and doctor stay on the warning-only path instead of creating unnecessary migration snapshots. (#64373) Thanks @gumadeiras.
## 2026.4.9

View File

@@ -22,7 +22,7 @@ function buildConfig(
} as OpenClawConfig;
}
describe("matrix native approval adapter", () => {
describe("matrix approval capability", () => {
it("describes the correct Matrix exec-approval setup path", () => {
const text = matrixApprovalCapability.describeExecApprovalSetup?.({
channel: "matrix",

View File

@@ -18,11 +18,15 @@ vi.mock("./matrix-migration.runtime.js", async () => {
);
return {
...actual,
hasActionableMatrixMigration: vi.fn(() => false),
hasPendingMatrixMigration: vi.fn(() => false),
maybeCreateMatrixMigrationSnapshot: vi.fn(),
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
resolveMatrixMigrationStatus: vi.fn(() => ({
legacyState: null,
legacyCrypto: { warnings: [], plans: [] },
pending: false,
actionable: false,
})),
};
});
@@ -93,7 +97,12 @@ describe("matrix doctor", () => {
it("surfaces matrix sequence warnings and repair changes", async () => {
const runtimeApi = await import("./matrix-migration.runtime.js");
vi.mocked(runtimeApi.hasActionableMatrixMigration).mockReturnValue(true);
vi.mocked(runtimeApi.resolveMatrixMigrationStatus).mockReturnValue({
legacyState: null,
legacyCrypto: { warnings: [], plans: [] },
pending: true,
actionable: true,
});
vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
archivePath: "/tmp/matrix-backup.tgz",
created: true,

View File

@@ -14,9 +14,8 @@ import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
detectLegacyMatrixState,
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
import { isRecord } from "./record-shared.js";
@@ -135,17 +134,13 @@ export async function applyMatrixDoctorRepair(params: {
}): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: params.cfg,
env: params.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
let matrixSnapshotReady = true;
if (actionableMatrixMigration) {
if (migrationStatus.actionable) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
@@ -163,7 +158,7 @@ export async function applyMatrixDoctorRepair(params: {
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
);
}
} else if (pendingMatrixMigration) {
} else if (migrationStatus.pending) {
warnings.push(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
);
@@ -224,15 +219,6 @@ export async function runMatrixDoctorSequence(params: {
return { changeNotes, warningNotes };
}
const legacyState = detectLegacyMatrixState({
cfg: params.cfg,
env: params.env,
});
const legacyCrypto = detectLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
});
if (params.shouldRepair) {
const repair = await applyMatrixDoctorRepair({
cfg: params.cfg,
@@ -240,16 +226,24 @@ export async function runMatrixDoctorSequence(params: {
});
changeNotes.push(...repair.changes);
warningNotes.push(...repair.warnings);
} else if (legacyState) {
if ("warning" in legacyState) {
warningNotes.push(`- ${legacyState.warning}`);
} else {
warningNotes.push(formatMatrixLegacyStatePreview(legacyState));
} else {
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
if (migrationStatus.legacyState) {
if ("warning" in migrationStatus.legacyState) {
warningNotes.push(`- ${migrationStatus.legacyState.warning}`);
} else {
warningNotes.push(formatMatrixLegacyStatePreview(migrationStatus.legacyState));
}
}
if (
migrationStatus.legacyCrypto.warnings.length > 0 ||
migrationStatus.legacyCrypto.plans.length > 0
) {
warningNotes.push(...formatMatrixLegacyCryptoPreview(migrationStatus.legacyCrypto));
}
}
if (!params.shouldRepair && (legacyCrypto.warnings.length > 0 || legacyCrypto.plans.length > 0)) {
warningNotes.push(...formatMatrixLegacyCryptoPreview(legacyCrypto));
}
return { changeNotes, warningNotes };

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const availabilityState = vi.hoisted(() => ({
currentFilePath: "/virtual/dist/matrix-migration.runtime.js",
existingPaths: new Set<string>(),
dirEntries: [] as Array<{ name: string; isFile: () => boolean }>,
}));
vi.mock("node:fs", async () => {
const { mockNodeBuiltinModule } = await import("../../../test/helpers/node-builtin-mocks.js");
return mockNodeBuiltinModule(
() => vi.importActual<typeof import("node:fs")>("node:fs"),
{
existsSync: (candidate: unknown) => availabilityState.existingPaths.has(String(candidate)),
readdirSync: () => availabilityState.dirEntries as never,
},
{ mirrorToDefault: true },
);
});
vi.mock("node:url", async () => {
const actual = await vi.importActual<typeof import("node:url")>("node:url");
return {
...actual,
fileURLToPath: () => availabilityState.currentFilePath,
};
});
const { isMatrixLegacyCryptoInspectorAvailable } =
await import("./legacy-crypto-inspector-availability.js");
describe("isMatrixLegacyCryptoInspectorAvailable", () => {
beforeEach(() => {
availabilityState.currentFilePath = "/virtual/dist/matrix-migration.runtime.js";
availabilityState.existingPaths.clear();
availabilityState.dirEntries = [];
});
it("detects the source inspector module directly", () => {
availabilityState.currentFilePath =
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js";
availabilityState.existingPaths.add(
"/virtual/extensions/matrix/src/matrix/legacy-crypto-inspector.ts",
);
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("detects hashed built inspector chunks", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(true);
});
it("does not confuse the availability helper artifact with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
it("does not confuse hashed availability helper chunks with the real inspector", () => {
availabilityState.dirEntries = [
{
name: "legacy-crypto-inspector-availability-TPlLnFSE.js",
isFile: () => true,
},
];
expect(isMatrixLegacyCryptoInspectorAvailable()).toBe(false);
});
});

View File

@@ -2,7 +2,31 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const LEGACY_CRYPTO_INSPECTOR_BASENAME_RE = /^legacy-crypto-inspector(?:[-.].*)?\.js$/u;
const LEGACY_CRYPTO_INSPECTOR_FILE = "legacy-crypto-inspector.js";
const LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX = "legacy-crypto-inspector-";
const LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX = "availability-";
const JAVASCRIPT_MODULE_SUFFIX = ".js";
function isLegacyCryptoInspectorArtifactName(name: string): boolean {
if (name === LEGACY_CRYPTO_INSPECTOR_FILE) {
return true;
}
if (
!name.startsWith(LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX) ||
!name.endsWith(JAVASCRIPT_MODULE_SUFFIX)
) {
return false;
}
const chunkSuffix = name.slice(
LEGACY_CRYPTO_INSPECTOR_CHUNK_PREFIX.length,
-JAVASCRIPT_MODULE_SUFFIX.length,
);
return (
chunkSuffix.length > 0 &&
chunkSuffix !== "availability" &&
!chunkSuffix.startsWith(LEGACY_CRYPTO_INSPECTOR_HELPER_CHUNK_PREFIX)
);
}
function hasSourceInspectorArtifact(currentDir: string): boolean {
return [
@@ -20,7 +44,7 @@ function hasBuiltInspectorArtifact(currentDir: string): boolean {
}
return fs
.readdirSync(currentDir, { withFileTypes: true })
.some((entry) => entry.isFile() && LEGACY_CRYPTO_INSPECTOR_BASENAME_RE.test(entry.name));
.some((entry) => entry.isFile() && isLegacyCryptoInspectorArtifactName(entry.name));
}
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {

View File

@@ -92,6 +92,7 @@ describe("matrix legacy encrypted-state migration", () => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(true);
expect(detection.warnings).toEqual([]);
expect(detection.plans).toHaveLength(1);
@@ -210,6 +211,7 @@ describe("matrix legacy encrypted-state migration", () => {
const { cfg } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(false);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",

View File

@@ -57,6 +57,7 @@ type MatrixLegacyCryptoPlan = {
};
type MatrixLegacyCryptoDetection = {
inspectorAvailable: boolean;
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
@@ -324,13 +325,20 @@ export function detectLegacyMatrixCrypto(params: {
cfg: params.cfg,
env: params.env ?? process.env,
});
if (detection.plans.length > 0 && !isMatrixLegacyCryptoInspectorAvailable()) {
const inspectorAvailable =
detection.plans.length === 0 || isMatrixLegacyCryptoInspectorAvailable();
if (!inspectorAvailable && detection.plans.length > 0) {
return {
inspectorAvailable,
plans: detection.plans,
warnings: [...detection.warnings, MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE],
};
}
return detection;
return {
inspectorAvailable,
plans: detection.plans,
warnings: detection.warnings,
};
}
export async function autoPrepareLegacyMatrixCrypto(params: {
@@ -359,7 +367,7 @@ export async function autoPrepareLegacyMatrixCrypto(params: {
warnings,
};
}
if (!params.deps?.inspectLegacyStore && !isMatrixLegacyCryptoInspectorAvailable()) {
if (!params.deps?.inspectLegacyStore && !detection.inspectorAvailable) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,

View File

@@ -1,4 +1,9 @@
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
export { hasActionableMatrixMigration, hasPendingMatrixMigration } from "./migration-snapshot.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./migration-snapshot.js";
export { maybeCreateMatrixMigrationSnapshot } from "./migration-snapshot-backup.js";

View File

@@ -124,6 +124,7 @@ describe("matrix migration snapshots", () => {
cfg,
env: process.env,
});
expect(detection.inspectorAvailable).toBe(true);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toEqual([]);
expect(
@@ -167,6 +168,7 @@ describe("matrix migration snapshots", () => {
cfg,
env: process.env,
});
expect(detection.inspectorAvailable).toBe(false);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toContain(
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",

View File

@@ -1,5 +1,4 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { detectLegacyMatrixState } from "./legacy-state.js";
import {
@@ -9,30 +8,43 @@ import {
type MatrixMigrationSnapshotResult,
} from "./migration-snapshot-backup.js";
export type MatrixMigrationStatus = {
legacyState: ReturnType<typeof detectLegacyMatrixState>;
legacyCrypto: ReturnType<typeof detectLegacyMatrixCrypto>;
pending: boolean;
actionable: boolean;
};
export function resolveMatrixMigrationStatus(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixMigrationStatus {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const actionableLegacyState = legacyState !== null && !("warning" in legacyState);
const actionableLegacyCrypto = legacyCrypto.plans.length > 0 && legacyCrypto.inspectorAvailable;
return {
legacyState,
legacyCrypto,
pending:
legacyState !== null || legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0,
actionable: actionableLegacyState || actionableLegacyCrypto,
};
}
export function hasPendingMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 || legacyCrypto.warnings.length > 0;
return resolveMatrixMigrationStatus(params).pending;
}
export function hasActionableMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
const env = params.env ?? process.env;
const legacyState = detectLegacyMatrixState({ cfg: params.cfg, env });
if (legacyState && !("warning" in legacyState)) {
return true;
}
const legacyCrypto = detectLegacyMatrixCrypto({ cfg: params.cfg, env });
return legacyCrypto.plans.length > 0 && isMatrixLegacyCryptoInspectorAvailable();
return resolveMatrixMigrationStatus(params).actionable;
}
export {

View File

@@ -1,8 +1,18 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../../test/helpers/temp-home.js";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
async function seedLegacyMatrixState(home: string) {
const stateDir = path.join(home, ".openclaw");
@@ -26,6 +36,22 @@ function makeMatrixStartupConfig(includeCredentials = true) {
} as const;
}
async function seedLegacyMatrixCrypto(home: string) {
const stateDir = path.join(home, ".openclaw");
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
await fs.mkdir(path.join(rootDir, "crypto"), { recursive: true });
await fs.writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
"utf8",
);
}
function createSuccessfulMatrixMigrationDeps() {
return {
maybeCreateMatrixMigrationSnapshot: vi.fn(async () => ({
@@ -42,6 +68,10 @@ function createSuccessfulMatrixMigrationDeps() {
}
describe("runMatrixStartupMaintenance", () => {
beforeEach(() => {
legacyCryptoInspectorAvailability.available = true;
});
it("creates a snapshot before actionable startup migration", async () => {
await withTempHome(async (home) => {
await seedLegacyMatrixState(home);
@@ -78,6 +108,7 @@ describe("runMatrixStartupMaintenance", () => {
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const info = vi.fn();
const warn = vi.fn();
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(false),
@@ -87,7 +118,7 @@ describe("runMatrixStartupMaintenance", () => {
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never,
},
log: { info },
log: { info, warn },
});
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
@@ -96,6 +127,41 @@ describe("runMatrixStartupMaintenance", () => {
expect(info).toHaveBeenCalledWith(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
expect(warn).toHaveBeenCalledWith(expect.stringContaining("could not be resolved yet"));
});
});
it("logs the concrete unavailable-inspector warning when startup migration is warning-only", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
await seedLegacyMatrixCrypto(home);
const maybeCreateMatrixMigrationSnapshotMock = vi.fn();
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const info = vi.fn();
const warn = vi.fn();
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock as never,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never,
},
log: { info, warn },
});
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled();
expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled();
expect(info).toHaveBeenCalledWith(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
expect(warn).toHaveBeenCalledWith(
"matrix: legacy encrypted-state warnings:\n- Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
});
});

View File

@@ -2,9 +2,9 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import {
autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto,
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
type MatrixStartupLogger = {
@@ -12,6 +12,21 @@ type MatrixStartupLogger = {
warn?: (message: string) => void;
};
function logWarningOnlyMatrixMigrationReasons(params: {
status: MatrixMigrationStatus;
log: MatrixStartupLogger;
}): void {
if (params.status.legacyState && "warning" in params.status.legacyState) {
params.log.warn?.(`matrix: ${params.status.legacyState.warning}`);
}
if (params.status.legacyCrypto.warnings.length > 0) {
params.log.warn?.(
`matrix: legacy encrypted-state warnings:\n${params.status.legacyCrypto.warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
}
async function runBestEffortMatrixMigrationStep(params: {
label: string;
log: MatrixStartupLogger;
@@ -48,16 +63,16 @@ export async function runMatrixStartupMaintenance(params: {
params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto;
const trigger = params.trigger?.trim() || "gateway-startup";
const logPrefix = params.logPrefix?.trim() || "gateway";
const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env });
const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env });
const migrationStatus = resolveMatrixMigrationStatus({ cfg: params.cfg, env });
if (!pending) {
if (!migrationStatus.pending) {
return;
}
if (!actionable) {
if (!migrationStatus.actionable) {
params.log.info?.(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
logWarningOnlyMatrixMigrationReasons({ status: migrationStatus, log: params.log });
return;
}

View File

@@ -19,6 +19,7 @@ type MatrixLegacyCryptoPlan = {
};
type MatrixLegacyCryptoDetection = {
inspectorAvailable: boolean;
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};