refactor: remove pre-2026.4 compatibility shims and legacy migrations (#104650)

* refactor(matrix): retire pre-2026.4 legacy crypto and flat-storage migrations

* refactor(cli): drop pre-tsdown daemon-cli dist compat shim from the build

* refactor(diffs): remove deprecated image*/format tool params and output aliases

* refactor(ui): drop pre-gateway-scoped localStorage readers and legacy theme map

* refactor: remove assorted pre-2026.4 compat shims and deprecated aliases

* fix(discord): map legacy thread-binding fields in the doctor JSON import

* fix(msteams): keep bot read fallback for legacy imported conversation rows

* test(msteams): cover legacy bot-only imported conversation references

* fix(ui): keep channel-prefixed session key display fallback

* chore: refresh native i18n inventory and build-docker step count

* chore(matrix): drop now-unused migration-config module

* chore: re-pin plugin SDK public export budget after compat removals

* chore: refresh native i18n inventory after rebase

* chore: re-pin plugin SDK callable budget and refresh i18n inventory after rebase
This commit is contained in:
Peter Steinberger
2026-07-11 13:22:27 -07:00
committed by GitHub
parent a4c1e22731
commit 8fe6ac7fb9
88 changed files with 203 additions and 4710 deletions
+2 -2
View File
@@ -15107,7 +15107,7 @@
},
{
"kind": "conditional-branch",
"line": 742,
"line": 739,
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
"source": "\\(user)@\\(host)",
"surface": "apple",
@@ -15115,7 +15115,7 @@
},
{
"kind": "conditional-branch",
"line": 742,
"line": 739,
"path": "apps/macos/Sources/OpenClaw/AppState.swift",
"source": "\\(user)@\\(host):\\(port)",
"surface": "apple",
@@ -325,9 +325,6 @@ final class AppState {
isPreview || ApplicationRelocator.currentBundleAllowsPersistentIntegration()
self.execApprovalsDefaultsAsyncResolver = execApprovalsDefaultsAsyncResolver
self.execApprovalsReadRetryDelay = execApprovalsReadRetryDelay
if !isPreview {
migrateLegacyDefaults()
}
let onboardingSeen = UserDefaults.standard.bool(forKey: onboardingSeenKey)
self.isPaused = UserDefaults.standard.bool(forKey: pauseDefaultsKey)
self.launchAtLogin = false
@@ -1,16 +0,0 @@
import Foundation
private let legacyDefaultsPrefix = "openclaw."
private let defaultsPrefix = "openclaw."
func migrateLegacyDefaults() {
let defaults = UserDefaults.standard
let snapshot = defaults.dictionaryRepresentation()
for (key, value) in snapshot where key.hasPrefix(legacyDefaultsPrefix) {
let suffix = key.dropFirst(legacyDefaultsPrefix.count)
let newKey = defaultsPrefix + suffix
if defaults.object(forKey: newKey) == nil {
defaults.set(value, forKey: newKey)
}
}
}
+15 -154
View File
@@ -25,67 +25,38 @@ into the root OpenClaw package.
## What the migration does automatically
Matrix migration runs when the gateway starts (through the loaded Matrix plugin), when you run [`openclaw doctor --fix`](/gateway/doctor), and as a fallback when the Matrix client starts and still finds old on-disk state. Before any actionable migration step mutates on-disk state, OpenClaw creates or reuses a focused recovery snapshot.
When you use `openclaw update`, the exact trigger depends on how OpenClaw is installed:
- source installs run a non-interactive `openclaw doctor --fix` pass during the update flow, then restart the gateway by default
- package-manager installs update the package, run `openclaw doctor --non-interactive --fix`, then rely on the default gateway restart so startup can finish Matrix migration
- if you use `openclaw update --no-restart`, startup-backed Matrix migration is deferred until you later run `openclaw doctor --fix` and restart the gateway
Matrix migration runs when you run [`openclaw doctor --fix`](/gateway/doctor), and as a fallback when the Matrix client starts and still finds file-based sidecar state next to its SQLite store.
Automatic migration covers:
- creating or reusing a pre-migration snapshot under `~/Backups/openclaw-migrations/`
- reusing your cached Matrix credentials
- keeping the same account selection and `channels.matrix` config
- moving the old flat Matrix sync store and crypto store into the current account-scoped location when the target account can be resolved safely
- 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
- extracting a previously saved Matrix room-key backup decryption key from the old rust crypto store, when that key exists locally
- reusing the most complete existing token-hash storage root for the same Matrix account, homeserver, user, and device when the access token changes later
- scanning sibling token-hash storage roots for pending encrypted-state restore metadata when the Matrix access token changed but the account/device identity stayed the same
- restoring backed-up room keys into the new crypto store on the next Matrix startup
Snapshot details:
## Upgrading from OpenClaw releases older than 2026.4
- OpenClaw writes a marker file at `~/.openclaw/matrix/migration-snapshot.json` after a successful snapshot so later startup and repair passes can reuse the same archive.
- These automatic Matrix migration snapshots back up config + state only (`includeWorkspace: false`).
- If Matrix only has warning-only migration state, for example because `userId` or `accessToken` is still missing, OpenClaw does not create the snapshot yet because no Matrix mutation is actionable.
- If the snapshot step fails, OpenClaw skips Matrix migration for that run instead of mutating state without a recovery point.
Releases through the 2026.6 train also migrated the original flat single-store
Matrix layout (`~/.openclaw/matrix/bot-storage.json` plus
`~/.openclaw/matrix/crypto/`) and prepared encrypted-state recovery from the
old rust crypto store. Current releases no longer carry that migration.
About multi-account upgrades:
If you are upgrading an installation that still uses the flat layout, first
upgrade to a 2026.6 release, run `openclaw doctor --fix`, and start the gateway
once so the flat store and any recoverable room keys are migrated. Then update
to the latest release.
- the flat Matrix store (`~/.openclaw/matrix/bot-storage.json` and `~/.openclaw/matrix/crypto/`) came from a single-store layout, so OpenClaw can only migrate it into one resolved Matrix account target
- already account-scoped legacy Matrix stores are detected and prepared per configured Matrix account
## What the migration cannot do automatically
The previous public Matrix plugin did **not** automatically create Matrix room-key backups. It persisted local crypto state and requested device verification, but it did not guarantee that your room keys were backed up to the homeserver.
That means some encrypted installs can only be migrated partially.
OpenClaw cannot automatically recover:
- local-only room keys that were never backed up
- encrypted state when the target Matrix account cannot be resolved yet because `homeserver`, `userId`, or `accessToken` are still unavailable
- encrypted state when the old crypto store has no recorded device ID for the account
- automatic migration of one shared flat Matrix store when multiple Matrix accounts are configured but `channels.matrix.defaultAccount` is not set
- custom plugin path installs that are pinned to a repo path instead of the standard Matrix package (surfaced by `openclaw doctor`)
- a missing recovery key when the old store had backed-up keys but did not keep the decryption key locally
If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade.
The previous public Matrix plugin did **not** automatically create Matrix room-key backups. If your old installation had local-only encrypted history that was never backed up, some older encrypted messages may remain unreadable after the upgrade regardless of the migration path.
## Recommended upgrade flow
1. Update OpenClaw and the Matrix plugin normally.
Prefer plain `openclaw update` without `--no-restart` so startup can finish the Matrix migration immediately.
2. Run:
```bash
openclaw doctor --fix
```
If Matrix has actionable migration work, doctor will create or reuse the pre-migration snapshot first and print the archive path.
3. Start or restart the gateway.
4. Check current verification and backup state:
@@ -135,77 +106,11 @@ If your old installation had local-only encrypted history that was never backed
openclaw matrix verify bootstrap
```
## How encrypted migration works
Encrypted migration is a two-stage process:
1. Startup or `openclaw doctor --fix` creates or reuses the pre-migration snapshot if encrypted migration is actionable, then inspects the old Matrix rust crypto store through the crypto inspector bundled with the Matrix plugin.
2. If a backup decryption key is found, OpenClaw imports it into Matrix SQLite state and marks room-key restore as pending.
3. On the next Matrix startup, OpenClaw restores backed-up room keys into the new crypto store automatically. Pending restore state is also picked up from sibling token-hash storage roots when the access token rotated in between.
If the old store reports room keys that were never backed up, OpenClaw warns instead of pretending recovery succeeded.
## Common messages and what they mean
### Upgrade and detection messages
`Matrix plugin upgraded in place.` (doctor) or `matrix: plugin upgraded in place for account "..."` (startup)
- Meaning: the old on-disk Matrix state was detected and migrated into the current layout.
- What to do: nothing unless the same output also includes warnings.
`Matrix migration snapshot created before applying Matrix upgrades.` / `Matrix migration snapshot reused before applying Matrix upgrades.`
- Meaning: doctor created a recovery archive before mutating Matrix state, or found an existing snapshot marker and reused that archive instead of creating a duplicate backup. Startup logs the same as `matrix: created pre-migration backup snapshot: ...` / `matrix: reusing existing pre-migration backup snapshot: ...`.
- What to do: keep the printed archive path until you confirm migration succeeded.
`Legacy Matrix state detected at ... but channels.matrix is not configured yet.`
- Meaning: old Matrix state exists, but OpenClaw cannot map it to a current Matrix account because Matrix is not configured.
- What to do: configure `channels.matrix`, then rerun `openclaw doctor --fix` or restart the gateway.
`Legacy Matrix state detected at ... but the new account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix...).`
- Meaning: OpenClaw found old state, but it still cannot determine the exact current account/device root.
- What to do: start the gateway once with a working Matrix login, or rerun `openclaw doctor --fix` after cached credentials exist.
`Legacy Matrix state detected at ... but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.`
- Meaning: OpenClaw found one shared flat Matrix store, but it refuses to guess which named Matrix account should receive it.
- What to do: set `channels.matrix.defaultAccount` to the intended account, then rerun `openclaw doctor --fix` or restart the gateway.
The same three warnings also appear with the prefix `Legacy Matrix encrypted state detected at ...` when the blocked store is the old encrypted crypto store.
`Matrix legacy sync store not migrated because the target already exists (...)` / `Matrix legacy crypto store not migrated because the target already exists (...)`
- Meaning: the new account-scoped location already has a sync or crypto store, so OpenClaw did not overwrite it automatically.
- What to do: verify that the current account is the correct one before manually removing or moving the conflicting target.
`Failed migrating Matrix legacy sync store (...)` or `Failed migrating Matrix legacy crypto store (...)`
- Meaning: OpenClaw tried to move old Matrix state but the filesystem operation failed.
- What to do: inspect filesystem permissions and disk state, then rerun `openclaw doctor --fix`.
`Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.`
- Meaning: OpenClaw detected old Matrix state, but the migration is still blocked on missing identity or credential data. Startup logs this as `matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet`.
- What to do: finish Matrix login or config setup, then rerun `openclaw doctor --fix` or restart the gateway.
`Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.`
- Meaning: OpenClaw found old encrypted Matrix state, but the Matrix plugin build is missing the crypto inspector module that inspects the old rust crypto store.
- What to do: reinstall or repair the Matrix plugin (`openclaw plugins install @openclaw/matrix`, or `openclaw plugins install ./path/to/local/matrix-plugin` for a repo checkout), then rerun `openclaw doctor --fix` or restart the gateway.
`- Failed creating a Matrix migration snapshot before repair: ...`
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
`Failed migrating legacy Matrix client storage: ...`
- Meaning: the Matrix client-side fallback found old storage, but the migration failed. OpenClaw rolls back completed moves and aborts that fallback instead of silently starting with a fresh store. This error also appears when the flat store targets a different account than the one currently starting.
- Meaning: the Matrix client-side fallback found file-based sidecar state, but the import into SQLite failed. OpenClaw rolls back completed moves and aborts that fallback instead of silently starting with a fresh store.
- What to do: inspect filesystem permissions or conflicts, keep the old state intact, and retry after fixing the error.
`Matrix is installed from a custom path: ...`
@@ -213,47 +118,10 @@ The same three warnings also appear with the prefix `Legacy Matrix encrypted sta
- Meaning: Matrix is pinned to a path install, so mainline updates do not automatically replace it with the default Matrix package.
- What to do: reinstall with `openclaw plugins install @openclaw/matrix` when you want to return to the default Matrix plugin.
### Encrypted-state recovery messages
`Matrix is installed from a custom path that no longer exists: ...`
`matrix: restored X/Y room key(s) from legacy encrypted-state backup`
- Meaning: backed-up room keys were restored successfully into the new crypto store.
- What to do: usually nothing.
`matrix: N legacy local-only room key(s) were never backed up and could not be restored automatically`
- Meaning: some old room keys existed only in the old local store and had never been uploaded to Matrix backup. During preparation the same limit is reported as `Legacy Matrix encrypted state for account "..." contains N room key(s) that were never backed up.`
- What to do: expect some old encrypted history to remain unavailable unless you can recover those keys manually from another verified client.
`Legacy Matrix encrypted state detected at ... but no device ID was found for account "..."`
- Meaning: the old crypto store does not record which Matrix device it belonged to, so OpenClaw cannot inspect it safely.
- What to do: old encrypted history cannot be recovered automatically; OpenClaw continues without it.
`Legacy Matrix encrypted state for account "..." has backed-up room keys, but no local backup decryption key was found. Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
- What to do: run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin` (preferred over passing the key as an argument).
`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...`
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin`.
`Legacy Matrix backup key was found for account "...", but Matrix SQLite state already contains a different recovery key. Leaving the existing state unchanged.`
- Meaning: OpenClaw detected a backup key conflict and refused to overwrite the current recovery-key state automatically.
- What to do: verify which recovery key is correct before retrying any restore command.
`Legacy Matrix encrypted state for account "..." cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`
- Meaning: this is the hard limit of the old storage format.
- What to do: backed-up keys can still be restored, but local-only encrypted history may remain unavailable.
`matrix: failed restoring room keys from legacy encrypted-state backup: ...`
- Meaning: the new plugin attempted restore but Matrix returned an error.
- What to do: run `openclaw matrix verify backup status`, then retry with `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin` if needed.
- Meaning: your plugin install record points at a local path that is gone.
- What to do: reinstall with `openclaw plugins install @openclaw/matrix`, or if you are running from a repo checkout, `openclaw plugins install ./path/to/local/matrix-plugin`. `openclaw doctor --fix` can also remove the stale Matrix plugin references for you.
### Manual recovery messages
@@ -291,13 +159,6 @@ current backup baseline with `openclaw matrix verify backup reset --yes`. When t
stored backup secret is broken, that reset also repairs secret storage so the
new backup key can load correctly after restart.
### Custom plugin install messages
`Matrix is installed from a custom path that no longer exists: ...`
- Meaning: your plugin install record points at a local path that is gone.
- What to do: reinstall with `openclaw plugins install @openclaw/matrix`, or if you are running from a repo checkout, `openclaw plugins install ./path/to/local/matrix-plugin`. `openclaw doctor --fix` can also remove the stale Matrix plugin references for you.
## If encrypted history still does not come back
Run these checks in order:
+1 -5
View File
@@ -538,14 +538,10 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- Route: /channels/matrix-migration
- Headings:
- H2: What the migration does automatically
- H2: What the migration cannot do automatically
- H2: Upgrading from OpenClaw releases older than 2026.4
- H2: Recommended upgrade flow
- H2: How encrypted migration works
- H2: Common messages and what they mean
- H3: Upgrade and detection messages
- H3: Encrypted-state recovery messages
- H3: Manual recovery messages
- H3: Custom plugin install messages
- H2: If encrypted history still does not come back
- H2: If you want to start fresh for future messages
- H2: Related
+5 -24
View File
@@ -126,16 +126,6 @@ All fields are optional unless noted.
</ParamField>
<AccordionGroup>
<Accordion title="Legacy input aliases">
Still accepted for backward compatibility:
- `format` -> `fileFormat`
- `imageFormat` -> `fileFormat`
- `imageQuality` -> `fileQuality`
- `imageScale` -> `fileScale`
- `imageMaxWidth` -> `fileMaxWidth`
</Accordion>
<Accordion title="Validation and limits">
- `before`/`after`: max 512 KiB each.
- `patch`: max 2 MiB.
@@ -200,22 +190,13 @@ All successful results include `changed`: identical before/after input returns `
- `fileMaxWidth`
</Accordion>
<Accordion title="Compatibility aliases (always returned)">
- `format` (= `fileFormat`)
- `imagePath` (= `filePath`)
- `imageBytes` (= `fileBytes`)
- `imageQuality` (= `fileQuality`)
- `imageScale` (= `fileScale`)
- `imageMaxWidth` (= `fileMaxWidth`)
</Accordion>
</AccordionGroup>
| Mode | Returns |
| -------- | ------------------------------------------------------------------------------------------------------------ |
| `"view"` | Viewer fields only. |
| `"file"` | File fields only, no viewer artifact. |
| `"both"` | Viewer fields plus file fields. If file rendering fails, viewer still returns with `fileError`/`imageError`. |
| Mode | Returns |
| -------- | ----------------------------------------------------------------------------------------------- |
| `"view"` | Viewer fields only. |
| `"file"` | File fields only, no viewer artifact. |
| `"both"` | Viewer fields plus file fields. If file rendering fails, viewer still returns with `fileError`. |
### Collapsed unchanged sections
-8
View File
@@ -76,14 +76,6 @@ Useful options:
- `baseUrl`: override the gateway base URL used in the returned viewer link (origin or origin+base path only; no query/hash)
- `viewerBaseUrl` plugin config: persistent fallback used when a tool call omits `baseUrl`
Legacy input aliases still accepted for backward compatibility:
- `format` -> `fileFormat`
- `imageFormat` -> `fileFormat`
- `imageQuality` -> `fileQuality`
- `imageScale` -> `fileScale`
- `imageMaxWidth` -> `fileMaxWidth`
Input safety limits:
- `before` and `after`: max 512 KiB each
-1
View File
@@ -181,7 +181,6 @@ describe("resolveDiffsPluginDefaults", () => {
expect(resolveDiffImageRenderOptions({ defaults }).format).toBe("pdf");
expect(resolveDiffImageRenderOptions({ defaults, fileFormat: "png" }).format).toBe("png");
expect(resolveDiffImageRenderOptions({ defaults, format: "png" }).format).toBe("png");
});
it("accepts format as a config alias for fileFormat", () => {
+5 -15
View File
@@ -388,14 +388,9 @@ function normalizeTtlSeconds(ttlSeconds?: number): number {
export function resolveDiffImageRenderOptions(params: {
defaults: DiffFileDefaults;
fileFormat?: DiffOutputFormat;
format?: DiffOutputFormat;
fileQuality?: DiffImageQualityPreset;
fileScale?: number;
fileMaxWidth?: number;
imageFormat?: DiffOutputFormat;
imageQuality?: DiffImageQualityPreset;
imageScale?: number;
imageMaxWidth?: number;
}): {
format: DiffOutputFormat;
qualityPreset: DiffImageQualityPreset;
@@ -403,22 +398,17 @@ export function resolveDiffImageRenderOptions(params: {
maxWidth: number;
maxPixels: number;
} {
const format = normalizeFileFormat(
params.fileFormat ?? params.imageFormat ?? params.format ?? params.defaults.fileFormat,
);
const qualityOverrideProvided =
params.fileQuality !== undefined || params.imageQuality !== undefined;
const qualityPreset = normalizeFileQuality(
params.fileQuality ?? params.imageQuality ?? params.defaults.fileQuality,
);
const format = normalizeFileFormat(params.fileFormat ?? params.defaults.fileFormat);
const qualityOverrideProvided = params.fileQuality !== undefined;
const qualityPreset = normalizeFileQuality(params.fileQuality ?? params.defaults.fileQuality);
const profile = DEFAULT_IMAGE_QUALITY_PROFILES[qualityPreset];
const scale = normalizeFileScale(
params.fileScale ?? params.imageScale,
params.fileScale,
qualityOverrideProvided ? profile.scale : params.defaults.fileScale,
);
const maxWidth = normalizeFileMaxWidth(
params.fileMaxWidth ?? params.imageMaxWidth,
params.fileMaxWidth,
qualityOverrideProvided ? profile.maxWidth : params.defaults.fileMaxWidth,
);
+3 -54
View File
@@ -165,14 +165,10 @@ describe("diffs tool", () => {
expect(result?.content).toHaveLength(1);
const details = readDetails(result);
expect(requireString(details.filePath, "filePath")).toMatch(/preview\.png$/);
expect(requireString(details.imagePath, "imagePath")).toMatch(/preview\.png$/);
expect(details.format).toBe("png");
expect(details.fileFormat).toBe("png");
expect(details.fileQuality).toBe("standard");
expect(details.imageQuality).toBe("standard");
expect(details.fileScale).toBe(2);
expect(details.imageScale).toBe(2);
expect(details.fileMaxWidth).toBe(960);
expect(details.imageMaxWidth).toBe(960);
expect(details.viewerUrl).toBeUndefined();
expect(cleanupSpy).toHaveBeenCalledTimes(1);
});
@@ -200,7 +196,7 @@ describe("diffs tool", () => {
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect(readTextContent(result, 0)).toContain("Diff PDF generated at:");
expect((result.details as Record<string, unknown>).format).toBe("pdf");
expect((result.details as Record<string, unknown>).fileFormat).toBe("pdf");
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
@@ -302,52 +298,6 @@ describe("diffs tool", () => {
}
});
it("accepts image* tool options for backward compatibility", async () => {
const screenshotter = createPngScreenshotter({
assertImage: (image) => {
expect(image.qualityPreset).toBe("hq");
expect(image.scale).toBe(2.4);
expect(image.maxWidth).toBe(1100);
},
});
const tool = createToolWithScreenshotter(store, screenshotter);
const result = await tool.execute?.("tool-2legacy", {
before: "one\n",
after: "two\n",
mode: "file",
imageQuality: "hq",
imageScale: "2.4",
imageMaxWidth: "1100",
});
expect((result.details as Record<string, unknown>).fileQuality).toBe("hq");
expect((result.details as Record<string, unknown>).fileScale).toBe(2.4);
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1100);
});
it("accepts deprecated format alias for fileFormat", async () => {
const screenshotter = createPdfScreenshotter();
const tool = createDiffsTool({
api: createApi(),
store,
defaults: DEFAULT_DIFFS_TOOL_DEFAULTS,
screenshotter,
});
const result = await tool.execute?.("tool-2format", {
before: "one\n",
after: "two\n",
mode: "file",
format: "pdf",
});
expect((result.details as Record<string, unknown>).fileFormat).toBe("pdf");
expect((result.details as Record<string, unknown>).filePath).toMatch(/preview\.pdf$/);
});
it("honors defaults.mode=file when mode is omitted", async () => {
const screenshotter = createPngScreenshotter();
const tool = createToolWithScreenshotter(store, screenshotter, {
@@ -384,7 +334,6 @@ describe("diffs tool", () => {
expect(result?.content).toHaveLength(1);
expect(readTextContent(result, 0)).toContain("File rendering failed");
expect((result.details as Record<string, unknown>).fileError).toBe("browser missing");
expect((result.details as Record<string, unknown>).imageError).toBe("browser missing");
});
it("rejects invalid base URLs as tool input errors", async () => {
@@ -539,7 +488,7 @@ describe("diffs tool", () => {
expect((result.details as Record<string, unknown>).mode).toBe("both");
expect(screenshotter["screenshotHtml"]).toHaveBeenCalledTimes(1);
expect((result.details as Record<string, unknown>).format).toBe("png");
expect((result.details as Record<string, unknown>).fileFormat).toBe("png");
expect((result.details as Record<string, unknown>).fileQuality).toBe("print");
expect((result.details as Record<string, unknown>).fileScale).toBe(2.75);
expect((result.details as Record<string, unknown>).fileMaxWidth).toBe(1320);
+6 -50
View File
@@ -95,34 +95,6 @@ const DiffsToolSchema = Type.Object(
minimum: 640,
maximum: 2400,
}),
/** @deprecated Use fileQuality. */
imageQuality: Type.Optional(
stringEnum(DIFF_IMAGE_QUALITY_PRESETS, {
description: "Deprecated alias for fileQuality.",
deprecated: true,
}),
),
/** @deprecated Use fileFormat. */
imageFormat: Type.Optional(
stringEnum(DIFF_OUTPUT_FORMATS, {
description: "Deprecated alias for fileFormat.",
deprecated: true,
}),
),
/** @deprecated Use fileScale. */
imageScale: optionalFiniteNumberSchema({
description: "Deprecated alias for fileScale.",
deprecated: true,
minimum: 1,
maximum: 4,
}),
/** @deprecated Use fileMaxWidth. */
imageMaxWidth: optionalFiniteNumberSchema({
description: "Deprecated alias for fileMaxWidth.",
deprecated: true,
minimum: 640,
maximum: 2400,
}),
expandUnchanged: Type.Optional(
Type.Boolean({ description: "Expand unchanged sections instead of collapsing them." }),
),
@@ -142,10 +114,6 @@ const DiffsToolSchema = Type.Object(
);
type DiffsToolParams = Static<typeof DiffsToolSchema>;
type DiffsToolRawParams = DiffsToolParams & {
/** @deprecated Use fileFormat. */
format?: DiffOutputFormat;
};
export function createDiffsTool(params: {
api: OpenClawPluginApi;
@@ -163,7 +131,7 @@ export function createDiffsTool(params: {
"Create a read-only diff viewer from before/after text or a unified patch. Returns a gateway viewer URL for canvas use and can also render the same diff to a PNG or PDF.",
parameters: DiffsToolSchema,
execute: async (_toolCallId, rawParams) => {
const toolParams = rawParams as DiffsToolRawParams;
const toolParams = rawParams as DiffsToolParams;
const rawRecord = rawParams as Record<string, unknown>;
const artifactContext = buildArtifactContext(params.context);
const input = normalizeDiffInput(toolParams);
@@ -187,19 +155,13 @@ export function createDiffsTool(params: {
const expandUnchanged = toolParams.expandUnchanged === true;
const ttlSeconds =
readFiniteNumberParam(rawRecord, "ttlSeconds") ?? params.defaults.ttlSeconds;
const fileScale =
readFiniteNumberParam(rawRecord, "fileScale") ??
readFiniteNumberParam(rawRecord, "imageScale");
const fileMaxWidth =
readFiniteNumberParam(rawRecord, "fileMaxWidth") ??
readFiniteNumberParam(rawRecord, "imageMaxWidth");
const fileScale = readFiniteNumberParam(rawRecord, "fileScale");
const fileMaxWidth = readFiniteNumberParam(rawRecord, "fileMaxWidth");
const ttlMs = normalizeTtlMs(ttlSeconds);
const image = resolveDiffImageRenderOptions({
defaults: params.defaults,
fileFormat: normalizeOutputFormat(
toolParams.fileFormat ?? toolParams.imageFormat ?? toolParams.format,
),
fileQuality: normalizeFileQuality(toolParams.fileQuality ?? toolParams.imageQuality),
fileFormat: normalizeOutputFormat(toolParams.fileFormat),
fileQuality: normalizeFileQuality(toolParams.fileQuality),
fileScale,
fileMaxWidth,
});
@@ -347,7 +309,6 @@ export function createDiffsTool(params: {
details: {
...baseDetails,
fileError: errorMessage,
imageError: errorMessage,
},
};
}
@@ -396,18 +357,13 @@ function buildArtifactDetails(params: {
return {
...params.baseDetails,
filePath: params.artifactFile.path,
imagePath: params.artifactFile.path,
// `path` mirrors filePath so the message tool can send the artifact directly.
path: params.artifactFile.path,
fileBytes: params.artifactFile.bytes,
imageBytes: params.artifactFile.bytes,
format: params.image.format,
fileFormat: params.image.format,
fileQuality: params.image.qualityPreset,
imageQuality: params.image.qualityPreset,
fileScale: params.image.scale,
imageScale: params.image.scale,
fileMaxWidth: params.image.maxWidth,
imageMaxWidth: params.image.maxWidth,
};
}
@@ -125,6 +125,44 @@ function legacyUpdatedAtForIndex(updatedAt: unknown, index: number, total: numbe
);
}
function readFiniteNumberField(entry: Record<string, unknown>, key: string): number | undefined {
const value = entry[key];
return typeof value === "number" && Number.isFinite(value) ? Math.floor(value) : undefined;
}
// Doctor-owned legacy-shape repair: pre-account-scoped stores carried a flat
// `sessionKey` alias and an absolute `expiresAt`. Runtime normalization reads
// canonical fields only, so the one-time JSON import maps them here.
function upgradeLegacyThreadBindingShape(rawEntry: unknown): unknown {
if (!rawEntry || typeof rawEntry !== "object" || Array.isArray(rawEntry)) {
return rawEntry;
}
const entry = { ...(rawEntry as Record<string, unknown>) };
if (entry.targetSessionKey === undefined && typeof entry.sessionKey === "string") {
entry.targetSessionKey = entry.sessionKey;
}
delete entry.sessionKey;
const expiresAt = readFiniteNumberField(entry, "expiresAt");
delete entry.expiresAt;
if (
entry.idleTimeoutMs === undefined &&
entry.maxAgeMs === undefined &&
expiresAt !== undefined
) {
// Legacy expiresAt was an absolute timestamp; map it to max-age and disable idle timeout.
entry.idleTimeoutMs = 0;
if (expiresAt <= 0) {
entry.maxAgeMs = 0;
} else {
const boundAt = readFiniteNumberField(entry, "boundAt") ?? 0;
const lastActivityAt = readFiniteNumberField(entry, "lastActivityAt") ?? 0;
const base = boundAt > 0 ? boundAt : lastActivityAt;
entry.maxAgeMs = Math.max(1, expiresAt - Math.max(0, base));
}
}
return entry;
}
export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrationDetector = ({
stateDir,
}) => {
@@ -193,7 +231,10 @@ export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrat
for (const [rawKey, rawEntry] of Object.entries(
store.bindings as Record<string, unknown>,
)) {
const normalized = normalizePersistedBinding(rawKey, rawEntry);
const normalized = normalizePersistedBinding(
rawKey,
upgradeLegacyThreadBindingShape(rawEntry),
);
if (normalized) {
out.push({
key: toBindingRecordKey(normalized),
@@ -1876,97 +1876,6 @@ describe("thread binding lifecycle", () => {
expect(maxInFlight).toBeLessThanOrEqual(PROBE_LIMIT);
});
it("migrates legacy expiresAt bindings to idle/max-age semantics", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
testing.resetThreadBindingsForTests();
const boundAt = Date.now() - 10_000;
const expiresAt = boundAt + 60_000;
const store = createPluginStateSyncKeyedStoreForTests("discord", {
namespace: "thread-bindings",
maxEntries: 10_000,
});
store.register("default:thread-legacy-active", {
accountId: "default",
channelId: "parent-1",
threadId: "thread-legacy-active",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:legacy-active",
agentId: "main",
boundBy: "system",
boundAt,
expiresAt,
});
store.register("default:thread-legacy-disabled", {
accountId: "default",
channelId: "parent-1",
threadId: "thread-legacy-disabled",
targetKind: "subagent",
targetSessionKey: "agent:main:subagent:legacy-disabled",
agentId: "main",
boundBy: "system",
boundAt,
expiresAt: 0,
});
const manager = createTestThreadBindingManager({
accountId: "default",
persist: false,
enableSweeper: false,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
});
const active = manager.getByThreadId("thread-legacy-active");
if (!active) {
throw new Error("missing migrated legacy active thread binding");
}
expect(active.idleTimeoutMs).toBe(0);
expect(active.maxAgeMs).toBe(expiresAt - boundAt);
expect(
resolveThreadBindingMaxAgeExpiresAt({
record: active,
defaultMaxAgeMs: manager.getMaxAgeMs(),
}),
).toBe(expiresAt);
expect(
resolveThreadBindingInactivityExpiresAt({
record: active,
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBeUndefined();
const disabled = manager.getByThreadId("thread-legacy-disabled");
if (!disabled) {
throw new Error("missing migrated legacy disabled thread binding");
}
expect(disabled.idleTimeoutMs).toBe(0);
expect(disabled.maxAgeMs).toBe(0);
expect(
resolveThreadBindingMaxAgeExpiresAt({
record: disabled,
defaultMaxAgeMs: manager.getMaxAgeMs(),
}),
).toBeUndefined();
expect(
resolveThreadBindingInactivityExpiresAt({
record: disabled,
defaultIdleTimeoutMs: manager.getIdleTimeoutMs(),
}),
).toBeUndefined();
} finally {
testing.resetThreadBindingsForTests();
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
it("persists unbinds even when no manager is active", () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-thread-bindings-"));
@@ -156,10 +156,7 @@ export function normalizePersistedBinding(
const value = raw as Partial<PersistedThreadBindingRecord>;
const threadId = normalizeThreadId(value.threadId ?? threadIdKey);
const channelId = normalizeOptionalString(value.channelId) ?? "";
const targetSessionKey =
normalizeOptionalString(value.targetSessionKey) ??
normalizeOptionalString(value.sessionKey) ??
"";
const targetSessionKey = normalizeOptionalString(value.targetSessionKey) ?? "";
if (!threadId || !channelId || !targetSessionKey) {
return null;
}
@@ -189,29 +186,6 @@ export function normalizePersistedBinding(
: undefined;
const metadata =
value.metadata && typeof value.metadata === "object" ? { ...value.metadata } : undefined;
const legacyExpiresAt =
typeof (value as { expiresAt?: unknown }).expiresAt === "number" &&
Number.isFinite((value as { expiresAt?: unknown }).expiresAt)
? Math.max(0, Math.floor((value as { expiresAt?: number }).expiresAt ?? 0))
: undefined;
let migratedIdleTimeoutMs = idleTimeoutMs;
let migratedMaxAgeMs = maxAgeMs;
if (
migratedIdleTimeoutMs === undefined &&
migratedMaxAgeMs === undefined &&
legacyExpiresAt != null
) {
if (legacyExpiresAt <= 0) {
migratedIdleTimeoutMs = 0;
migratedMaxAgeMs = 0;
} else {
const baseBoundAt = boundAt > 0 ? boundAt : lastActivityAt;
// Legacy expiresAt represented an absolute timestamp; map it to max-age and disable idle timeout.
migratedIdleTimeoutMs = 0;
migratedMaxAgeMs = Math.max(1, legacyExpiresAt - Math.max(0, baseBoundAt));
}
}
const record: ThreadBindingRecord = {
accountId,
@@ -233,11 +207,11 @@ export function normalizePersistedBinding(
if (webhookToken !== undefined) {
record.webhookToken = webhookToken;
}
if (migratedIdleTimeoutMs !== undefined) {
record.idleTimeoutMs = migratedIdleTimeoutMs;
if (idleTimeoutMs !== undefined) {
record.idleTimeoutMs = idleTimeoutMs;
}
if (migratedMaxAgeMs !== undefined) {
record.maxAgeMs = migratedMaxAgeMs;
if (maxAgeMs !== undefined) {
record.maxAgeMs = maxAgeMs;
}
if (metadata !== undefined) {
record.metadata = metadata;
@@ -21,11 +21,7 @@ export type ThreadBindingRecord = {
metadata?: Record<string, unknown>;
};
export type PersistedThreadBindingRecord = ThreadBindingRecord & {
sessionKey?: string;
/** @deprecated Legacy absolute expiry timestamp; migrated on load. */
expiresAt?: number;
};
export type PersistedThreadBindingRecord = ThreadBindingRecord;
export type ThreadBindingManager = {
accountId: string;
-2
View File
@@ -21,8 +21,6 @@ export {
resolveMatrixCredentialsFilename,
resolveMatrixCredentialsPath,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoragePaths,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} from "./src/storage-paths.js";
export {
-2
View File
@@ -25,8 +25,6 @@ export {
resolveMatrixCredentialsFilename,
resolveMatrixCredentialsPath,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoragePaths,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} from "./src/storage-paths.js";
export { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./src/matrix/deps.js";
-2
View File
@@ -1,2 +0,0 @@
// Matrix API module exposes the plugin public contract.
export * from "./src/matrix-migration.runtime.js";
-4
View File
@@ -78,7 +78,6 @@ import {
singleAccountKeysToMove,
} from "./setup-contract.js";
import { createMatrixSetupWizardProxy, matrixSetupAdapter } from "./setup-core.js";
import { runMatrixStartupMaintenance } from "./startup-maintenance.js";
import {
defaultTopLevelPlacement,
resolveMatrixInboundConversation,
@@ -622,9 +621,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount, MatrixProbe> =
},
},
doctor: matrixDoctor,
lifecycle: {
runStartupMaintenance: runMatrixStartupMaintenance,
},
heartbeat: {
sendTyping: async ({ cfg, to, accountId }) => {
await (
-99
View File
@@ -4,33 +4,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
applyMatrixDoctorRepair,
cleanStaleMatrixPluginConfig,
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
matrixDoctor,
runMatrixDoctorSequence,
} from "./doctor.js";
vi.mock("./matrix-migration.runtime.js", async () => {
const actual = await vi.importActual<typeof import("./matrix-migration.runtime.js")>(
"./matrix-migration.runtime.js",
);
return {
...actual,
maybeCreateMatrixMigrationSnapshot: vi.fn(),
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
resolveMatrixMigrationStatus: vi.fn(() => ({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: false,
actionable: false,
})),
};
});
describe("matrix doctor", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -62,42 +40,6 @@ describe("matrix doctor", () => {
expect(changes.join("\n")).toContain(fragment);
}
it("formats state and crypto previews", () => {
expect(
formatMatrixLegacyStatePreview({
accountId: "default",
legacyStoragePath: "/tmp/legacy-sync.json",
targetStoragePath: "/tmp/new-sync.json",
legacyCryptoPath: "/tmp/legacy-crypto.json",
targetCryptoPath: "/tmp/new-crypto.json",
selectionNote: "Picked the newest account.",
targetRootDir: "/tmp/account-root",
}),
).toContain("Matrix plugin upgraded in place.");
const previews = formatMatrixLegacyCryptoPreview({
inspectorAvailable: true,
warnings: ["matrix warning"],
plans: [
{
accountId: "default",
rootDir: "/tmp/account-root",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
legacyCryptoPath: "/tmp/legacy-crypto.json",
recoveryKeyPath: "/tmp/recovery-key.txt",
statePath: "/tmp/state.json",
},
],
});
expect(previews[0]).toBe("- matrix warning");
expect(previews[1]).toContain("/tmp/recovery-key.txt");
expect(previews[1]).toContain("Recovery key state: Matrix SQLite state");
expect(previews[1]).toContain("Migration state: Matrix SQLite state");
});
it("warns on stale custom Matrix plugin paths and cleans them", async () => {
const missingPath = path.join(tmpdir(), `openclaw-matrix-missing-${Date.now()}`);
await fs.rm(missingPath, { recursive: true, force: true });
@@ -125,47 +67,6 @@ describe("matrix doctor", () => {
expect(cleaned.config.plugins?.allow).toEqual(["other-plugin"]);
});
it("surfaces matrix sequence warnings and repair changes", async () => {
const runtimeApi = await import("./matrix-migration.runtime.js");
vi.mocked(runtimeApi.resolveMatrixMigrationStatus).mockReturnValue({
legacyState: null,
legacyCrypto: { inspectorAvailable: true, warnings: [], plans: [] },
pending: true,
actionable: true,
});
vi.mocked(runtimeApi.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
archivePath: "/tmp/matrix-backup.tgz",
created: true,
markerPath: "/tmp/marker.json",
});
vi.mocked(runtimeApi.autoMigrateLegacyMatrixState).mockResolvedValue({
migrated: true,
changes: ["Migrated legacy sync state"],
warnings: [],
});
vi.mocked(runtimeApi.autoPrepareLegacyMatrixCrypto).mockResolvedValue({
migrated: true,
changes: ["Prepared recovery key export"],
warnings: [],
});
const cfg = {
channels: {
matrix: {},
},
} as never;
const repair = await applyMatrixDoctorRepair({ cfg, env: process.env });
expect(repair.changes.join("\n")).toContain("Matrix migration snapshot");
const sequence = await runMatrixDoctorSequence({
cfg,
env: process.env,
shouldRepair: true,
});
expect(sequence.changeNotes.join("\n")).toContain("Matrix migration snapshot");
});
it("normalizes legacy Matrix room allow aliases to enabled", () => {
const result = runMatrixCompatibilityNormalize({
cfg: {
+1 -179
View File
@@ -10,76 +10,6 @@ import {
legacyConfigRules as MATRIX_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMatrixCompatibilityConfig,
} from "./doctor-contract.js";
import {
autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
detectLegacyMatrixState,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
import { isRecord } from "./record-shared.js";
function hasConfiguredMatrixChannel(cfg: OpenClawConfig): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
return isRecord(channels?.matrix);
}
function hasConfiguredMatrixPluginSurface(cfg: OpenClawConfig): boolean {
return Boolean(
cfg.plugins?.installs?.matrix ||
cfg.plugins?.entries?.matrix ||
cfg.plugins?.allow?.includes("matrix") ||
cfg.plugins?.deny?.includes("matrix"),
);
}
function hasConfiguredMatrixEnv(env: NodeJS.ProcessEnv): boolean {
return Object.entries(env).some(
([key, value]) => key.startsWith("MATRIX_") && typeof value === "string" && value.trim(),
);
}
function configMayNeedMatrixDoctorSequence(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean {
return (
hasConfiguredMatrixChannel(cfg) ||
hasConfiguredMatrixPluginSurface(cfg) ||
hasConfiguredMatrixEnv(env)
);
}
export function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
export function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- Recovery key state: Matrix SQLite state (imports ${plan.recoveryKeyPath} if present)`,
`- Migration state: Matrix SQLite state (imports ${plan.statePath} if present)`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
@@ -129,125 +59,17 @@ export async function cleanStaleMatrixPluginConfig(cfg: OpenClawConfig) {
};
}
export async function applyMatrixDoctorRepair(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
const migrationStatus = resolveMatrixMigrationStatus({
cfg: params.cfg,
env: params.env,
});
let matrixSnapshotReady = true;
if (migrationStatus.actionable) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: params.env,
});
changes.push(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
);
} catch (error) {
matrixSnapshotReady = false;
warnings.push(
`- Failed creating a Matrix migration snapshot before repair: ${String(error)}`,
);
warnings.push(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
);
}
} 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.",
);
}
if (!matrixSnapshotReady) {
return { changes, warnings };
}
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: params.cfg,
env: params.env,
});
if (matrixStateRepair.changes.length > 0) {
changes.push(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
);
}
if (matrixStateRepair.warnings.length > 0) {
warnings.push(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
});
if (matrixCryptoRepair.changes.length > 0) {
changes.push(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
);
}
if (matrixCryptoRepair.warnings.length > 0) {
warnings.push(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
return { changes, warnings };
}
export async function runMatrixDoctorSequence(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
shouldRepair: boolean;
}): Promise<{ changeNotes: string[]; warningNotes: string[] }> {
const warningNotes: string[] = [];
const changeNotes: string[] = [];
const installWarnings = await collectMatrixInstallPathWarnings(params.cfg);
if (installWarnings.length > 0) {
warningNotes.push(installWarnings.join("\n"));
}
if (!configMayNeedMatrixDoctorSequence(params.cfg, params.env)) {
return { changeNotes, warningNotes };
}
if (params.shouldRepair) {
const repair = await applyMatrixDoctorRepair({
cfg: params.cfg,
env: params.env,
});
changeNotes.push(...repair.changes);
warningNotes.push(...repair.warnings);
} 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));
}
}
return { changeNotes, warningNotes };
return { changeNotes: [], warningNotes };
}
export const matrixDoctor: ChannelDoctorAdapter = {
@@ -1,84 +0,0 @@
// Matrix tests cover legacy crypto inspector availability plugin behavior.
import path from "node:path";
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("openclaw/plugin-sdk/test-node-mocks");
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 = path.resolve(
"/virtual/extensions/matrix/src/legacy-crypto-inspector-availability.js",
);
availabilityState.existingPaths.add(
path.resolve("/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);
});
});
@@ -1,61 +0,0 @@
// Matrix plugin module implements legacy crypto inspector availability behavior.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
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 [
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.ts"),
path.resolve(currentDir, "matrix", "legacy-crypto-inspector.js"),
].some((candidate) => fs.existsSync(candidate));
}
function hasBuiltInspectorArtifact(currentDir: string): boolean {
if (fs.existsSync(path.join(currentDir, "legacy-crypto-inspector.js"))) {
return true;
}
if (fs.existsSync(path.join(currentDir, "extensions", "matrix", "legacy-crypto-inspector.js"))) {
return true;
}
return fs
.readdirSync(currentDir, { withFileTypes: true })
.some((entry) => entry.isFile() && isLegacyCryptoInspectorArtifactName(entry.name));
}
export function isMatrixLegacyCryptoInspectorAvailable(): boolean {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
if (hasSourceInspectorArtifact(currentDir)) {
return true;
}
try {
return hasBuiltInspectorArtifact(currentDir);
} catch {
return false;
}
}
-243
View File
@@ -1,243 +0,0 @@
// Matrix tests cover legacy crypto plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
} from "./matrix/crypto-state-store.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
import {
MATRIX_DEFAULT_ACCESS_TOKEN,
MATRIX_DEFAULT_DEVICE_ID,
MATRIX_DEFAULT_USER_ID,
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_DEVICE_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeFile,
writeMatrixCredentials,
} from "./test-helpers.js";
import { installMatrixTestRuntime } from "./test-runtime.js";
function createDefaultMatrixConfig(): OpenClawConfig {
return {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
},
},
};
}
function writeDefaultLegacyCryptoFixture(home: string) {
const stateDir = path.join(home, ".openclaw");
const cfg = createDefaultMatrixConfig();
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_DEFAULT_USER_ID,
accessToken: MATRIX_DEFAULT_ACCESS_TOKEN,
});
writeFile(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_DEFAULT_DEVICE_ID }),
);
return { cfg, rootDir };
}
function createOpsLegacyCryptoFixture(params: {
home: string;
accessToken?: string;
includeStoredCredentials?: boolean;
}) {
const stateDir = path.join(params.home, ".openclaw");
writeFile(
path.join(stateDir, "matrix", "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: MATRIX_OPS_DEVICE_ID }),
);
if (params.includeStoredCredentials) {
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
deviceId: MATRIX_OPS_DEVICE_ID,
});
}
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
accessToken: params.accessToken ?? MATRIX_OPS_ACCESS_TOKEN,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
return { rootDir };
}
describe("matrix legacy encrypted-state migration", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
legacyCryptoInspectorAvailability.available = true;
resetPluginStateStoreForTests();
});
it("extracts a saved backup key into the new recovery-key path", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const detection = detectLegacyMatrixCrypto({ cfg, env: process.env });
expect(detection.inspectorAvailable).toBe(true);
expect(detection.warnings).toStrictEqual([]);
expect(detection.plans).toHaveLength(1);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 12, backedUp: 12 },
backupVersion: "1",
decryptionKeyBase64: "YWJjZA==",
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toStrictEqual([]);
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("YWJjZA==");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
it("skips migration when no legacy Matrix plans exist", async () => {
await withTempHome(async () => {
const result = await autoPrepareLegacyMatrixCrypto({
cfg: createDefaultMatrixConfig(),
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [],
});
});
});
it("warns when legacy local-only room keys cannot be recovered automatically", async () => {
await withTempHome(async (home) => {
const { cfg, rootDir } = writeDefaultLegacyCryptoFixture(home);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_DEFAULT_DEVICE_ID,
roomKeyCounts: { total: 15, backedUp: 10 },
backupVersion: null,
decryptionKeyBase64: null,
}),
},
});
expect(result.migrated).toBe(true);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" contains 5 room key(s) that were never backed up. Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.',
);
expect(result.warnings).toContain(
'Legacy Matrix encrypted state for account "default" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.',
);
expect(readMatrixLegacyCryptoMigrationState(rootDir)?.restoreStatus).toBe(
"manual-action-required",
);
});
});
it("prefers stored credentials for named accounts when config is token-only", async () => {
await withTempHome(async (home) => {
const { rootDir } = createOpsLegacyCryptoFixture({
home,
includeStoredCredentials: true,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
deps: {
inspectLegacyStore: async () => ({
deviceId: MATRIX_OPS_DEVICE_ID,
roomKeyCounts: { total: 1, backedUp: 1 },
backupVersion: "1",
decryptionKeyBase64: "b3Bz",
}),
},
});
expect(result.migrated).toBe(true);
expect(readMatrixRecoveryKeyState(rootDir)?.privateKeyBase64).toBe("b3Bz");
expect(fs.existsSync(path.join(rootDir, "recovery-key.json"))).toBe(false);
});
});
it("stays warning-only when the legacy crypto inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
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.",
);
const result = await autoPrepareLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(result).toEqual({
migrated: false,
changes: [],
warnings: [
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
],
});
});
});
});
-502
View File
@@ -1,502 +0,0 @@
// Matrix plugin module implements legacy crypto behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveConfiguredMatrixAccountIds } from "./account-selection.js";
import { isMatrixLegacyCryptoInspectorAvailable } from "./legacy-crypto-inspector-availability.js";
import {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore,
migrateLegacyMatrixRecoveryKeyFileToStore,
readMatrixLegacyCryptoMigrationState,
readMatrixRecoveryKeyState,
writeMatrixLegacyCryptoMigrationState,
writeMatrixRecoveryKeyState,
type MatrixLegacyCryptoMigrationState,
} from "./matrix/crypto-state-store.js";
import { formatMatrixErrorMessage } from "./matrix/errors.js";
import type { MatrixStoredRecoveryKey } from "./matrix/sdk/types.js";
import {
resolveLegacyMatrixFlatStoreTarget,
resolveMatrixMigrationAccountTarget,
} from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
const MATRIX_LEGACY_CRYPTO_INSPECTOR_UNAVAILABLE_MESSAGE =
"Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.";
type MatrixLegacyCryptoCounts = {
total: number;
backedUp: number;
};
type MatrixLegacyCryptoSummary = {
deviceId: string | null;
roomKeyCounts: MatrixLegacyCryptoCounts | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoPlan = {
accountId: string;
rootDir: string;
recoveryKeyPath: string;
statePath: string;
legacyCryptoPath: string;
homeserver: string;
userId: string;
accessToken: string;
deviceId: string | null;
};
type MatrixLegacyCryptoDetection = {
inspectorAvailable: boolean;
plans: MatrixLegacyCryptoPlan[];
warnings: string[];
};
type MatrixLegacyCryptoPreparationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyCryptoPrepareDeps = {
inspectLegacyStore: MatrixLegacyCryptoInspector;
};
type MatrixLegacyCryptoInspectorParams = {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
};
type MatrixLegacyCryptoInspectorResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
type MatrixLegacyCryptoInspector = (
params: MatrixLegacyCryptoInspectorParams,
) => Promise<MatrixLegacyCryptoInspectorResult>;
type MatrixLegacyBotSdkMetadata = {
deviceId: string | null;
};
async function loadMatrixLegacyCryptoInspector(): Promise<MatrixLegacyCryptoInspector> {
const module = await import("./matrix/legacy-crypto-inspector.js");
return module.inspectLegacyMatrixCryptoStore as MatrixLegacyCryptoInspector;
}
function detectLegacyBotSdkCryptoStore(cryptoRootDir: string): {
detected: boolean;
warning?: string;
} {
try {
const stat = fs.statSync(cryptoRootDir);
if (!stat.isDirectory()) {
return {
detected: false,
warning:
`Legacy Matrix encrypted state path exists but is not a directory: ${cryptoRootDir}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
} catch (err) {
return {
detected: false,
warning:
`Failed reading legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
try {
return {
detected:
fs.existsSync(path.join(cryptoRootDir, "bot-sdk.json")) ||
fs.existsSync(path.join(cryptoRootDir, "matrix-sdk-crypto.sqlite3")) ||
fs
.readdirSync(cryptoRootDir, { withFileTypes: true })
.some(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
),
};
} catch (err) {
return {
detected: false,
warning:
`Failed scanning legacy Matrix encrypted state path (${cryptoRootDir}): ${String(err)}. ` +
"OpenClaw skipped automatic crypto migration for that path.",
};
}
}
function resolveMatrixAccountIds(cfg: OpenClawConfig): string[] {
return resolveConfiguredMatrixAccountIds(cfg);
}
function resolveLegacyMatrixFlatStorePlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoPlan | { warning: string } | null {
const legacy = resolveMatrixLegacyFlatStoragePaths(resolveStateDir(params.env, os.homedir));
if (!fs.existsSync(legacy.cryptoPath)) {
return null;
}
const legacyStore = detectLegacyBotSdkCryptoStore(legacy.cryptoPath);
if (legacyStore.warning) {
return { warning: legacyStore.warning };
}
if (!legacyStore.detected) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.cryptoPath,
detectedKind: "encrypted state",
});
if ("warning" in target) {
return target;
}
const metadata = loadLegacyBotSdkMetadata(legacy.cryptoPath);
return {
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath: legacy.cryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
};
}
function loadLegacyBotSdkMetadata(cryptoRootDir: string): MatrixLegacyBotSdkMetadata {
const metadataPath = path.join(cryptoRootDir, "bot-sdk.json");
const fallback: MatrixLegacyBotSdkMetadata = { deviceId: null };
const parsed = loadJsonFile<{ deviceId?: unknown }>(metadataPath);
return {
deviceId:
typeof parsed?.deviceId === "string" && parsed.deviceId.trim()
? parsed.deviceId
: fallback.deviceId,
};
}
function resolveMatrixLegacyCryptoPlans(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Omit<MatrixLegacyCryptoDetection, "inspectorAvailable"> {
const warnings: string[] = [];
const plans: MatrixLegacyCryptoPlan[] = [];
const flatPlan = resolveLegacyMatrixFlatStorePlan(params);
if (flatPlan) {
if ("warning" in flatPlan) {
warnings.push(flatPlan.warning);
} else {
plans.push(flatPlan);
}
}
for (const accountId of resolveMatrixAccountIds(params.cfg)) {
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
continue;
}
const legacyCryptoPath = path.join(target.rootDir, "crypto");
if (!fs.existsSync(legacyCryptoPath)) {
continue;
}
const detectedStore = detectLegacyBotSdkCryptoStore(legacyCryptoPath);
if (detectedStore.warning) {
warnings.push(detectedStore.warning);
continue;
}
if (!detectedStore.detected) {
continue;
}
if (
plans.some(
(plan) =>
plan.accountId === accountId &&
path.resolve(plan.legacyCryptoPath) === path.resolve(legacyCryptoPath),
)
) {
continue;
}
const metadata = loadLegacyBotSdkMetadata(legacyCryptoPath);
plans.push({
accountId: target.accountId,
rootDir: target.rootDir,
recoveryKeyPath: path.join(target.rootDir, "recovery-key.json"),
statePath: path.join(target.rootDir, "legacy-crypto-migration.json"),
legacyCryptoPath,
homeserver: target.homeserver,
userId: target.userId,
accessToken: target.accessToken,
deviceId: metadata.deviceId ?? target.storedDeviceId,
});
}
return { plans, warnings };
}
export function detectLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyCryptoDetection {
const detection = resolveMatrixLegacyCryptoPlans({
cfg: params.cfg,
env: params.env ?? process.env,
});
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 {
inspectorAvailable,
plans: detection.plans,
warnings: detection.warnings,
};
}
export async function autoPrepareLegacyMatrixCrypto(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
deps?: Partial<MatrixLegacyCryptoPrepareDeps>;
}): Promise<MatrixLegacyCryptoPreparationResult> {
const env = params.env ?? process.env;
const detection = params.deps?.inspectLegacyStore
? resolveMatrixLegacyCryptoPlans({ cfg: params.cfg, env })
: detectLegacyMatrixCrypto({ cfg: params.cfg, env });
const inspectorAvailable =
"inspectorAvailable" in detection ? detection.inspectorAvailable : true;
const warnings = [...detection.warnings];
const changes: string[] = [];
if (detection.plans.length === 0) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
if (!params.deps?.inspectLegacyStore && !inspectorAvailable) {
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
let inspectLegacyStore = params.deps?.inspectLegacyStore;
if (!inspectLegacyStore) {
try {
inspectLegacyStore = await loadMatrixLegacyCryptoInspector();
} catch (err) {
const message = formatMatrixErrorMessage(err);
if (!warnings.includes(message)) {
warnings.push(message);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: false,
changes,
warnings,
};
}
}
if (!inspectLegacyStore) {
return {
migrated: false,
changes,
warnings,
};
}
for (const plan of detection.plans) {
try {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(plan.rootDir);
migrateLegacyMatrixRecoveryKeyFileToStore(plan.rootDir);
} catch (err) {
warnings.push(
`Failed migrating Matrix crypto sidecar state for account "${plan.accountId}" (${plan.rootDir}): ${String(err)}`,
);
}
const existingState = readMatrixLegacyCryptoMigrationState(plan.rootDir);
if (existingState?.version === 1) {
continue;
}
if (!plan.deviceId) {
warnings.push(
`Legacy Matrix encrypted state detected at ${plan.legacyCryptoPath}, but no device ID was found for account "${plan.accountId}". ` +
`OpenClaw will continue, but old encrypted history cannot be recovered automatically.`,
);
continue;
}
let summary: MatrixLegacyCryptoSummary;
try {
summary = await inspectLegacyStore({
cryptoRootDir: plan.legacyCryptoPath,
userId: plan.userId,
deviceId: plan.deviceId,
log: params.log?.info,
});
} catch (err) {
warnings.push(
`Failed inspecting legacy Matrix encrypted state for account "${plan.accountId}" (${plan.legacyCryptoPath}): ${String(err)}`,
);
continue;
}
let decryptionKeyImported = false;
if (summary.decryptionKeyBase64) {
const existingRecoveryKey = readMatrixRecoveryKeyState(plan.rootDir);
if (
existingRecoveryKey?.privateKeyBase64 &&
existingRecoveryKey.privateKeyBase64 !== summary.decryptionKeyBase64
) {
warnings.push(
`Legacy Matrix backup key was found for account "${plan.accountId}", but Matrix SQLite state already contains a different recovery key. Leaving the existing state unchanged.`,
);
} else if (!existingRecoveryKey?.privateKeyBase64) {
const payload: MatrixStoredRecoveryKey = {
version: 1,
createdAt: new Date().toISOString(),
keyId: null,
privateKeyBase64: summary.decryptionKeyBase64,
};
try {
writeMatrixRecoveryKeyState({
storageRootDir: plan.rootDir,
payload,
});
changes.push(
`Imported Matrix legacy backup key for account "${plan.accountId}" into SQLite`,
);
decryptionKeyImported = true;
} catch (err) {
warnings.push(
`Failed writing Matrix recovery key for account "${plan.accountId}" to SQLite: ${String(err)}`,
);
}
} else {
decryptionKeyImported = true;
}
}
const localOnlyKeys =
summary.roomKeyCounts && summary.roomKeyCounts.total > summary.roomKeyCounts.backedUp
? summary.roomKeyCounts.total - summary.roomKeyCounts.backedUp
: 0;
if (localOnlyKeys > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" contains ${localOnlyKeys} room key(s) that were never backed up. ` +
"Backed-up keys can be restored automatically, but local-only encrypted history may remain unavailable after upgrade.",
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.backedUp ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" has backed-up room keys, but no local backup decryption key was found. ` +
`Ask the operator to run "openclaw matrix verify backup restore --recovery-key <key>" after upgrade if they have the recovery key.`,
);
}
if (!summary.decryptionKeyBase64 && (summary.roomKeyCounts?.total ?? 0) > 0) {
warnings.push(
`Legacy Matrix encrypted state for account "${plan.accountId}" cannot be fully converted automatically because the old rust crypto store does not expose all local room keys for export.`,
);
}
// If recovery-key persistence failed, leave the migration state absent so the next startup can retry.
if (
summary.decryptionKeyBase64 &&
!decryptionKeyImported &&
!readMatrixRecoveryKeyState(plan.rootDir)
) {
continue;
}
const state: MatrixLegacyCryptoMigrationState = {
version: 1,
source: "matrix-bot-sdk-rust",
accountId: plan.accountId,
deviceId: summary.deviceId,
roomKeyCounts: summary.roomKeyCounts,
backupVersion: summary.backupVersion,
decryptionKeyImported,
restoreStatus: decryptionKeyImported ? "pending" : "manual-action-required",
detectedAt: new Date().toISOString(),
lastError: null,
};
try {
writeMatrixLegacyCryptoMigrationState({
storageRootDir: plan.rootDir,
state,
});
changes.push(
`Prepared Matrix legacy encrypted-state migration for account "${plan.accountId}" in SQLite`,
);
} catch (err) {
warnings.push(
`Failed writing Matrix legacy encrypted-state migration record for account "${plan.accountId}" to SQLite: ${String(err)}`,
);
}
}
if (changes.length > 0) {
params.log?.info?.(
`matrix: prepared encrypted-state upgrade.\n${changes.map((entry) => `- ${entry}`).join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy encrypted-state warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}
@@ -1,87 +0,0 @@
// Matrix tests cover legacy state plugin behavior.
import fs from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf-8");
}
describe("matrix legacy state migration", () => {
it("migrates the flat legacy Matrix store into account-scoped storage", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(path.join(stateDir, "matrix", "crypto", "store.db"), "crypto");
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected a migratable Matrix legacy state plan");
}
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(result.warnings).toStrictEqual([]);
expect(fs.existsSync(path.join(stateDir, "matrix", "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(stateDir, "matrix", "crypto"))).toBe(false);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
expect(fs.existsSync(path.join(detection.targetCryptoPath, "store.db"))).toBe(true);
});
});
it("uses cached Matrix credentials when the config no longer stores an access token", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
writeFile(
path.join(stateDir, "credentials", "matrix", "credentials.json"),
JSON.stringify(
{
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-from-cache",
},
null,
2,
),
);
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
password: "secret",
},
},
};
const detection = detectLegacyMatrixState({ cfg, env: process.env });
expect(detection && "warning" in detection).toBe(false);
if (!detection || "warning" in detection) {
throw new Error("expected cached credentials to make Matrix migration resolvable");
}
expect(detection.targetRootDir).toContain("matrix.example.org__bot_example.org");
const result = await autoMigrateLegacyMatrixState({ cfg, env: process.env });
expect(result.migrated).toBe(true);
expect(fs.existsSync(detection.targetStoragePath)).toBe(true);
});
});
});
-157
View File
@@ -1,157 +0,0 @@
// Matrix plugin module implements legacy state behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { resolveLegacyMatrixFlatStoreTarget } from "./migration-config.js";
import { resolveMatrixLegacyFlatStoragePaths } from "./storage-paths.js";
type MatrixLegacyStateMigrationResult = {
migrated: boolean;
changes: string[];
warnings: string[];
};
type MatrixLegacyStatePlan = {
accountId: string;
legacyStoragePath: string;
legacyCryptoPath: string;
targetRootDir: string;
targetStoragePath: string;
targetCryptoPath: string;
selectionNote?: string;
};
function resolveLegacyMatrixPaths(env: NodeJS.ProcessEnv): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function resolveMatrixMigrationPlan(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
const legacy = resolveLegacyMatrixPaths(params.env);
if (!fs.existsSync(legacy.storagePath) && !fs.existsSync(legacy.cryptoPath)) {
return null;
}
const target = resolveLegacyMatrixFlatStoreTarget({
cfg: params.cfg,
env: params.env,
detectedPath: legacy.rootDir,
detectedKind: "state",
});
if ("warning" in target) {
return target;
}
return {
accountId: target.accountId,
legacyStoragePath: legacy.storagePath,
legacyCryptoPath: legacy.cryptoPath,
targetRootDir: target.rootDir,
targetStoragePath: path.join(target.rootDir, "bot-storage.json"),
targetCryptoPath: path.join(target.rootDir, "crypto"),
selectionNote: target.selectionNote,
};
}
export function detectLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): MatrixLegacyStatePlan | { warning: string } | null {
return resolveMatrixMigrationPlan({
cfg: params.cfg,
env: params.env ?? process.env,
});
}
function moveLegacyPath(params: {
sourcePath: string;
targetPath: string;
label: string;
changes: string[];
warnings: string[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
params.warnings.push(
`Matrix legacy ${params.label} not migrated because the target already exists (${params.targetPath}).`,
);
return;
}
try {
fs.mkdirSync(path.dirname(params.targetPath), { recursive: true });
fs.renameSync(params.sourcePath, params.targetPath);
params.changes.push(
`Migrated Matrix legacy ${params.label}: ${params.sourcePath} -> ${params.targetPath}`,
);
} catch (err) {
params.warnings.push(
`Failed migrating Matrix legacy ${params.label} (${params.sourcePath} -> ${params.targetPath}): ${String(err)}`,
);
}
}
export async function autoMigrateLegacyMatrixState(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixLegacyStateMigrationResult> {
const env = params.env ?? process.env;
const detection = detectLegacyMatrixState({ cfg: params.cfg, env });
if (!detection) {
return { migrated: false, changes: [], warnings: [] };
}
if ("warning" in detection) {
params.log?.warn?.(`matrix: ${detection.warning}`);
return { migrated: false, changes: [], warnings: [detection.warning] };
}
const changes: string[] = [];
const warnings: string[] = [];
moveLegacyPath({
sourcePath: detection.legacyStoragePath,
targetPath: detection.targetStoragePath,
label: "sync store",
changes,
warnings,
});
moveLegacyPath({
sourcePath: detection.legacyCryptoPath,
targetPath: detection.targetCryptoPath,
label: "crypto store",
changes,
warnings,
});
if (changes.length > 0) {
const details = [
...changes.map((entry) => `- ${entry}`),
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
"- No user action required.",
];
params.log?.info?.(
`matrix: plugin upgraded in place for account "${detection.accountId}".\n${details.join("\n")}`,
);
}
if (warnings.length > 0) {
params.log?.warn?.(
`matrix: legacy state migration warnings:\n${warnings.map((entry) => `- ${entry}`).join("\n")}`,
);
}
return {
migrated: changes.length > 0,
changes,
warnings,
};
}
@@ -1,10 +0,0 @@
// Matrix plugin module implements matrix migration behavior.
export { autoMigrateLegacyMatrixState, detectLegacyMatrixState } from "./legacy-state.js";
export { autoPrepareLegacyMatrixCrypto, detectLegacyMatrixCrypto } from "./legacy-crypto.js";
export {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./migration-snapshot.js";
export { maybeCreateMatrixMigrationSnapshot } from "./migration-snapshot-backup.js";
@@ -1,2 +0,0 @@
// Matrix plugin module implements migration snapshot behavior.
export { maybeCreateMatrixMigrationSnapshot } from "../../migration-snapshot-backup.js";
@@ -22,32 +22,6 @@ import {
writeStorageMeta,
} from "./storage.js";
const createBackupArchiveMock = vi.hoisted(() =>
vi.fn(async (_params: unknown) => ({
createdAt: "2026-03-17T00:00:00.000Z",
archiveRoot: "2026-03-17-openclaw-backup",
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
dryRun: false,
includeWorkspace: false,
onlyConfig: false,
verified: false,
assets: [],
skipped: [],
})),
);
const maybeCreateMatrixMigrationSnapshotMock = vi.hoisted(() =>
vi.fn(async (_params: unknown) => ({
created: true,
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
markerPath: "/tmp/matrix-migration-snapshot.json",
})),
);
vi.mock("./migration-snapshot.runtime.js", () => ({
maybeCreateMatrixMigrationSnapshot: (params: unknown) =>
maybeCreateMatrixMigrationSnapshotMock(params),
}));
describe("matrix client storage paths", () => {
const tempDirs: string[] = [];
const defaultStorageAuth = {
@@ -61,23 +35,6 @@ describe("matrix client storage paths", () => {
});
afterEach(() => {
createBackupArchiveMock.mockReset();
createBackupArchiveMock.mockImplementation(async (_params: unknown) => ({
createdAt: "2026-03-17T00:00:00.000Z",
archiveRoot: "2026-03-17-openclaw-backup",
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
dryRun: false,
includeWorkspace: false,
onlyConfig: false,
verified: false,
assets: [],
skipped: [],
}));
maybeCreateMatrixMigrationSnapshotMock.mockReset().mockResolvedValue({
created: true,
archivePath: "/tmp/matrix-migration-snapshot.tar.gz",
markerPath: "/tmp/matrix-migration-snapshot.json",
});
vi.restoreAllMocks();
resetPluginStateStoreForTests();
for (const dir of tempDirs.splice(0)) {
@@ -124,24 +81,6 @@ describe("matrix client storage paths", () => {
} as NodeJS.ProcessEnv;
}
function expectFallbackMigrationSnapshot(env: NodeJS.ProcessEnv): void {
expect(maybeCreateMatrixMigrationSnapshotMock).toHaveBeenCalledTimes(1);
const [params] = maybeCreateMatrixMigrationSnapshotMock.mock.calls.at(0) ?? [];
expect(params).toEqual({
env,
log: {
info: (params as { log?: { info?: unknown } })?.log?.info,
warn: (params as { log?: { warn?: unknown } })?.log?.warn,
error: (params as { log?: { error?: unknown } })?.log?.error,
},
trigger: "matrix-client-fallback",
});
const log = (params as { log?: { info?: unknown; warn?: unknown; error?: unknown } })?.log;
expect(typeof log?.info).toBe("function");
expect(typeof log?.warn).toBe("function");
expect(typeof log?.error).toBe("function");
}
function resolveDefaultStoragePaths(
overrides: Partial<{
homeserver: string;
@@ -257,23 +196,6 @@ describe("matrix client storage paths", () => {
);
});
function writeLegacyMatrixStorage(
stateDir: string,
params: {
storageBody?: string;
withCrypto?: boolean;
} = {},
) {
const legacyRoot = path.join(stateDir, "matrix");
if (params.withCrypto ?? true) {
fs.mkdirSync(path.join(legacyRoot, "crypto"), { recursive: true });
}
if (params.storageBody !== undefined) {
fs.writeFileSync(path.join(legacyRoot, "bot-storage.json"), params.storageBody);
}
return legacyRoot;
}
function legacySyncCacheBody(nextBatch = "legacy-token"): string {
return JSON.stringify({
version: 1,
@@ -431,29 +353,6 @@ describe("matrix client storage paths", () => {
);
});
it("falls back to migrating the older flat matrix storage layout", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveDefaultStoragePaths();
const legacyRoot = writeLegacyMatrixStorage(stateDir, {
storageBody: legacySyncCacheBody("legacy-token"),
});
const env = createMigrationEnv(stateDir);
await maybeMigrateLegacyStorage({
storagePaths,
env,
});
expectFallbackMigrationSnapshot(env);
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json.migrated"))).toBe(true);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true);
const syncStore = new SqliteBackedMatrixSyncStore(storagePaths.rootDir);
expect(syncStore.hasSavedSync()).toBe(true);
await expect(syncStore.getSavedSyncToken()).resolves.toBe("legacy-token");
});
it("migrates the previous account-scoped sync cache into sqlite before startup", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveDefaultStoragePaths();
@@ -466,7 +365,6 @@ describe("matrix client storage paths", () => {
env,
});
expectFallbackMigrationSnapshot(env);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
expect(fs.existsSync(`${storagePaths.storagePath}.migrated`)).toBe(true);
const syncStore = new SqliteBackedMatrixSyncStore(storagePaths.rootDir);
@@ -501,7 +399,6 @@ describe("matrix client storage paths", () => {
env,
});
expectFallbackMigrationSnapshot(env);
expect(readMatrixIdbSnapshotJson(storagePaths.rootDir)).toBe(currentSnapshot);
expect(fs.existsSync(storagePaths.idbSnapshotPath)).toBe(false);
expect(fs.existsSync(`${storagePaths.idbSnapshotPath}.migrated`)).toBe(true);
@@ -519,132 +416,9 @@ describe("matrix client storage paths", () => {
env,
});
expect(maybeCreateMatrixMigrationSnapshotMock).not.toHaveBeenCalled();
expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe('{"new":true}');
});
it("continues migrating whichever legacy artifact is still missing", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveDefaultStoragePaths();
const legacyRoot = writeLegacyMatrixStorage(stateDir);
const env = createMigrationEnv(stateDir);
fs.mkdirSync(storagePaths.rootDir, { recursive: true });
fs.writeFileSync(storagePaths.storagePath, '{"new":true}');
await maybeMigrateLegacyStorage({
storagePaths,
env,
});
expectFallbackMigrationSnapshot(env);
expect(fs.readFileSync(storagePaths.storagePath, "utf8")).toBe('{"new":true}');
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(false);
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(false);
expect(fs.existsSync(storagePaths.cryptoPath)).toBe(true);
});
it("refuses to migrate legacy storage when the snapshot step fails", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveDefaultStoragePaths();
const legacyRoot = writeLegacyMatrixStorage(stateDir, { storageBody: '{"legacy":true}' });
const env = createMigrationEnv(stateDir);
maybeCreateMatrixMigrationSnapshotMock.mockRejectedValueOnce(new Error("snapshot failed"));
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env,
}),
).rejects.toThrow("snapshot failed");
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
});
it("rolls back moved legacy storage when the crypto move fails", async () => {
const stateDir = setupStateDir();
const storagePaths = resolveDefaultStoragePaths();
const legacyRoot = writeLegacyMatrixStorage(stateDir, { storageBody: '{"legacy":true}' });
const env = createMigrationEnv(stateDir);
const realRenameSync = fs.renameSync.bind(fs);
const renameSync = vi.spyOn(fs, "renameSync");
renameSync.mockImplementation((sourcePath, targetPath) => {
if (String(targetPath) === storagePaths.cryptoPath) {
throw new Error("disk full");
}
return realRenameSync(sourcePath, targetPath);
});
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env,
}),
).rejects.toThrow("disk full");
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
expect(fs.existsSync(storagePaths.storagePath)).toBe(false);
expect(fs.existsSync(path.join(legacyRoot, "crypto"))).toBe(true);
});
it("refuses fallback migration when multiple Matrix accounts need explicit selection", async () => {
const stateDir = setupStateDir({
channels: {
matrix: {
accounts: {
ops: {},
work: {},
},
},
},
});
const storagePaths = resolveDefaultStoragePaths({ accountId: "ops" });
const legacyRoot = writeLegacyMatrixStorage(stateDir, { storageBody: '{"legacy":true}' });
const env = createMigrationEnv(stateDir);
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env,
}),
).rejects.toThrow(/defaultAccount is not set/i);
expect(createBackupArchiveMock).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
});
it("refuses fallback migration for a non-selected Matrix account", async () => {
const stateDir = setupStateDir({
channels: {
matrix: {
defaultAccount: "ops",
homeserver: "https://matrix.default.example.org",
accessToken: "default-token",
accounts: {
ops: {
homeserver: "https://matrix.ops.example.org",
accessToken: "ops-token",
},
},
},
},
});
const storagePaths = resolveMatrixStoragePaths({
homeserver: "https://matrix.default.example.org",
userId: "@default:example.org",
accessToken: "default-token",
env: {},
});
const legacyRoot = writeLegacyMatrixStorage(stateDir, { storageBody: '{"legacy":true}' });
const env = createMigrationEnv(stateDir);
await expect(
maybeMigrateLegacyStorage({
storagePaths,
env,
}),
).rejects.toThrow(/targets account "ops"/i);
expect(createBackupArchiveMock).not.toHaveBeenCalled();
expect(fs.existsSync(path.join(legacyRoot, "bot-storage.json"))).toBe(true);
});
it("keeps the canonical current-token storage root when deviceId is still unknown", () => {
const stateDir = setupStateDir();
const oldStoragePaths = seedExistingStorageRoot({
+3 -119
View File
@@ -3,22 +3,14 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { loadJsonFile } from "openclaw/plugin-sdk/json-store";
import type {
PluginStateKeyedStore,
PluginStateSyncKeyedStore,
} from "openclaw/plugin-sdk/plugin-state-runtime";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../../account-selection.js";
import { isRecord } from "../../record-shared.js";
import { getMatrixRuntime } from "../../runtime.js";
import {
resolveMatrixAccountStorageRoot,
resolveMatrixLegacyFlatStoragePaths,
} from "../../storage-paths.js";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import {
MATRIX_IDB_SNAPSHOT_FILENAME,
MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME,
@@ -74,35 +66,6 @@ function openStorageMetaStore(rootDir: string): PluginStateSyncKeyedStore<Matrix
);
}
function resolveLegacyStoragePaths(env: NodeJS.ProcessEnv = process.env): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const stateDir = getMatrixRuntime().state.resolveStateDir(env, os.homedir);
return resolveMatrixLegacyFlatStoragePaths(stateDir);
}
function assertLegacyMigrationAccountSelection(params: { accountKey: string }): void {
const cfg = getMatrixRuntime().config.current() as OpenClawConfig;
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
return;
}
if (requiresExplicitMatrixDefaultAccount(cfg)) {
throw new Error(
"Legacy Matrix client storage cannot be migrated automatically because multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set.",
);
}
const selectedAccountId = normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg));
const currentAccountId = normalizeAccountId(params.accountKey);
if (selectedAccountId !== currentAccountId) {
throw new Error(
`Legacy Matrix client storage targets account "${selectedAccountId}", but the current client is starting account "${currentAccountId}". Start the selected account first so flat legacy storage is not migrated into the wrong account directory.`,
);
}
}
function scoreStorageRoot(rootDir: string): number {
let score = 0;
const metadata = readStoredRootMetadata(rootDir);
@@ -425,61 +388,26 @@ export async function maybeMigrateLegacyStorage(params: {
storagePaths: MatrixStoragePaths;
env?: NodeJS.ProcessEnv;
}): Promise<void> {
const legacy = resolveLegacyStoragePaths(params.env);
const hasFlatLegacyStorageFile = fs.existsSync(legacy.storagePath);
const hasAccountScopedLegacyStorageFile = fs.existsSync(params.storagePaths.storagePath);
const syncCache =
hasFlatLegacyStorageFile || hasAccountScopedLegacyStorageFile
? await import("./file-sync-store.js")
: null;
const hasFlatLegacyStorage =
hasFlatLegacyStorageFile &&
(await syncCache?.readLegacyMatrixSyncCacheState(legacy.rootDir)) !== null;
const syncCache = hasAccountScopedLegacyStorageFile ? await import("./file-sync-store.js") : null;
const hasAccountScopedLegacyStorage =
hasAccountScopedLegacyStorageFile &&
(await syncCache?.readLegacyMatrixSyncCacheState(params.storagePaths.rootDir)) !== null;
const hasLegacyCrypto = fs.existsSync(legacy.cryptoPath);
const hasAccountScopedRecoveryKey = fs.existsSync(params.storagePaths.recoveryKeyPath);
const hasAccountScopedIdbSnapshot = fs.existsSync(params.storagePaths.idbSnapshotPath);
const hasAccountScopedLegacyCryptoMigration = fs.existsSync(
path.join(params.storagePaths.rootDir, MATRIX_LEGACY_CRYPTO_MIGRATION_FILENAME),
);
if (
!hasFlatLegacyStorage &&
!hasAccountScopedLegacyStorage &&
!hasLegacyCrypto &&
!hasAccountScopedRecoveryKey &&
!hasAccountScopedIdbSnapshot &&
!hasAccountScopedLegacyCryptoMigration
) {
return;
}
const hasTargetCrypto = fs.existsSync(params.storagePaths.cryptoPath);
const shouldMigrateCrypto = hasLegacyCrypto && !hasTargetCrypto;
if (
!hasFlatLegacyStorage &&
!hasAccountScopedLegacyStorage &&
!shouldMigrateCrypto &&
!hasAccountScopedRecoveryKey &&
!hasAccountScopedIdbSnapshot &&
!hasAccountScopedLegacyCryptoMigration
) {
return;
}
if (hasFlatLegacyStorage || hasLegacyCrypto) {
assertLegacyMigrationAccountSelection({
accountKey: params.storagePaths.accountKey,
});
}
const logger = getMatrixRuntime().logging.getChildLogger({ module: "matrix-storage" });
const { maybeCreateMatrixMigrationSnapshot } = await import("./migration-snapshot.runtime.js");
await maybeCreateMatrixMigrationSnapshot({
trigger: "matrix-client-fallback",
env: params.env,
log: logger,
});
fs.mkdirSync(params.storagePaths.rootDir, { recursive: true });
const moved: LegacyMoveRecord[] = [];
const pendingArchives: LegacyArchiveRecord[] = [];
@@ -495,28 +423,6 @@ export async function maybeMigrateLegacyStorage(params: {
pendingArchives,
});
}
if (hasFlatLegacyStorage) {
await migrateLegacySyncCacheToSqlite({
sourceRootDir: legacy.rootDir,
sourcePath: legacy.storagePath,
targetRootDir: params.storagePaths.rootDir,
label: "flat sync cache",
moved,
pendingArchives,
});
}
if (shouldMigrateCrypto) {
moveLegacyStoragePathOrThrow({
sourcePath: legacy.cryptoPath,
targetPath: params.storagePaths.cryptoPath,
label: "crypto store",
moved,
});
} else if (hasLegacyCrypto) {
skippedExistingTargets.push(
`- crypto store remains at ${legacy.cryptoPath} because ${params.storagePaths.cryptoPath} already exists`,
);
}
if (hasAccountScopedRecoveryKey) {
migrateLegacyMatrixRecoveryKeyFileToStore(params.storagePaths.rootDir);
moved.push({
@@ -565,7 +471,7 @@ export async function maybeMigrateLegacyStorage(params: {
}
if (skippedExistingTargets.length > 0) {
logger.warn?.(
`matrix: legacy client storage still exists in the flat path because some account-scoped targets already existed.\n${skippedExistingTargets.join("\n")}`,
`matrix: legacy client storage files were left in place because their migrated targets already existed.\n${skippedExistingTargets.join("\n")}`,
);
}
}
@@ -661,28 +567,6 @@ function archiveLegacyStoragePath(params: {
fs.renameSync(params.sourcePath, archivedLegacyStoragePath);
}
function moveLegacyStoragePathOrThrow(params: {
sourcePath: string;
targetPath: string;
label: string;
moved: LegacyMoveRecord[];
}): void {
if (!fs.existsSync(params.sourcePath)) {
return;
}
if (fs.existsSync(params.targetPath)) {
throw new Error(
`legacy Matrix ${params.label} target already exists (${params.targetPath}); refusing to overwrite it automatically`,
);
}
fs.renameSync(params.sourcePath, params.targetPath);
params.moved.push({
sourcePath: params.sourcePath,
targetPath: params.targetPath,
label: params.label,
});
}
function rollbackLegacyMoves(moved: LegacyMoveRecord[]): string | null {
for (const entry of moved.toReversed()) {
try {
@@ -1,98 +0,0 @@
// Matrix plugin module implements legacy crypto inspector behavior.
import crypto from "node:crypto";
import fs from "node:fs";
import { createRequire } from "node:module";
import path from "node:path";
import { ensureMatrixCryptoRuntime } from "./deps.js";
type MatrixLegacyCryptoInspectionResult = {
deviceId: string | null;
roomKeyCounts: {
total: number;
backedUp: number;
} | null;
backupVersion: string | null;
decryptionKeyBase64: string | null;
};
const MATRIX_CRYPTO_STORE_SQLITE = 0;
function resolveLegacyMachineStorePath(params: {
cryptoRootDir: string;
deviceId: string;
}): string | null {
const hashedDir = path.join(
params.cryptoRootDir,
crypto.createHash("sha256").update(params.deviceId).digest("hex"),
);
if (fs.existsSync(path.join(hashedDir, "matrix-sdk-crypto.sqlite3"))) {
return hashedDir;
}
if (fs.existsSync(path.join(params.cryptoRootDir, "matrix-sdk-crypto.sqlite3"))) {
return params.cryptoRootDir;
}
const match = fs
.readdirSync(params.cryptoRootDir, { withFileTypes: true })
.find(
(entry) =>
entry.isDirectory() &&
fs.existsSync(path.join(params.cryptoRootDir, entry.name, "matrix-sdk-crypto.sqlite3")),
);
return match ? path.join(params.cryptoRootDir, match.name) : null;
}
export async function inspectLegacyMatrixCryptoStore(params: {
cryptoRootDir: string;
userId: string;
deviceId: string;
log?: (message: string) => void;
}): Promise<MatrixLegacyCryptoInspectionResult> {
const machineStorePath = resolveLegacyMachineStorePath(params);
if (!machineStorePath) {
throw new Error(`Matrix legacy crypto store not found for device ${params.deviceId}`);
}
const requireFn = createRequire(import.meta.url);
await ensureMatrixCryptoRuntime({
requireFn,
resolveFn: requireFn.resolve.bind(requireFn),
log: params.log,
});
const { DeviceId, OlmMachine, UserId } = requireFn(
"@matrix-org/matrix-sdk-crypto-nodejs",
) as typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
const machine = await OlmMachine.initialize(
new UserId(params.userId),
new DeviceId(params.deviceId),
machineStorePath,
"",
MATRIX_CRYPTO_STORE_SQLITE,
);
try {
const [backupKeys, roomKeyCounts] = await Promise.all([
machine.getBackupKeys(),
machine.roomKeyCounts(),
]);
return {
deviceId: params.deviceId,
roomKeyCounts: roomKeyCounts
? {
total: typeof roomKeyCounts.total === "number" ? roomKeyCounts.total : 0,
backedUp: typeof roomKeyCounts.backedUp === "number" ? roomKeyCounts.backedUp : 0,
}
: null,
backupVersion:
typeof backupKeys?.backupVersion === "string" && backupKeys.backupVersion.trim()
? backupKeys.backupVersion
: null,
decryptionKeyBase64:
typeof backupKeys?.decryptionKeyBase64 === "string" && backupKeys.decryptionKeyBase64.trim()
? backupKeys.decryptionKeyBase64
: null,
};
} finally {
machine.close();
}
}
@@ -370,10 +370,6 @@ vi.mock("./inbound-dedupe.js", () => ({
createMatrixInboundEventDeduper: hoisted.createMatrixInboundEventDeduper,
}));
vi.mock("./legacy-crypto-restore.js", () => ({
maybeRestoreLegacyMatrixBackup: vi.fn(),
}));
vi.mock("./room-info.js", () => ({
createMatrixRoomInfoResolver: vi.fn(() => ({
getRoomInfo: hoisted.getRoomInfo,
@@ -1,212 +0,0 @@
// Matrix tests cover legacy crypto restore plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../storage-paths.js";
import { installMatrixTestRuntime } from "../../test-runtime.js";
import { readMatrixLegacyCryptoMigrationState } from "../crypto-state-store.js";
import type { MatrixRoomKeyBackupRestoreResult } from "../sdk.js";
import { maybeRestoreLegacyMatrixBackup } from "./legacy-crypto-restore.js";
function createBackupStatus() {
return {
serverVersion: "1",
activeVersion: "1",
trusted: true,
matchesDecryptionKey: true,
decryptionKeyCached: true,
keyLoadAttempted: true,
keyLoadError: null,
};
}
function writeFile(filePath: string, value: string) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, value, "utf8");
}
const BASE_AUTH = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
};
type MatrixAuth = typeof BASE_AUTH;
function readLegacyMigrationState(rootDir: string) {
return readMatrixLegacyCryptoMigrationState(rootDir) as Record<string, unknown> | null;
}
async function runLegacyRestoreScenario(params: {
migration: Record<string, unknown>;
auth?: MatrixAuth;
sourceAuth?: MatrixAuth;
restoreRoomKeyBackup: () => Promise<MatrixRoomKeyBackupRestoreResult>;
}) {
return withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const auth = params.auth ?? BASE_AUTH;
const sourceAuth = params.sourceAuth ?? auth;
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
...auth,
});
const { rootDir: sourceRootDir } = resolveMatrixAccountStorageRoot({
stateDir,
...sourceAuth,
});
writeFile(
path.join(sourceRootDir, "legacy-crypto-migration.json"),
JSON.stringify(params.migration),
);
const restoreRoomKeyBackup = vi.fn(params.restoreRoomKeyBackup);
const result = await maybeRestoreLegacyMatrixBackup({
client: { restoreRoomKeyBackup },
auth,
stateDir,
env: {
...process.env,
OPENCLAW_STATE_DIR: stateDir,
HOME: home,
},
});
return {
result,
restoreRoomKeyBackup,
rootState: readLegacyMigrationState(rootDir),
rootStateExists: readLegacyMigrationState(rootDir) !== null,
sourceRootState: readLegacyMigrationState(sourceRootDir),
sourceRootStateExists: readLegacyMigrationState(sourceRootDir) !== null,
};
});
}
describe("maybeRestoreLegacyMatrixBackup", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
installMatrixTestRuntime();
});
afterEach(() => {
resetPluginStateStoreForTests();
});
it("marks pending legacy backup restore as completed after success", async () => {
const { result, sourceRootState } = await runLegacyRestoreScenario({
migration: {
version: 1,
accountId: "default",
roomKeyCounts: { total: 10, backedUp: 8 },
restoreStatus: "pending",
},
restoreRoomKeyBackup: async () => ({
success: true,
restoredAt: "2026-03-08T10:00:00.000Z",
imported: 8,
total: 8,
loadedFromSecretStorage: true,
backupVersion: "1",
backup: createBackupStatus(),
}),
});
expect(result).toEqual({
kind: "restored",
imported: 8,
total: 8,
localOnlyKeys: 2,
});
const state = sourceRootState as {
restoreStatus: string;
importedCount: number;
totalCount: number;
};
expect(state.restoreStatus).toBe("completed");
expect(state.importedCount).toBe(8);
expect(state.totalCount).toBe(8);
});
it("keeps the restore pending when startup restore fails", async () => {
const { result, sourceRootState } = await runLegacyRestoreScenario({
migration: {
version: 1,
accountId: "default",
roomKeyCounts: { total: 5, backedUp: 5 },
restoreStatus: "pending",
},
restoreRoomKeyBackup: async () => ({
success: false,
error: "backup unavailable",
imported: 0,
total: 0,
loadedFromSecretStorage: false,
backupVersion: null,
backup: createBackupStatus(),
}),
});
expect(result).toEqual({
kind: "failed",
error: "backup unavailable",
localOnlyKeys: 0,
});
const state = sourceRootState as {
restoreStatus: string;
lastError: string;
};
expect(state.restoreStatus).toBe("pending");
expect(state.lastError).toBe("backup unavailable");
});
it("restores from a sibling token-hash directory when the access token changed", async () => {
const oldAuth = {
...BASE_AUTH,
accessToken: "tok-old",
};
const newAuth = {
...oldAuth,
accessToken: "tok-new",
};
const {
result,
rootStateExists: newRootStateExists,
sourceRootState,
} = await runLegacyRestoreScenario({
auth: newAuth,
sourceAuth: oldAuth,
migration: {
version: 1,
accountId: "default",
roomKeyCounts: { total: 3, backedUp: 3 },
restoreStatus: "pending",
},
restoreRoomKeyBackup: async () => ({
success: true,
restoredAt: "2026-03-08T10:00:00.000Z",
imported: 3,
total: 3,
loadedFromSecretStorage: true,
backupVersion: "1",
backup: createBackupStatus(),
}),
});
expect(result).toEqual({
kind: "restored",
imported: 3,
total: 3,
localOnlyKeys: 0,
});
const oldState = sourceRootState as {
restoreStatus: string;
};
expect(oldState.restoreStatus).toBe("completed");
expect(newRootStateExists).toBe(false);
});
});
@@ -1,136 +0,0 @@
// Matrix plugin module implements legacy crypto restore behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { getMatrixRuntime } from "../../runtime.js";
import { resolveMatrixStoragePaths } from "../client/storage.js";
import type { MatrixAuth } from "../client/types.js";
import {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore,
readMatrixLegacyCryptoMigrationState,
writeMatrixLegacyCryptoMigrationState,
type MatrixLegacyCryptoMigrationState,
} from "../crypto-state-store.js";
import type { MatrixClient } from "../sdk.js";
export type MatrixLegacyCryptoRestoreResult =
| { kind: "skipped" }
| {
kind: "restored";
imported: number;
total: number;
localOnlyKeys: number;
}
| {
kind: "failed";
error: string;
localOnlyKeys: number;
};
async function resolvePendingMigrationStateRoot(params: {
stateDir: string;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId" | "deviceId">;
}): Promise<{
storageRootDir: string;
value: MatrixLegacyCryptoMigrationState | null;
}> {
const { rootDir } = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
userId: params.auth.userId,
accessToken: params.auth.accessToken,
accountId: params.auth.accountId,
deviceId: params.auth.deviceId,
stateDir: params.stateDir,
});
try {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(rootDir);
} catch {
// Startup restore can still proceed from any already-migrated SQLite state.
}
const directValue = readMatrixLegacyCryptoMigrationState(rootDir);
if (directValue?.restoreStatus === "pending") {
return { storageRootDir: rootDir, value: directValue };
}
const accountStorageDir = path.dirname(rootDir);
let siblingEntries: string[];
try {
siblingEntries = (await fs.readdir(accountStorageDir, { withFileTypes: true }))
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.filter((entry) => path.join(accountStorageDir, entry) !== rootDir)
.toSorted((left, right) => left.localeCompare(right));
} catch {
return { storageRootDir: rootDir, value: directValue };
}
for (const sibling of siblingEntries) {
const siblingRootDir = path.join(accountStorageDir, sibling);
try {
migrateLegacyMatrixLegacyCryptoMigrationFileToStore(siblingRootDir);
} catch {
// Sibling scan is best-effort; unreadable roots are ignored.
}
const value = readMatrixLegacyCryptoMigrationState(siblingRootDir);
if (value?.restoreStatus === "pending") {
return { storageRootDir: siblingRootDir, value };
}
}
return { storageRootDir: rootDir, value: directValue };
}
export async function maybeRestoreLegacyMatrixBackup(params: {
client: Pick<MatrixClient, "restoreRoomKeyBackup">;
auth: Pick<MatrixAuth, "homeserver" | "userId" | "accessToken" | "accountId" | "deviceId">;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): Promise<MatrixLegacyCryptoRestoreResult> {
const env = params.env ?? process.env;
const stateDir = params.stateDir ?? getMatrixRuntime().state.resolveStateDir(env, os.homedir);
const { storageRootDir, value } = await resolvePendingMigrationStateRoot({
stateDir,
auth: params.auth,
});
if (value?.restoreStatus !== "pending") {
return { kind: "skipped" };
}
const restore = await params.client.restoreRoomKeyBackup();
const localOnlyKeys =
value.roomKeyCounts && value.roomKeyCounts.total > value.roomKeyCounts.backedUp
? value.roomKeyCounts.total - value.roomKeyCounts.backedUp
: 0;
if (restore.success) {
writeMatrixLegacyCryptoMigrationState({
storageRootDir,
state: {
...value,
restoreStatus: "completed",
restoredAt: restore.restoredAt ?? new Date().toISOString(),
importedCount: restore.imported,
totalCount: restore.total,
lastError: null,
} satisfies MatrixLegacyCryptoMigrationState,
});
return {
kind: "restored",
imported: restore.imported,
total: restore.total,
localOnlyKeys,
};
}
writeMatrixLegacyCryptoMigrationState({
storageRootDir,
state: {
...value,
lastError: restore.error ?? "unknown",
} satisfies MatrixLegacyCryptoMigrationState,
});
return {
kind: "failed",
error: restore.error ?? "unknown",
localOnlyKeys,
};
}
@@ -5,7 +5,6 @@ import type { MatrixAccountPatch } from "../config-update.js";
import type { MatrixManagedDeviceInfo } from "../device-health.js";
import type { MatrixProfileSyncResult } from "../profile.js";
import type { MatrixOwnDeviceVerificationStatus } from "../sdk.js";
import type { MatrixLegacyCryptoRestoreResult } from "./legacy-crypto-restore.js";
import type { MatrixStartupVerificationOutcome } from "./startup-verification.js";
import type { MatrixStartupMaintenanceDeps } from "./startup.js";
import { runMatrixStartupMaintenance } from "./startup.js";
@@ -78,20 +77,10 @@ async function expectMatrixStartupAbort(promise: Promise<unknown>): Promise<void
expect(error.message).toBe("Matrix startup aborted");
}
function createLegacyCryptoRestoreResult(
overrides: Partial<MatrixLegacyCryptoRestoreResult> = {},
): MatrixLegacyCryptoRestoreResult {
return {
kind: "skipped",
...overrides,
} as MatrixLegacyCryptoRestoreResult;
}
function createDeps(
overrides: Partial<MatrixStartupMaintenanceDeps> = {},
): MatrixStartupMaintenanceDeps {
return {
maybeRestoreLegacyMatrixBackup: vi.fn(async () => createLegacyCryptoRestoreResult()),
summarizeMatrixDeviceHealth: vi.fn(() => ({
currentDeviceId: null,
staleOpenClawDevices: [] as MatrixManagedDeviceInfo[],
@@ -221,15 +210,6 @@ describe("runMatrixStartupMaintenance", () => {
vi.mocked(deps.ensureMatrixStartupVerification).mockResolvedValue(
createStartupVerificationOutcome("pending"),
);
vi.mocked(deps.maybeRestoreLegacyMatrixBackup).mockResolvedValue(
createLegacyCryptoRestoreResult({
kind: "restored",
imported: 2,
total: 3,
localOnlyKeys: 1,
}),
);
await runMatrixStartupMaintenance(params, deps);
expect(params.logger.warn).toHaveBeenCalledWith(
@@ -241,12 +221,6 @@ describe("runMatrixStartupMaintenance", () => {
expect(params.logger.info).toHaveBeenCalledWith(
"matrix: startup verification request is already pending; finish it in another Matrix client",
);
expect(params.logger.info).toHaveBeenCalledWith(
"matrix: restored 2/3 room key(s) from legacy encrypted-state backup",
);
expect(params.logger.warn).toHaveBeenCalledWith(
"matrix: 1 legacy local-only room key(s) were never backed up and could not be restored automatically",
);
});
it("logs cooldown and request-failure verification outcomes without throwing", async () => {
@@ -286,6 +260,5 @@ describe("runMatrixStartupMaintenance", () => {
await expectMatrixStartupAbort(runMatrixStartupMaintenance(params, deps));
expect(deps.ensureMatrixStartupVerification).not.toHaveBeenCalled();
expect(deps.maybeRestoreLegacyMatrixBackup).not.toHaveBeenCalled();
});
});
@@ -22,7 +22,6 @@ export type MatrixStartupMaintenanceDeps = {
updateMatrixAccountConfig: typeof import("../config-update.js").updateMatrixAccountConfig;
summarizeMatrixDeviceHealth: typeof import("../device-health.js").summarizeMatrixDeviceHealth;
syncMatrixOwnProfile: typeof import("../profile.js").syncMatrixOwnProfile;
maybeRestoreLegacyMatrixBackup: typeof import("./legacy-crypto-restore.js").maybeRestoreLegacyMatrixBackup;
ensureMatrixStartupVerification: typeof import("./startup-verification.js").ensureMatrixStartupVerification;
};
@@ -31,23 +30,13 @@ const loadMatrixStartupMaintenanceDeps = createLazyRuntimeModule(() =>
import("../config-update.js"),
import("../device-health.js"),
import("../profile.js"),
import("./legacy-crypto-restore.js"),
import("./startup-verification.js"),
]).then(
([
configUpdateModule,
deviceHealthModule,
profileModule,
legacyCryptoRestoreModule,
startupVerificationModule,
]) => ({
updateMatrixAccountConfig: configUpdateModule.updateMatrixAccountConfig,
summarizeMatrixDeviceHealth: deviceHealthModule.summarizeMatrixDeviceHealth,
syncMatrixOwnProfile: profileModule.syncMatrixOwnProfile,
maybeRestoreLegacyMatrixBackup: legacyCryptoRestoreModule.maybeRestoreLegacyMatrixBackup,
ensureMatrixStartupVerification: startupVerificationModule.ensureMatrixStartupVerification,
}),
),
]).then(([configUpdateModule, deviceHealthModule, profileModule, startupVerificationModule]) => ({
updateMatrixAccountConfig: configUpdateModule.updateMatrixAccountConfig,
summarizeMatrixDeviceHealth: deviceHealthModule.summarizeMatrixDeviceHealth,
syncMatrixOwnProfile: profileModule.syncMatrixOwnProfile,
ensureMatrixStartupVerification: startupVerificationModule.ensureMatrixStartupVerification,
})),
);
export async function runMatrixStartupMaintenance(
@@ -178,40 +167,4 @@ export async function runMatrixStartupMaintenance(
error: String(err),
});
}
try {
throwIfMatrixStartupAborted(params.abortSignal);
const legacyCryptoRestore = await runtimeDeps.maybeRestoreLegacyMatrixBackup({
client: params.client,
auth: params.auth,
env: params.env,
});
throwIfMatrixStartupAborted(params.abortSignal);
if (legacyCryptoRestore.kind === "restored") {
params.logger.info(
`matrix: restored ${legacyCryptoRestore.imported}/${legacyCryptoRestore.total} room key(s) from legacy encrypted-state backup`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
params.logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and could not be restored automatically`,
);
}
} else if (legacyCryptoRestore.kind === "failed") {
params.logger.warn(
`matrix: failed restoring room keys from legacy encrypted-state backup: ${legacyCryptoRestore.error}`,
);
if (legacyCryptoRestore.localOnlyKeys > 0) {
params.logger.warn(
`matrix: ${legacyCryptoRestore.localOnlyKeys} legacy local-only room key(s) were never backed up and may remain unavailable until manually recovered`,
);
}
}
} catch (err) {
if (isMatrixStartupAbortError(err)) {
throw err;
}
params.logger.warn("matrix: failed restoring legacy encrypted-state backup", {
error: String(err),
});
}
}
@@ -1,239 +0,0 @@
// Matrix tests cover migration config plugin behavior.
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";
import { resolveMatrixMigrationAccountTarget } from "./migration-config.js";
import {
MATRIX_OPS_ACCESS_TOKEN,
MATRIX_OPS_ACCOUNT_ID,
MATRIX_OPS_USER_ID,
MATRIX_TEST_HOMESERVER,
writeMatrixCredentials,
} from "./test-helpers.js";
function resolveOpsTarget(cfg: OpenClawConfig, env = process.env) {
return resolveMatrixMigrationAccountTarget({
cfg,
env,
accountId: MATRIX_OPS_ACCOUNT_ID,
});
}
type MatrixMigrationTarget = NonNullable<ReturnType<typeof resolveOpsTarget>>;
function expectMigrationTarget(target: ReturnType<typeof resolveOpsTarget>): MatrixMigrationTarget {
if (target === null) {
throw new Error("Expected Matrix migration account target");
}
expect(typeof target.homeserver).toBe("string");
return target;
}
describe("resolveMatrixMigrationAccountTarget", () => {
it("reuses stored user identity for token-only configs when the access token matches", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
deviceId: "DEVICE-OPS",
accessToken: MATRIX_OPS_ACCESS_TOKEN,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
const migrationTarget = expectMigrationTarget(target);
expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID);
expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("ignores stored device IDs from stale cached Matrix credentials", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@new-bot:example.org",
accessToken: "tok-new",
},
},
},
},
};
const target = resolveOpsTarget(cfg);
const migrationTarget = expectMigrationTarget(target);
expect(migrationTarget.userId).toBe("@new-bot:example.org");
expect(migrationTarget.accessToken).toBe("tok-new");
expect(migrationTarget.storedDeviceId).toBeNull();
});
});
it("does not trust stale stored creds on the same homeserver when the token changes", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
userId: "@old-bot:example.org",
accessToken: "tok-old",
deviceId: "DEVICE-OLD",
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: "tok-new",
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
});
});
it("does not inherit the base userId for non-default token-only accounts", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
writeMatrixCredentials(stateDir, {
accountId: MATRIX_OPS_ACCOUNT_ID,
deviceId: "DEVICE-OPS",
accessToken: MATRIX_OPS_ACCESS_TOKEN,
});
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@base-bot:example.org",
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
accessToken: MATRIX_OPS_ACCESS_TOKEN,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
const migrationTarget = expectMigrationTarget(target);
expect(migrationTarget.userId).toBe(MATRIX_OPS_USER_ID);
expect(migrationTarget.storedDeviceId).toBe("DEVICE-OPS");
});
});
it("does not inherit the base access token for non-default accounts", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: "@base-bot:example.org",
accessToken: "tok-base",
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
});
});
it("does not inherit the global Matrix access token for non-default accounts", async () => {
await withTempHome(
async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
ops: {
homeserver: MATRIX_TEST_HOMESERVER,
userId: MATRIX_OPS_USER_ID,
},
},
},
},
};
const target = resolveOpsTarget(cfg);
expect(target).toBeNull();
},
{
env: {
MATRIX_ACCESS_TOKEN: "tok-global",
},
},
);
});
it("uses the same scoped env token encoding as runtime account auth", async () => {
await withTempHome(async () => {
const cfg: OpenClawConfig = {
channels: {
matrix: {
accounts: {
"ops-prod": {},
},
},
},
};
const env = {
MATRIX_OPS_X2D_PROD_HOMESERVER: "https://matrix.example.org",
MATRIX_OPS_X2D_PROD_USER_ID: "@ops-prod:example.org",
MATRIX_OPS_X2D_PROD_ACCESS_TOKEN: "tok-ops-prod",
} as NodeJS.ProcessEnv;
const target = resolveMatrixMigrationAccountTarget({
cfg,
env,
accountId: "ops-prod",
});
const migrationTarget = expectMigrationTarget(target);
expect(migrationTarget.homeserver).toBe("https://matrix.example.org");
expect(migrationTarget.userId).toBe("@ops-prod:example.org");
expect(migrationTarget.accessToken).toBe("tok-ops-prod");
});
});
});
-244
View File
@@ -1,244 +0,0 @@
// Matrix helper module supports migration config behavior.
import fs from "node:fs";
import os from "node:os";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
findMatrixAccountEntry,
requiresExplicitMatrixDefaultAccount,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixDefaultOrOnlyAccountId,
} from "./account-selection.js";
import { resolveMatrixAccountStringValues } from "./auth-precedence.js";
import {
resolveGlobalMatrixEnvConfig,
resolveScopedMatrixEnvConfig,
} from "./matrix/client/env-auth.js";
import { resolveMatrixAccountStorageRoot, resolveMatrixCredentialsPath } from "./storage-paths.js";
type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
};
type MatrixMigrationAccountTarget = {
accountId: string;
homeserver: string;
userId: string;
accessToken: string;
rootDir: string;
storedDeviceId: string | null;
};
type MatrixLegacyFlatStoreTarget = MatrixMigrationAccountTarget & {
selectionNote?: string;
};
type MatrixLegacyFlatStoreKind = "state" | "encrypted state";
function clean(value: unknown): string {
return normalizeOptionalString(value) ?? "";
}
function resolveMatrixAccountConfigEntry(
cfg: OpenClawConfig,
accountId: string,
): Record<string, unknown> | null {
return findMatrixAccountEntry(cfg, accountId);
}
function resolveMatrixFlatStoreSelectionNote(
cfg: OpenClawConfig,
accountId: string,
): string | undefined {
if (resolveConfiguredMatrixAccountIds(cfg).length <= 1) {
return undefined;
}
return (
`Legacy Matrix flat store uses one shared on-disk state, so it will be migrated into ` +
`account "${accountId}".`
);
}
function resolveMatrixMigrationConfigFields(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): {
homeserver: string;
userId: string;
accessToken: string;
} {
const channel = resolveMatrixChannelConfig(params.cfg);
const account = resolveMatrixAccountConfigEntry(params.cfg, params.accountId);
const scopedEnv = resolveScopedMatrixEnvConfig(params.accountId, params.env);
const globalEnv = resolveGlobalMatrixEnvConfig(params.env);
const normalizedAccountId = normalizeAccountId(params.accountId);
const resolvedStrings = resolveMatrixAccountStringValues({
accountId: normalizedAccountId,
account: {
homeserver: clean(account?.homeserver),
userId: clean(account?.userId),
accessToken: clean(account?.accessToken),
},
scopedEnv,
channel: {
homeserver: clean(channel?.homeserver),
userId: clean(channel?.userId),
accessToken: clean(channel?.accessToken),
},
globalEnv,
});
return {
homeserver: resolvedStrings.homeserver,
userId: resolvedStrings.userId,
accessToken: resolvedStrings.accessToken,
};
}
function loadStoredMatrixCredentials(
env: NodeJS.ProcessEnv,
accountId: string,
): MatrixStoredCredentials | null {
const stateDir = resolveStateDir(env, os.homedir);
const credentialsPath = resolveMatrixCredentialsPath({
stateDir,
accountId: normalizeAccountId(accountId),
});
try {
if (!fs.existsSync(credentialsPath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(credentialsPath, "utf8"),
) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return {
homeserver: parsed.homeserver,
userId: parsed.userId,
accessToken: parsed.accessToken,
deviceId: typeof parsed.deviceId === "string" ? parsed.deviceId : undefined,
};
} catch {
return null;
}
}
function credentialsMatchResolvedIdentity(
stored: MatrixStoredCredentials | null,
identity: {
homeserver: string;
userId: string;
accessToken: string;
},
): stored is MatrixStoredCredentials {
if (!stored || !identity.homeserver) {
return false;
}
if (!identity.userId) {
if (!identity.accessToken) {
return false;
}
return stored.homeserver === identity.homeserver && stored.accessToken === identity.accessToken;
}
return stored.homeserver === identity.homeserver && stored.userId === identity.userId;
}
export function resolveMatrixMigrationAccountTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
accountId: string;
}): MatrixMigrationAccountTarget | null {
const stored = loadStoredMatrixCredentials(params.env, params.accountId);
const resolved = resolveMatrixMigrationConfigFields(params);
const matchingStored = credentialsMatchResolvedIdentity(stored, {
homeserver: resolved.homeserver,
userId: resolved.userId,
accessToken: resolved.accessToken,
})
? stored
: null;
const homeserver = resolved.homeserver;
const userId = resolved.userId || matchingStored?.userId || "";
const accessToken = resolved.accessToken || matchingStored?.accessToken || "";
if (!homeserver || !userId || !accessToken) {
return null;
}
const stateDir = resolveStateDir(params.env, os.homedir);
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver,
userId,
accessToken,
accountId: params.accountId,
});
return {
accountId: params.accountId,
homeserver,
userId,
accessToken,
rootDir,
storedDeviceId: matchingStored?.deviceId ?? null,
};
}
export function resolveLegacyMatrixFlatStoreTarget(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
detectedPath: string;
detectedKind: MatrixLegacyFlatStoreKind;
}): MatrixLegacyFlatStoreTarget | { warning: string } {
const channel = resolveMatrixChannelConfig(params.cfg);
if (!channel) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but channels.matrix is not configured yet. ` +
'Configure Matrix, then rerun "openclaw doctor --fix" or restart the gateway.',
};
}
if (requiresExplicitMatrixDefaultAccount(params.cfg)) {
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but multiple Matrix accounts are configured and channels.matrix.defaultAccount is not set. ` +
'Set "channels.matrix.defaultAccount" to the intended target account before rerunning "openclaw doctor --fix" or restarting the gateway.',
};
}
const accountId = resolveMatrixDefaultOrOnlyAccountId(params.cfg);
const target = resolveMatrixMigrationAccountTarget({
cfg: params.cfg,
env: params.env,
accountId,
});
if (!target) {
const targetDescription =
params.detectedKind === "state"
? "the new account-scoped target"
: "the account-scoped target";
return {
warning:
`Legacy Matrix ${params.detectedKind} detected at ${params.detectedPath}, but ${targetDescription} could not be resolved yet ` +
`(need homeserver, userId, and access token for channels.matrix${accountId === DEFAULT_ACCOUNT_ID ? "" : `.accounts.${accountId}`}). ` +
'Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.',
};
}
return {
...target,
selectionNote: resolveMatrixFlatStoreSelectionNote(params.cfg, accountId),
};
}
@@ -1,117 +0,0 @@
// Matrix plugin module implements migration snapshot backup behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store";
import { resolveRequiredHomeDir, resolveStateDir } from "openclaw/plugin-sdk/state-paths";
const MATRIX_MIGRATION_SNAPSHOT_DIRNAME = "openclaw-migrations";
type MatrixMigrationSnapshotMarker = {
version: 1;
createdAt: string;
archivePath: string;
trigger: string;
includeWorkspace: boolean;
};
type MatrixMigrationSnapshotResult = {
created: boolean;
archivePath: string;
markerPath: string;
};
function loadSnapshotMarker(filePath: string): MatrixMigrationSnapshotMarker | null {
try {
if (!fs.existsSync(filePath)) {
return null;
}
const parsed = JSON.parse(
fs.readFileSync(filePath, "utf8"),
) as Partial<MatrixMigrationSnapshotMarker>;
if (
parsed.version !== 1 ||
typeof parsed.createdAt !== "string" ||
typeof parsed.archivePath !== "string" ||
typeof parsed.trigger !== "string"
) {
return null;
}
return {
version: 1,
createdAt: parsed.createdAt,
archivePath: parsed.archivePath,
trigger: parsed.trigger,
includeWorkspace: parsed.includeWorkspace === true,
};
} catch {
return null;
}
}
export function resolveMatrixMigrationSnapshotMarkerPath(
env: NodeJS.ProcessEnv = process.env,
): string {
const stateDir = resolveStateDir(env, os.homedir);
return path.join(stateDir, "matrix", "migration-snapshot.json");
}
export function resolveMatrixMigrationSnapshotOutputDir(
env: NodeJS.ProcessEnv = process.env,
): string {
const homeDir = resolveRequiredHomeDir(env, os.homedir);
return path.join(homeDir, "Backups", MATRIX_MIGRATION_SNAPSHOT_DIRNAME);
}
export async function maybeCreateMatrixMigrationSnapshot(params: {
trigger: string;
env?: NodeJS.ProcessEnv;
outputDir?: string;
createBackupArchive?: typeof import("openclaw/plugin-sdk/runtime").createBackupArchive;
log?: { info?: (message: string) => void; warn?: (message: string) => void };
}): Promise<MatrixMigrationSnapshotResult> {
const env = params.env ?? process.env;
const createBackupArchive =
params.createBackupArchive ?? (await import("openclaw/plugin-sdk/runtime")).createBackupArchive;
const markerPath = resolveMatrixMigrationSnapshotMarkerPath(env);
const existingMarker = loadSnapshotMarker(markerPath);
if (existingMarker?.archivePath && fs.existsSync(existingMarker.archivePath)) {
params.log?.info?.(
`matrix: reusing existing pre-migration backup snapshot: ${existingMarker.archivePath}`,
);
return {
created: false,
archivePath: existingMarker.archivePath,
markerPath,
};
}
if (existingMarker?.archivePath && !fs.existsSync(existingMarker.archivePath)) {
params.log?.warn?.(
`matrix: previous migration snapshot is missing (${existingMarker.archivePath}); creating a replacement backup before continuing`,
);
}
const snapshot = await createBackupArchive({
output: (() => {
const outputDir = params.outputDir ?? resolveMatrixMigrationSnapshotOutputDir(env);
fs.mkdirSync(outputDir, { recursive: true });
return outputDir;
})(),
includeWorkspace: false,
});
const marker: MatrixMigrationSnapshotMarker = {
version: 1,
createdAt: snapshot.createdAt,
archivePath: snapshot.archivePath,
trigger: params.trigger,
includeWorkspace: snapshot.includeWorkspace,
};
await writeJsonFileAtomically(markerPath, marker);
params.log?.info?.(`matrix: created pre-migration backup snapshot: ${snapshot.archivePath}`);
return {
created: true,
archivePath: snapshot.archivePath,
markerPath,
};
}
@@ -1,171 +0,0 @@
// Matrix tests cover migration snapshot plugin behavior.
import fs from "node:fs";
import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
const legacyCryptoInspectorAvailability = vi.hoisted(() => ({
available: true,
}));
vi.mock("./legacy-crypto-inspector-availability.js", () => ({
isMatrixLegacyCryptoInspectorAvailable: () => legacyCryptoInspectorAvailability.available,
}));
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import {
hasActionableMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} from "./migration-snapshot.js";
import { resolveMatrixAccountStorageRoot } from "./storage-paths.js";
const createBackupArchiveMock = vi.hoisted(() => vi.fn());
const MATRIX_CREDENTIALS = {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
} as const;
function makeMatrixMigrationConfig() {
return {
channels: {
matrix: MATRIX_CREDENTIALS,
},
} as never;
}
function seedLegacyMatrixCrypto(home: string) {
const stateDir = path.join(home, ".openclaw");
const { rootDir } = resolveMatrixAccountStorageRoot({
stateDir,
...MATRIX_CREDENTIALS,
});
fs.mkdirSync(path.join(rootDir, "crypto"), { recursive: true });
fs.writeFileSync(
path.join(rootDir, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
"utf8",
);
}
describe("matrix migration snapshots", () => {
beforeEach(() => {
createBackupArchiveMock.mockReset();
legacyCryptoInspectorAvailability.available = true;
createBackupArchiveMock.mockImplementation(
async (params: { output?: string; includeWorkspace?: boolean }) => {
const outputDir = params.output;
if (!outputDir) {
throw new Error("expected migration snapshot output dir");
}
fs.mkdirSync(outputDir, { recursive: true });
const archivePath = path.join(outputDir, "matrix-migration-backup.tar.gz");
fs.writeFileSync(archivePath, "archive\n", "utf8");
return {
createdAt: "2026-04-05T00:00:00.000Z",
archivePath,
includeWorkspace: params.includeWorkspace ?? true,
};
},
);
});
it("creates a backup marker after writing a pre-migration snapshot", async () => {
await withTempHome(async (home) => {
fs.writeFileSync(path.join(home, ".openclaw", "openclaw.json"), "{}\n", "utf8");
fs.writeFileSync(path.join(home, ".openclaw", "state.txt"), "state\n", "utf8");
const result = await maybeCreateMatrixMigrationSnapshot({
trigger: "unit-test",
createBackupArchive: createBackupArchiveMock,
});
expect(result.created).toBe(true);
expect(result.markerPath).toBe(resolveMatrixMigrationSnapshotMarkerPath(process.env));
expect(
result.archivePath.startsWith(resolveMatrixMigrationSnapshotOutputDir(process.env)),
).toBe(true);
expect(fs.existsSync(result.archivePath)).toBe(true);
expect(createBackupArchiveMock).toHaveBeenCalledWith({
output: resolveMatrixMigrationSnapshotOutputDir(process.env),
includeWorkspace: false,
});
});
});
it("treats resolvable Matrix legacy state as actionable", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
fs.mkdirSync(path.join(stateDir, "matrix"), { recursive: true });
fs.writeFileSync(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"legacy":true}',
"utf8",
);
expect(
hasActionableMatrixMigration({
cfg: {
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
} as never,
env: process.env,
}),
).toBe(true);
});
});
it("treats legacy Matrix crypto as actionable when the extension inspector is present", async () => {
await withTempHome(async (home) => {
seedLegacyMatrixCrypto(home);
const cfg = makeMatrixMigrationConfig();
const detection = detectLegacyMatrixCrypto({
cfg,
env: process.env,
});
expect(detection.inspectorAvailable).toBe(true);
expect(detection.plans).toHaveLength(1);
expect(detection.warnings).toStrictEqual([]);
expect(
hasActionableMatrixMigration({
cfg,
env: process.env,
}),
).toBe(true);
});
});
it("keeps legacy Matrix crypto pending but not actionable when the inspector artifact is unavailable", async () => {
legacyCryptoInspectorAvailability.available = false;
await withTempHome(async (home) => {
seedLegacyMatrixCrypto(home);
const cfg = makeMatrixMigrationConfig();
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.",
);
expect(
hasActionableMatrixMigration({
cfg,
env: process.env,
}),
).toBe(false);
});
});
});
@@ -1,54 +0,0 @@
// Matrix plugin module implements migration snapshot behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { detectLegacyMatrixCrypto } from "./legacy-crypto.js";
import { detectLegacyMatrixState } from "./legacy-state.js";
import {
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
} 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 {
return resolveMatrixMigrationStatus(params).pending;
}
export function hasActionableMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): boolean {
return resolveMatrixMigrationStatus(params).actionable;
}
export {
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationSnapshotMarkerPath,
resolveMatrixMigrationSnapshotOutputDir,
};
@@ -1,231 +0,0 @@
// Matrix tests cover startup maintenance plugin behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { withTempHome } from "openclaw/plugin-sdk/test-env";
import { beforeEach, describe, expect, it, vi } from "vitest";
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");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"legacy":true}');
}
function makeMatrixStartupConfig(includeCredentials = true) {
return {
channels: {
matrix: includeCredentials
? {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
}
: {
homeserver: "https://matrix.example.org",
},
},
} 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 () => ({
created: true,
archivePath: "/tmp/snapshot.tar.gz",
markerPath: "/tmp/migration-snapshot.json",
})),
autoMigrateLegacyMatrixState: vi.fn(async () => ({
migrated: true,
changes: [],
warnings: [],
})),
};
}
function createWarningOnlyMaintenanceHarness() {
return {
deps: {
maybeCreateMatrixMigrationSnapshot: vi.fn(),
autoMigrateLegacyMatrixState: vi.fn(),
autoPrepareLegacyMatrixCrypto: vi.fn(),
},
log: {
info: vi.fn(),
warn: vi.fn(),
},
};
}
function expectWarningOnlyMaintenanceSkipped(
harness: ReturnType<typeof createWarningOnlyMaintenanceHarness>,
) {
expect(harness.deps.maybeCreateMatrixMigrationSnapshot).not.toHaveBeenCalled();
expect(harness.deps.autoMigrateLegacyMatrixState).not.toHaveBeenCalled();
expect(harness.deps.autoPrepareLegacyMatrixCrypto).not.toHaveBeenCalled();
expect(harness.log.info).toHaveBeenCalledWith(
"matrix: migration remains in a warning-only state; no pre-migration snapshot was needed yet",
);
}
describe("runMatrixStartupMaintenance", () => {
beforeEach(() => {
legacyCryptoInspectorAvailability.available = true;
});
it("creates a snapshot before actionable startup migration", async () => {
await withTempHome(async (home) => {
await seedLegacyMatrixState(home);
const deps = createSuccessfulMatrixMigrationDeps();
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => ({
migrated: false,
changes: [],
warnings: [],
}));
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
},
log: {},
});
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledWith({
trigger: "gateway-startup",
env: process.env,
log: {},
});
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
});
});
it("skips snapshot creation when startup only has warning-only migration state", async () => {
await withTempHome(async (home) => {
await seedLegacyMatrixState(home);
const harness = createWarningOnlyMaintenanceHarness();
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(false),
env: process.env,
deps: harness.deps as never,
log: harness.log,
});
expectWarningOnlyMaintenanceSkipped(harness);
expect(harness.log.warn).toHaveBeenCalledWith(
`matrix: Legacy Matrix state detected at ${path.join(home, ".openclaw", "matrix")}, but the new account-scoped target could not be resolved yet (need homeserver, userId, and access token for channels.matrix). Start the gateway once with a working Matrix login, or rerun "openclaw doctor --fix" after cached credentials are available.`,
);
});
});
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 harness = createWarningOnlyMaintenanceHarness();
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: harness.deps as never,
log: harness.log,
});
expectWarningOnlyMaintenanceSkipped(harness);
expect(harness.log.warn).toHaveBeenCalledWith(
"matrix: legacy encrypted-state warnings:\n- Legacy Matrix encrypted state was detected, but the Matrix crypto inspector is unavailable.",
);
});
});
it("skips startup migration when snapshot creation fails", async () => {
await withTempHome(async (home) => {
await seedLegacyMatrixState(home);
const maybeCreateMatrixMigrationSnapshotMock = vi.fn(async () => {
throw new Error("backup failed");
});
const autoMigrateLegacyMatrixStateMock = vi.fn();
const autoPrepareLegacyMatrixCryptoMock = vi.fn();
const warn = vi.fn();
await runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: maybeCreateMatrixMigrationSnapshotMock,
autoMigrateLegacyMatrixState: autoMigrateLegacyMatrixStateMock as never,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock as never,
},
log: { warn },
});
expect(autoMigrateLegacyMatrixStateMock).not.toHaveBeenCalled();
expect(autoPrepareLegacyMatrixCryptoMock).not.toHaveBeenCalled();
expect(warn).toHaveBeenCalledWith(
"gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: Error: backup failed",
);
});
});
it("downgrades migration step failures to warnings so startup can continue", async () => {
await withTempHome(async (home) => {
await seedLegacyMatrixState(home);
const deps = createSuccessfulMatrixMigrationDeps();
const autoPrepareLegacyMatrixCryptoMock = vi.fn(async () => {
throw new Error("disk full");
});
const warn = vi.fn();
await expect(
runMatrixStartupMaintenance({
cfg: makeMatrixStartupConfig(),
env: process.env,
deps: {
maybeCreateMatrixMigrationSnapshot: deps.maybeCreateMatrixMigrationSnapshot,
autoMigrateLegacyMatrixState: deps.autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto: autoPrepareLegacyMatrixCryptoMock,
},
log: { warn },
}),
).resolves.toBeUndefined();
expect(deps.maybeCreateMatrixMigrationSnapshot).toHaveBeenCalledOnce();
expect(deps.autoMigrateLegacyMatrixState).toHaveBeenCalledOnce();
expect(autoPrepareLegacyMatrixCryptoMock).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
"gateway: legacy Matrix encrypted-state preparation failed during Matrix migration; continuing startup: Error: disk full",
);
});
});
});
@@ -1,115 +0,0 @@
// Matrix plugin module implements startup maintenance behavior.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
autoMigrateLegacyMatrixState,
autoPrepareLegacyMatrixCrypto,
maybeCreateMatrixMigrationSnapshot,
resolveMatrixMigrationStatus,
type MatrixMigrationStatus,
} from "./matrix-migration.runtime.js";
type MatrixStartupLogger = {
info?: (message: string) => void;
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;
logPrefix?: string;
run: () => Promise<unknown>;
}): Promise<void> {
try {
await params.run();
} catch (err) {
params.log.warn?.(
`${params.logPrefix?.trim() || "gateway"}: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
);
}
}
export async function runMatrixStartupMaintenance(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log: MatrixStartupLogger;
trigger?: string;
logPrefix?: string;
deps?: {
maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot;
autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState;
autoPrepareLegacyMatrixCrypto?: typeof autoPrepareLegacyMatrixCrypto;
};
}): Promise<void> {
const env = params.env ?? process.env;
const createSnapshot =
params.deps?.maybeCreateMatrixMigrationSnapshot ?? maybeCreateMatrixMigrationSnapshot;
const migrateLegacyState =
params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState;
const prepareLegacyCrypto =
params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto;
const trigger = params.trigger?.trim() || "gateway-startup";
const logPrefix = params.logPrefix?.trim() || "gateway";
const migrationStatus = resolveMatrixMigrationStatus({ cfg: params.cfg, env });
if (!migrationStatus.pending) {
return;
}
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;
}
try {
await createSnapshot({
trigger,
env,
log: params.log,
});
} catch (err) {
params.log.warn?.(
`${logPrefix}: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
);
return;
}
await runBestEffortMatrixMigrationStep({
label: "legacy Matrix state migration",
log: params.log,
logPrefix,
run: () =>
migrateLegacyState({
cfg: params.cfg,
env,
log: params.log,
}),
});
await runBestEffortMatrixMigrationStep({
label: "legacy Matrix encrypted-state preparation",
log: params.log,
logPrefix,
run: () =>
prepareLegacyCrypto({
cfg: params.cfg,
env,
log: params.log,
}),
});
}
-17
View File
@@ -46,23 +46,6 @@ export function resolveMatrixCredentialsPath(params: {
);
}
export function resolveMatrixLegacyFlatStoreRoot(stateDir: string): string {
return path.join(stateDir, "matrix");
}
export function resolveMatrixLegacyFlatStoragePaths(stateDir: string): {
rootDir: string;
storagePath: string;
cryptoPath: string;
} {
const rootDir = resolveMatrixLegacyFlatStoreRoot(stateDir);
return {
rootDir,
storagePath: path.join(rootDir, "bot-storage.json"),
cryptoPath: path.join(rootDir, "crypto"),
};
}
export function resolveMatrixAccountStorageRoot(params: {
stateDir: string;
homeserver: string;
@@ -224,6 +224,5 @@ export function createMSTeamsConversationStoreState(
list,
remove,
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}
@@ -53,7 +53,6 @@ function createMemoryConversationStore(
list: async () => toConversationStoreEntries(map.entries()),
remove: async (conversationId) => map.delete(normalizeStoredConversationId(conversationId)),
findPreferredDmByUserId,
findByUserId: findPreferredDmByUserId,
};
}
@@ -175,9 +174,6 @@ describe.each(storeFactories)("msteams conversation store ($name)", ({ createSto
lastSeenAt: "2026-03-25T20:00:00.000Z",
},
});
await expect(store.findByUserId("user-a")).resolves.toEqual(
await store.findPreferredDmByUserId("user-a"),
);
await expect(store.findPreferredDmByUserId(" ")).resolves.toBeNull();
await expect(store.remove("conv-a")).resolves.toBe(true);
+5 -3
View File
@@ -17,7 +17,11 @@ export type StoredConversationReference = {
user?: { id?: string; name?: string; aadObjectId?: string };
/** Agent/bot that received the message */
agent?: { id?: string; name?: string; aadObjectId?: string } | null;
/** @deprecated legacy field (pre-Agents SDK). Prefer `agent`. */
/**
* Read-only legacy field: pre-Agents-SDK rows imported raw from the year-TTL
* msteams-conversations.json store may carry `bot` without `agent`. Writers
* are canonical (`agent`); drop this once those imported rows age out.
*/
bot?: { id?: string; name?: string };
/** Conversation details */
conversation?: { id?: string; conversationType?: string; tenantId?: string };
@@ -59,6 +63,4 @@ export type MSTeamsConversationStore = {
remove: (conversationId: string) => Promise<boolean>;
/** Person-targeted proactive lookup: prefer the freshest personal DM reference. */
findPreferredDmByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
/** @deprecated Use `findPreferredDmByUserId` for proactive user-targeted sends. */
findByUserId: (id: string) => Promise<MSTeamsConversationStoreEntry | null>;
};
@@ -160,9 +160,6 @@ export async function runMSTeamsFeedbackInvokeHandler(
agent: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
bot: activity.recipient
? { id: activity.recipient.id, name: activity.recipient.name }
: undefined,
conversation: {
id: conversationId,
conversationType: activity.conversation?.conversationType,
+17
View File
@@ -806,6 +806,23 @@ describe("msteams messenger", () => {
expect(reference.aadObjectId).toBe("aad-legacy");
});
it("accepts a legacy bot-only imported reference and resolves the agent from bot", () => {
const botOnly: StoredConversationReference = {
activityId: "activity-bot-only",
user: { id: "user-legacy", name: "Legacy" },
bot: { id: "bot-legacy", name: "Bot" },
conversation: {
id: "a:personal-chat",
conversationType: "personal",
tenantId: "tenant-1",
},
channelId: "msteams",
serviceUrl: "https://smba.trafficmanager.net/amer/",
};
const reference = buildConversationReference(botOnly);
expect(reference.agent).toEqual({ id: "bot-legacy", name: "Bot" });
});
it("omits tenantId and aadObjectId when neither source is available", () => {
const minimal: StoredConversationReference = {
activityId: "activity-2",
+1
View File
@@ -110,6 +110,7 @@ export function buildConversationReference(
if (!conversationId) {
throw new Error("Invalid stored reference: missing conversation.id");
}
// Legacy imported rows may only carry `bot`; see StoredConversationReference.bot.
const agent = ref.agent ?? ref.bot ?? undefined;
if (agent == null || !agent.id) {
throw new Error("Invalid stored reference: missing agent.id");
@@ -57,7 +57,6 @@ function createDeps(): MSTeamsMessageHandlerDeps {
list: vi.fn(async () => []),
remove: vi.fn(async () => false),
findPreferredDmByUserId: vi.fn(async () => null),
findByUserId: vi.fn(async () => null),
} satisfies MSTeamsConversationStore,
pollStore: {
recordVote: vi.fn(async () => null),
@@ -171,7 +171,6 @@ export function createMSTeamsMessageHandlerDeps(params?: {
list: async () => [],
remove: async () => false,
findPreferredDmByUserId: async () => null,
findByUserId: async () => null,
};
const pollStore: MSTeamsPollStore = {
createPoll: async () => {},
@@ -440,10 +440,6 @@ describe("msteams monitor handler authz", () => {
id: "bot-id",
name: "Bot",
},
bot: {
id: "bot-id",
name: "Bot",
},
conversation: {
id: "a:personal-chat",
conversationType: "personal",
@@ -65,7 +65,6 @@ export function createMessageHandlerDeps(
list: vi.fn(async () => []),
remove: vi.fn(async () => false),
findPreferredDmByUserId: vi.fn(async () => null),
findByUserId: vi.fn(async () => null),
} satisfies MSTeamsMessageHandlerDeps["conversationStore"];
const deps: MSTeamsMessageHandlerDeps = {
@@ -157,7 +157,6 @@ function buildStoredConversationReference(params: {
activityId: activity.id,
user: from ? { id: from.id, name: from.name, aadObjectId: from.aadObjectId } : undefined,
agent,
bot: agent ? { id: agent.id, name: agent.name } : undefined,
conversation: {
id: conversationId,
conversationType,
@@ -38,6 +38,33 @@ describe("sendMSTeamsActivityWithReference", () => {
vi.unstubAllEnvs();
});
it("sends a legacy bot-only imported reference instead of rejecting it", async () => {
vi.stubEnv("SERVICE_URL", "https://bot.example.com/api/messages");
const app = {
client: { request: vi.fn() },
api: { serviceUrl: "https://smba.trafficmanager.net/teams" },
} as unknown as MSTeamsApp;
const result = await sendMSTeamsActivityWithReference(
app,
{
serviceUrl: "https://smba.trafficmanager.net/amer/",
bot: { id: "28:legacy-bot", name: "OpenClaw" },
user: { id: "29:user" },
conversation: {
id: "19:conversation@thread.tacv2",
conversationType: "personal",
tenantId: "tenant-1",
},
channelId: "msteams",
},
{ type: "message", text: "hello" },
);
expect(result).toMatchObject({ id: "activity-1" });
expect(clientState.create).toHaveBeenCalledTimes(1);
});
it("sends through a reference-scoped API client without the protected SDK activitySender", async () => {
vi.stubEnv("SERVICE_URL", "https://bot.example.com/api/messages");
const httpClient = { request: vi.fn() };
+1
View File
@@ -18,6 +18,7 @@ type MSTeamsSdkReferenceSource = {
activityId?: string;
user?: MSTeamsAccountRef;
agent?: MSTeamsAccountRef | null;
/** Legacy imported rows may only carry `bot`; see StoredConversationReference.bot. */
bot?: MSTeamsAccountRef | null;
conversation: { id: string; conversationType?: string; tenantId?: string };
channelId?: string;
@@ -12,7 +12,6 @@ const sendContextMockState = vi.hoisted(() => {
list: vi.fn(),
remove: vi.fn(),
findPreferredDmByUserId: vi.fn(),
findByUserId: vi.fn(),
};
return {
store,
@@ -57,7 +56,6 @@ beforeEach(() => {
sendContextMockState.store.list.mockReset();
sendContextMockState.store.remove.mockReset();
sendContextMockState.store.findPreferredDmByUserId.mockReset();
sendContextMockState.store.findByUserId.mockReset();
sendContextMockState.loadMSTeamsSdkWithAuth.mockClear();
sendContextMockState.createMSTeamsTokenProvider.mockClear();
sendContextMockState.getAccessToken.mockReset();
-1
View File
@@ -4,7 +4,6 @@ export {
buildOllamaChatRequest,
createConfiguredOllamaCompatStreamWrapper,
convertToOllamaMessages,
createConfiguredOllamaCompatNumCtxWrapper,
createConfiguredOllamaStreamFn,
createOllamaStreamFn,
isOllamaCompatProvider,
-3
View File
@@ -488,9 +488,6 @@ export function createConfiguredOllamaCompatStreamWrapper(
return streamFn;
}
/** @deprecated Use createConfiguredOllamaCompatStreamWrapper. */
export const createConfiguredOllamaCompatNumCtxWrapper = createConfiguredOllamaCompatStreamWrapper;
export function buildOllamaChatRequest(params: {
modelId: string;
providerId?: string;
@@ -4,7 +4,6 @@ export type OllamaEmbeddingClient = unknown;
export const buildAssistantMessage: (...args: unknown[]) => unknown;
export const buildOllamaChatRequest: (...args: unknown[]) => unknown;
export const convertToOllamaMessages: (...args: unknown[]) => unknown;
export const createConfiguredOllamaCompatNumCtxWrapper: (...args: unknown[]) => unknown;
export const createConfiguredOllamaCompatStreamWrapper: (...args: unknown[]) => unknown;
export const createConfiguredOllamaStreamFn: (...args: unknown[]) => unknown;
export const createOllamaStreamFn: (...args: unknown[]) => unknown;
+1 -1
View File
@@ -1520,7 +1520,7 @@
"audit:seams": "node scripts/audit-seams.mjs",
"build": "node scripts/build-all.mjs",
"build:ci-artifacts": "node scripts/build-all.mjs ciArtifacts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts && node --import tsx scripts/write-cli-compat.ts",
"build:docker": "node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && pnpm plugins:assets:build && pnpm plugins:assets:copy && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/copy-export-html-templates.ts && node --import tsx scripts/write-build-info.ts && node --import tsx scripts/write-cli-startup-metadata.ts",
"build:plugin-sdk:dts": "node scripts/run-tsgo.mjs -p tsconfig.plugin-sdk.dts.json --declaration true",
"build:plugin-sdk:strict-smoke": "node scripts/tsdown-build.mjs && node scripts/runtime-postbuild.mjs && node scripts/run-with-env.mjs OPENCLAW_PLUGIN_SDK_CANONICAL_DTS=1 -- node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
"build:strict-smoke": "pnpm plugins:assets:build && node scripts/tsdown-build.mjs && node scripts/check-cli-bootstrap-imports.mjs && node scripts/runtime-postbuild.mjs && node scripts/build-stamp.mjs && node scripts/runtime-postbuild-stamp.mjs && node scripts/run-with-env.mjs OPENCLAW_PLUGIN_SDK_CANONICAL_DTS=1 -- node --import tsx scripts/write-plugin-sdk-entry-dts.ts && node scripts/check-plugin-sdk-exports.mjs",
+2 -7
View File
@@ -355,8 +355,6 @@ export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
origin?: string;
connectChallengeTimeoutMs?: number;
/** @deprecated Use connectChallengeTimeoutMs. */
connectDelayMs?: number;
/**
* Server-side pre-auth handshake budget. Config-derived local clients use
* this to keep the connect-challenge watchdog aligned with the gateway.
@@ -418,7 +416,7 @@ export function describeGatewayCloseCode(code: number): string | undefined {
}
function readConnectChallengeTimeoutOverride(
opts: Pick<GatewayClientOptions, "connectChallengeTimeoutMs" | "connectDelayMs">,
opts: Pick<GatewayClientOptions, "connectChallengeTimeoutMs">,
): number | undefined {
if (
typeof opts.connectChallengeTimeoutMs === "number" &&
@@ -426,9 +424,6 @@ function readConnectChallengeTimeoutOverride(
) {
return opts.connectChallengeTimeoutMs;
}
if (typeof opts.connectDelayMs === "number" && Number.isFinite(opts.connectDelayMs)) {
return opts.connectDelayMs;
}
return undefined;
}
@@ -450,7 +445,7 @@ function formatGatewayClientErrorForLog(err: unknown): string {
export function resolveGatewayClientConnectChallengeTimeoutMs(
opts: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
"connectChallengeTimeoutMs" | "env" | "preauthHandshakeTimeoutMs"
>,
): number {
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
@@ -152,19 +152,18 @@ describe("GatewayClient", () => {
client.stop();
});
test("prefers connectChallengeTimeoutMs and still honors the legacy alias", () => {
test("resolves connectChallengeTimeoutMs with clamping and config fallback", () => {
expect(resolveGatewayClientConnectChallengeTimeoutMs({})).toBe(
DEFAULT_PREAUTH_HANDSHAKE_TIMEOUT_MS,
);
expect(resolveGatewayClientConnectChallengeTimeoutMs({ connectDelayMs: 0 })).toBe(
expect(resolveGatewayClientConnectChallengeTimeoutMs({ connectChallengeTimeoutMs: 0 })).toBe(
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
);
expect(resolveGatewayClientConnectChallengeTimeoutMs({ connectDelayMs: 20_000 })).toBe(
MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
);
expect(
resolveGatewayClientConnectChallengeTimeoutMs({ connectChallengeTimeoutMs: 20_000 }),
).toBe(MAX_CONNECT_CHALLENGE_TIMEOUT_MS);
expect(
resolveGatewayClientConnectChallengeTimeoutMs({
connectDelayMs: 2_000,
connectChallengeTimeoutMs: 5_000,
}),
).toBe(5_000);
+2 -5
View File
@@ -21,7 +21,7 @@ export type GatewayClientStartReadinessOptions = {
timeoutMs?: number;
clientOptions?: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "env" | "preauthHandshakeTimeoutMs"
"connectChallengeTimeoutMs" | "env" | "preauthHandshakeTimeoutMs"
>;
signal?: AbortSignal;
};
@@ -37,10 +37,7 @@ function resolveGatewayClientStartReadinessTimeoutMs(
typeof clientOptions.connectChallengeTimeoutMs === "number" &&
Number.isFinite(clientOptions.connectChallengeTimeoutMs)
? clientOptions.connectChallengeTimeoutMs
: typeof clientOptions.connectDelayMs === "number" &&
Number.isFinite(clientOptions.connectDelayMs)
? clientOptions.connectDelayMs
: undefined;
: undefined;
return resolveConnectChallengeTimeoutMs(timeoutOverride, {
env: clientOptions.env,
configuredTimeoutMs: clientOptions.preauthHandshakeTimeoutMs,
+1 -6
View File
@@ -1,6 +1,6 @@
// Network Policy tests cover ipv4 behavior.
import { describe, expect, it } from "vitest";
import { validateDottedDecimalIPv4Input, validateIPv4AddressInput } from "./ipv4.js";
import { validateDottedDecimalIPv4Input } from "./ipv4.js";
describe("net-policy/ipv4", () => {
it("requires a value for custom bind mode", () => {
@@ -28,9 +28,4 @@ describe("net-policy/ipv4", () => {
"Invalid IPv4 address (e.g., 192.168.1.100)",
);
});
it("keeps the backward-compatible alias wired to the same validation", () => {
expect(validateIPv4AddressInput("192.168.1.100")).toBeUndefined();
expect(validateIPv4AddressInput("bad-ip")).toBe("Invalid IPv4 address (e.g., 192.168.1.100)");
});
});
-5
View File
@@ -11,8 +11,3 @@ export function validateDottedDecimalIPv4Input(value: string | undefined): strin
}
return "Invalid IPv4 address (e.g., 192.168.1.100)";
}
/** @deprecated Use validateDottedDecimalIPv4Input. */
export function validateIPv4AddressInput(value: string | undefined): string | undefined {
return validateDottedDecimalIPv4Input(value);
}
-1
View File
@@ -25,7 +25,6 @@ const RAW_EVENT_REPLAY_LIMIT = 1000;
export type GatewayClientTransportOptions = {
url?: string;
connectChallengeTimeoutMs?: number;
connectDelayMs?: number;
preauthHandshakeTimeoutMs?: number;
tickWatchMinIntervalMs?: number;
requestTimeoutMs?: number;
-8
View File
@@ -113,11 +113,6 @@ export const BUILD_ALL_STEPS = [
kind: "node",
args: ["--import", "tsx", "scripts/write-cli-startup-metadata.ts"],
},
{
label: "write-cli-compat",
kind: "node",
args: ["--import", "tsx", "scripts/write-cli-compat.ts"],
},
];
export const BUILD_ALL_PROFILES = {
@@ -137,7 +132,6 @@ export const BUILD_ALL_PROFILES = {
"ui:build",
"write-build-info",
"write-cli-startup-metadata",
"write-cli-compat",
],
gatewayWatch: [
"tsdown",
@@ -164,7 +158,6 @@ export const BUILD_ALL_PROFILES = {
"build-stamp",
"runtime-postbuild-stamp",
"write-cli-startup-metadata",
"write-cli-compat",
],
cliStartup: [
"tsdown",
@@ -173,7 +166,6 @@ export const BUILD_ALL_PROFILES = {
"build-stamp",
"runtime-postbuild-stamp",
"write-cli-startup-metadata",
"write-cli-compat",
],
};
@@ -18,7 +18,6 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/matrix/src/plugin-entry.runtime.js",
"src/agents/subagent-registry.runtime.ts",
"src/auto-reply/reply/get-reply.test-loader.ts",
"src/cli/daemon-cli-compat.ts",
"src/commands/doctor/shared/deprecation-compat.ts",
"src/config/doc-baseline.runtime.ts",
"src/config/doc-baseline.ts",
+2 -2
View File
@@ -195,12 +195,12 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
10542,
10540,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
5252,
5250,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
-194
View File
@@ -1,194 +0,0 @@
// Write Cli Compat script supports OpenClaw repository automation.
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
LEGACY_DAEMON_CLI_EXPORTS,
resolveLegacyDaemonCliAccessors,
resolveLegacyDaemonCliRegisterAccessor,
resolveLegacyDaemonCliRunnerAccessors,
} from "../src/cli/daemon-cli-compat.ts";
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(rootDir, "dist");
const cliDir = path.join(distDir, "cli");
const findCandidates = () =>
fs.readdirSync(distDir).filter((entry) => {
const isDaemonCliBundle =
entry === "daemon-cli.js" || entry === "daemon-cli.mjs" || entry.startsWith("daemon-cli-");
if (!isDaemonCliBundle) {
return false;
}
// tsdown can emit either .js or .mjs depending on bundler settings/runtime.
return entry.endsWith(".js") || entry.endsWith(".mjs");
});
const findRunnerCandidates = () =>
fs.readdirSync(distDir).filter((entry) => {
const isRunnerBundle =
entry === "runners.js" ||
entry === "runners.mjs" ||
entry.startsWith("runners-") ||
entry === "install.runtime.js" ||
entry === "install.runtime.mjs" ||
entry.startsWith("install.runtime-") ||
entry === "lifecycle.runtime.js" ||
entry === "lifecycle.runtime.mjs" ||
entry.startsWith("lifecycle.runtime-") ||
entry === "status.runtime.js" ||
entry === "status.runtime.mjs" ||
entry.startsWith("status.runtime-");
if (!isRunnerBundle) {
return false;
}
return entry.endsWith(".js") || entry.endsWith(".mjs");
});
// In rare cases, build output can land slightly after this script starts (depending on FS timing).
// Retry briefly to avoid flaky builds.
let candidates = findCandidates();
for (let i = 0; i < 10 && candidates.length === 0; i++) {
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
candidates = findCandidates();
}
let runnerCandidates = findRunnerCandidates();
for (let i = 0; i < 10 && runnerCandidates.length === 0; i++) {
await new Promise((resolve) => {
setTimeout(resolve, 50);
});
runnerCandidates = findRunnerCandidates();
}
if (candidates.length === 0) {
throw new Error("No daemon-cli bundle found in dist; cannot write legacy CLI shim.");
}
const orderedCandidates = candidates.toSorted();
const resolved = orderedCandidates
.map((entry) => {
const source = fs.readFileSync(path.join(distDir, entry), "utf8");
const accessors = resolveLegacyDaemonCliAccessors(source);
return { entry, accessors };
})
.find((entry) => Boolean(entry.accessors));
const orderedRunnerCandidates = runnerCandidates.toSorted();
let daemonTarget: string;
let accessors: Partial<Record<(typeof LEGACY_DAEMON_CLI_EXPORTS)[number], string>>;
let accessorSources: Partial<Record<(typeof LEGACY_DAEMON_CLI_EXPORTS)[number], string>>;
let extraRunnerTargets: Array<{ entry: string; binding: string }>;
if (resolved?.accessors) {
daemonTarget = resolved.entry;
extraRunnerTargets = [];
accessors = resolved.accessors;
accessorSources = Object.fromEntries(
Object.keys(resolved.accessors).map((key) => [key, "daemonCli"]),
) as typeof accessorSources;
} else {
const registerResolved = orderedCandidates
.map((entry) => {
const source = fs.readFileSync(path.join(distDir, entry), "utf8");
const accessor = resolveLegacyDaemonCliRegisterAccessor(source);
return { entry, accessor };
})
.find((entry) => Boolean(entry.accessor));
const runnerAccessors = new Map<
Exclude<(typeof LEGACY_DAEMON_CLI_EXPORTS)[number], "registerDaemonCli">,
{ accessor: string; entry: string }
>();
for (const entry of orderedRunnerCandidates) {
const source = fs.readFileSync(path.join(distDir, entry), "utf8");
const resolvedAccessors = resolveLegacyDaemonCliRunnerAccessors(source);
if (!resolvedAccessors) {
continue;
}
for (const [name, accessor] of Object.entries(resolvedAccessors)) {
if (
!accessor ||
runnerAccessors.has(
name as Exclude<(typeof LEGACY_DAEMON_CLI_EXPORTS)[number], "registerDaemonCli">,
)
) {
continue;
}
runnerAccessors.set(
name as Exclude<(typeof LEGACY_DAEMON_CLI_EXPORTS)[number], "registerDaemonCli">,
{ accessor, entry },
);
}
}
if (!registerResolved?.accessor || !runnerAccessors.get("runDaemonRestart")) {
throw new Error(
`Could not resolve daemon-cli export aliases from dist bundles: ${orderedCandidates.join(", ")} | runners: ${orderedRunnerCandidates.join(", ")}`,
);
}
daemonTarget = registerResolved.entry;
const runnerBindingByEntry = new Map<string, string>();
extraRunnerTargets = [];
for (const { entry } of runnerAccessors.values()) {
if (runnerBindingByEntry.has(entry)) {
continue;
}
const binding = `daemonCliRunners${runnerBindingByEntry.size}`;
runnerBindingByEntry.set(entry, binding);
extraRunnerTargets.push({ entry, binding });
}
accessors = {
registerDaemonCli: registerResolved.accessor,
...Object.fromEntries(
[...runnerAccessors.entries()].map(([name, value]) => [name, value.accessor]),
),
};
accessorSources = {
registerDaemonCli: "daemonCli",
runDaemonInstall: runnerAccessors.get("runDaemonInstall")
? runnerBindingByEntry.get(runnerAccessors.get("runDaemonInstall")!.entry)
: undefined,
runDaemonRestart: runnerBindingByEntry.get(runnerAccessors.get("runDaemonRestart")!.entry)!,
runDaemonStart: runnerAccessors.get("runDaemonStart")
? runnerBindingByEntry.get(runnerAccessors.get("runDaemonStart")!.entry)
: undefined,
runDaemonStatus: runnerAccessors.get("runDaemonStatus")
? runnerBindingByEntry.get(runnerAccessors.get("runDaemonStatus")!.entry)
: undefined,
runDaemonStop: runnerAccessors.get("runDaemonStop")
? runnerBindingByEntry.get(runnerAccessors.get("runDaemonStop")!.entry)
: undefined,
runDaemonUninstall: runnerAccessors.get("runDaemonUninstall")
? runnerBindingByEntry.get(runnerAccessors.get("runDaemonUninstall")!.entry)
: undefined,
};
}
const missingExportError = (name: string) =>
`Legacy daemon CLI export "${name}" is unavailable in this build. Please upgrade OpenClaw.`;
const buildExportLine = (name: (typeof LEGACY_DAEMON_CLI_EXPORTS)[number]) => {
const accessor = accessors[name];
if (accessor) {
const sourceBinding = accessorSources[name] ?? "daemonCli";
return `export const ${name} = ${sourceBinding}.${accessor};`;
}
if (name === "registerDaemonCli") {
return `export const ${name} = () => { throw new Error(${JSON.stringify(missingExportError(name))}); };`;
}
return `export const ${name} = async () => { throw new Error(${JSON.stringify(missingExportError(name))}); };`;
};
const contents =
"// Legacy shim for pre-tsdown update-cli imports.\n" +
`import * as daemonCli from "../${daemonTarget}";\n` +
extraRunnerTargets
.map(({ entry, binding }) => `import * as ${binding} from "../${entry}";`)
.join("\n") +
(extraRunnerTargets.length > 0 ? "\n" : "") +
LEGACY_DAEMON_CLI_EXPORTS.map(buildExportLine).join("\n") +
"\n";
fs.mkdirSync(cliDir, { recursive: true });
fs.writeFileSync(path.join(cliDir, "daemon-cli.js"), contents);
-86
View File
@@ -1,86 +0,0 @@
// Daemon CLI compatibility tests cover legacy daemon command aliases and output.
import { describe, expect, it } from "vitest";
import {
resolveLegacyDaemonCliAccessors,
resolveLegacyDaemonCliRegisterAccessor,
resolveLegacyDaemonCliRunnerAccessors,
} from "./daemon-cli-compat.js";
describe("resolveLegacyDaemonCliAccessors", () => {
it("resolves aliased daemon-cli exports from a bundled chunk", () => {
const bundle = `
var daemon_cli_exports = /* @__PURE__ */ __exportAll({ registerDaemonCli: () => registerDaemonCli });
export { runDaemonStop as a, runDaemonStart as i, runDaemonStatus as n, runDaemonUninstall as o, runDaemonRestart as r, runDaemonInstall as s, daemon_cli_exports as t };
`;
expect(resolveLegacyDaemonCliAccessors(bundle)).toEqual({
registerDaemonCli: "t.registerDaemonCli",
runDaemonInstall: "s",
runDaemonRestart: "r",
runDaemonStart: "i",
runDaemonStatus: "n",
runDaemonStop: "a",
runDaemonUninstall: "o",
});
});
it("returns null when required aliases are missing", () => {
const bundle = `
var daemon_cli_exports = /* @__PURE__ */ __exportAll({ registerDaemonCli: () => registerDaemonCli });
export { runDaemonRestart as r, daemon_cli_exports as t };
`;
expect(resolveLegacyDaemonCliAccessors(bundle)).toEqual({
registerDaemonCli: "t.registerDaemonCli",
runDaemonRestart: "r",
});
});
it("returns null when the required restart alias is missing", () => {
const bundle = `
var daemon_cli_exports = /* @__PURE__ */ __exportAll({ registerDaemonCli: () => registerDaemonCli });
export { daemon_cli_exports as t };
`;
expect(resolveLegacyDaemonCliAccessors(bundle)).toBeNull();
});
it("resolves split register and runner bundles", () => {
const daemonBundle = `
var daemon_cli_exports = /* @__PURE__ */ __exportAll({ registerDaemonCli: () => registerDaemonCli });
export { addGatewayServiceCommands as n, daemon_cli_exports as t };
`;
const runnerBundle = `
export { runDaemonInstall as a, runDaemonUninstall as i, runDaemonStart as n, runDaemonStop as r, runDaemonRestart as t, runDaemonStatus as u };
`;
expect(resolveLegacyDaemonCliRegisterAccessor(daemonBundle)).toBe("t.registerDaemonCli");
expect(resolveLegacyDaemonCliRunnerAccessors(runnerBundle)).toEqual({
runDaemonInstall: "a",
runDaemonRestart: "t",
runDaemonStart: "n",
runDaemonStatus: "u",
runDaemonStop: "r",
runDaemonUninstall: "i",
});
});
it("resolves partial runner bundles for split runtime chunks", () => {
const installRuntimeBundle = `
export { runDaemonInstall };
`;
const lifecycleRuntimeBundle = `
export { runDaemonRestart as t, runDaemonStart as n, runDaemonStop as r, runDaemonUninstall as i };
`;
expect(resolveLegacyDaemonCliRunnerAccessors(installRuntimeBundle)).toEqual({
runDaemonInstall: "runDaemonInstall",
});
expect(resolveLegacyDaemonCliRunnerAccessors(lifecycleRuntimeBundle)).toEqual({
runDaemonRestart: "t",
runDaemonStart: "n",
runDaemonStop: "r",
runDaemonUninstall: "i",
});
});
});
-140
View File
@@ -1,140 +0,0 @@
// Compatibility parser for older bundled daemon CLI exports inside generated bundles.
export const LEGACY_DAEMON_CLI_EXPORTS = [
"registerDaemonCli",
"runDaemonInstall",
"runDaemonRestart",
"runDaemonStart",
"runDaemonStatus",
"runDaemonStop",
"runDaemonUninstall",
] as const;
type LegacyDaemonCliExport = (typeof LEGACY_DAEMON_CLI_EXPORTS)[number];
type LegacyDaemonCliRunnerExport = Exclude<LegacyDaemonCliExport, "registerDaemonCli">;
/** Accessor names for legacy daemon bundle exports after minifier/export alias resolution. */
export type LegacyDaemonCliAccessors = {
registerDaemonCli: string;
runDaemonRestart: string;
} & Partial<
Record<Exclude<LegacyDaemonCliExport, "registerDaemonCli" | "runDaemonRestart">, string>
>;
const EXPORT_SPEC_RE = /^([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?$/;
const REGISTER_CONTAINER_RE =
/(?:var|const|let)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\/\*[\s\S]*?\*\/\s*)?__exportAll\(\{\s*registerDaemonCli\s*:\s*\(\)\s*=>\s*registerDaemonCli\s*\}\)/;
function parseExportAliases(bundleSource: string): Map<string, string> | null {
const matches = [...bundleSource.matchAll(/export\s*\{([^}]+)\}\s*;?/g)];
if (matches.length === 0) {
return null;
}
const last = matches.at(-1);
const body = last?.[1];
if (!body) {
return null;
}
const aliases = new Map<string, string>();
for (const chunk of body.split(",")) {
const spec = chunk.trim();
if (!spec) {
continue;
}
const parsed = spec.match(EXPORT_SPEC_RE);
if (!parsed) {
return null;
}
const original = parsed[1];
const alias = parsed[2] ?? original;
aliases.set(original, alias);
}
return aliases;
}
function findRegisterContainerSymbol(bundleSource: string): string | null {
return bundleSource.match(REGISTER_CONTAINER_RE)?.[1] ?? null;
}
/** Find the accessor for the old `registerDaemonCli` export shape, including esbuild containers. */
export function resolveLegacyDaemonCliRegisterAccessor(bundleSource: string): string | null {
const aliases = parseExportAliases(bundleSource);
if (!aliases) {
return null;
}
const registerContainer = findRegisterContainerSymbol(bundleSource);
const registerContainerAlias = registerContainer ? aliases.get(registerContainer) : undefined;
const registerDirectAlias = aliases.get("registerDaemonCli");
return registerContainerAlias
? `${registerContainerAlias}.registerDaemonCli`
: (registerDirectAlias ?? null);
}
/** Find legacy daemon runner exports in generated bundle source. */
export function resolveLegacyDaemonCliRunnerAccessors(
bundleSource: string,
): Partial<Record<LegacyDaemonCliRunnerExport, string>> | null {
const aliases = parseExportAliases(bundleSource);
if (!aliases) {
return null;
}
const runDaemonInstall = aliases.get("runDaemonInstall");
const runDaemonRestart = aliases.get("runDaemonRestart");
const runDaemonStart = aliases.get("runDaemonStart");
const runDaemonStatus = aliases.get("runDaemonStatus");
const runDaemonStop = aliases.get("runDaemonStop");
const runDaemonUninstall = aliases.get("runDaemonUninstall");
if (
!runDaemonInstall &&
!runDaemonRestart &&
!runDaemonStart &&
!runDaemonStatus &&
!runDaemonStop &&
!runDaemonUninstall
) {
return null;
}
return {
...(runDaemonInstall ? { runDaemonInstall } : {}),
...(runDaemonRestart ? { runDaemonRestart } : {}),
...(runDaemonStart ? { runDaemonStart } : {}),
...(runDaemonStatus ? { runDaemonStatus } : {}),
...(runDaemonStop ? { runDaemonStop } : {}),
...(runDaemonUninstall ? { runDaemonUninstall } : {}),
};
}
/** Resolve all legacy daemon accessors required to bridge old bundles into current CLI code. */
export function resolveLegacyDaemonCliAccessors(
bundleSource: string,
): LegacyDaemonCliAccessors | null {
const registerDaemonCli = resolveLegacyDaemonCliRegisterAccessor(bundleSource);
const runnerAccessors = resolveLegacyDaemonCliRunnerAccessors(bundleSource);
if (!registerDaemonCli || !runnerAccessors?.runDaemonRestart) {
return null;
}
const accessors: LegacyDaemonCliAccessors = {
registerDaemonCli,
runDaemonRestart: runnerAccessors.runDaemonRestart,
};
if (runnerAccessors.runDaemonInstall) {
accessors.runDaemonInstall = runnerAccessors.runDaemonInstall;
}
if (runnerAccessors.runDaemonStart) {
accessors.runDaemonStart = runnerAccessors.runDaemonStart;
}
if (runnerAccessors.runDaemonStatus) {
accessors.runDaemonStatus = runnerAccessors.runDaemonStatus;
}
if (runnerAccessors.runDaemonStop) {
accessors.runDaemonStop = runnerAccessors.runDaemonStop;
}
if (runnerAccessors.runDaemonUninstall) {
accessors.runDaemonUninstall = runnerAccessors.runDaemonUninstall;
}
return accessors;
}
+2 -2
View File
@@ -1,5 +1,5 @@
// Configure wizard Gateway port, bind, auth, and Tailscale prompts.
import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4";
import { validateDottedDecimalIPv4Input } from "@openclaw/net-policy/ipv4";
import {
normalizeOptionalString,
readStringValue,
@@ -98,7 +98,7 @@ export async function promptGatewayConfig(
await text({
message: "Custom IP address",
placeholder: "192.168.1.100",
validate: validateIPv4AddressInput,
validate: validateDottedDecimalIPv4Input,
}),
runtime,
);
-33
View File
@@ -40,7 +40,6 @@ vi.mock("./exec-approval-surface.js", () => ({
import {
buildExecApprovalActionDescriptors,
buildExecApprovalCommandText,
buildExecApprovalInteractiveReply,
buildExecApprovalPendingReplyPayload,
buildExecApprovalUnavailableReplyPayload,
getExecApprovalApproverDmNoticeText,
@@ -471,38 +470,6 @@ describe("exec approval reply helpers", () => {
command: "/approve req-1 deny",
},
]);
expect(
buildExecApprovalInteractiveReply({
approvalCommandId: "req-1",
}),
).toEqual({
blocks: [
{
type: "buttons",
buttons: [
{
label: "Allow Once",
action: { type: "command", command: "/approve req-1 allow-once" },
value: "/approve req-1 allow-once",
style: "success",
},
{
label: "Allow Always",
action: { type: "command", command: "/approve req-1 allow-always" },
value: "/approve req-1 allow-always",
style: "primary",
},
{
label: "Deny",
action: { type: "command", command: "/approve req-1 deny" },
value: "/approve req-1 deny",
style: "danger",
},
],
},
],
});
});
it("builds and parses shared exec approval command text", () => {
-32
View File
@@ -242,38 +242,6 @@ export function buildApprovalInteractiveReplyFromActionDescriptors(
return buttons.length > 0 ? { blocks: [{ type: "buttons", buttons }] } : undefined;
}
/**
* @deprecated Use buildApprovalPresentation.
*/
export function buildApprovalInteractiveReply(params: {
approvalId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): InteractiveReply | undefined {
return buildApprovalInteractiveReplyFromActionDescriptors(
buildExecApprovalActionDescriptors({
approvalCommandId: params.approvalId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
}),
);
}
/**
* @deprecated Use buildExecApprovalPresentation.
*/
export function buildExecApprovalInteractiveReply(params: {
approvalCommandId: string;
ask?: string | null;
allowedDecisions?: readonly ExecApprovalReplyDecision[];
}): InteractiveReply | undefined {
return buildApprovalInteractiveReply({
approvalId: params.approvalCommandId,
ask: params.ask,
allowedDecisions: params.allowedDecisions,
});
}
export function getExecApprovalApproverDmNoticeText(): string {
return "Approval required. I sent approval DMs to the approvers for this account.";
}
@@ -147,7 +147,7 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'export { requiresExplicitMatrixDefaultAccount, resolveMatrixDefaultOrOnlyAccountId } from "./src/account-selection.js";',
'export { findMatrixAccountEntry, resolveConfiguredMatrixAccountIds, resolveMatrixChannelConfig } from "./src/account-selection.js";',
'export { getMatrixScopedEnvVarNames, listMatrixEnvAccountIds, resolveMatrixEnvAccountToken } from "./src/env-vars.js";',
'export { hashMatrixAccessToken, resolveMatrixAccountStorageRoot, resolveMatrixCredentialsDir, resolveMatrixCredentialsFilename, resolveMatrixCredentialsPath, resolveMatrixHomeserverKey, resolveMatrixLegacyFlatStoragePaths, resolveMatrixLegacyFlatStoreRoot, sanitizeMatrixPathSegment } from "./src/storage-paths.js";',
'export { hashMatrixAccessToken, resolveMatrixAccountStorageRoot, resolveMatrixCredentialsDir, resolveMatrixCredentialsFilename, resolveMatrixCredentialsPath, resolveMatrixHomeserverKey, sanitizeMatrixPathSegment } from "./src/storage-paths.js";',
'export { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./src/matrix/deps.js";',
'export { assertHttpUrlTargetsPrivateNetwork, closeDispatcher, createPinnedDispatcher, resolvePinnedHostnameWithPolicy, ssrfPolicyFromDangerouslyAllowPrivateNetwork, ssrfPolicyFromAllowPrivateNetwork, type LookupFn, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";',
'export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey } from "./src/matrix/thread-bindings-shared.js";',
+2 -2
View File
@@ -1,5 +1,5 @@
// Setup gateway config helpers build gateway config from onboarding answers.
import { validateIPv4AddressInput } from "@openclaw/net-policy/ipv4";
import { validateDottedDecimalIPv4Input } from "@openclaw/net-policy/ipv4";
import { formatPortRangeHint } from "../cli/error-format.js";
import { parsePort } from "../cli/shared/parse-port.js";
import {
@@ -131,7 +131,7 @@ export async function configureGatewayForSetup(
message: t("wizard.gateway.bindCustomIp"),
placeholder: "192.168.1.100",
initialValue: customBindHost ?? "",
validate: validateIPv4AddressInput,
validate: validateDottedDecimalIPv4Input,
});
customBindHost = typeof input === "string" ? input.trim() : undefined;
}
+1 -1
View File
@@ -120,7 +120,7 @@ describe("package scripts", () => {
});
it.each([
{ scriptName: "build:docker", expectedCount: 5 },
{ scriptName: "build:docker", expectedCount: 4 },
{ scriptName: "build:plugin-sdk:strict-smoke", expectedCount: 1 },
{ scriptName: "build:strict-smoke", expectedCount: 1 },
])("runs TypeScript steps in $scriptName through tsx", ({ scriptName, expectedCount }) => {
-8
View File
@@ -216,11 +216,6 @@ describe("resolveBuildAllStep", () => {
scriptPath: "scripts/write-cli-startup-metadata.ts",
expectedEnv: { FOO: "bar" },
},
{
label: "write-cli-compat",
scriptPath: "scripts/write-cli-compat.ts",
expectedEnv: { FOO: "bar" },
},
])("runs the $label TypeScript step through tsx", ({ label, scriptPath, expectedEnv }) => {
const step = getBuildAllStep(label);
@@ -328,7 +323,6 @@ describe("resolveBuildAllSteps", () => {
"ui:build",
"write-build-info",
"write-cli-startup-metadata",
"write-cli-compat",
]);
});
@@ -420,7 +414,6 @@ describe("resolveBuildAllSteps", () => {
"build-stamp",
"runtime-postbuild-stamp",
"write-cli-startup-metadata",
"write-cli-compat",
]);
});
@@ -432,7 +425,6 @@ describe("resolveBuildAllSteps", () => {
"build-stamp",
"runtime-postbuild-stamp",
"write-cli-startup-metadata",
"write-cli-compat",
]);
});
+18 -82
View File
@@ -3,7 +3,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createImportedCustomThemeFixture } from "../test-helpers/custom-theme.ts";
import { createStorageMock } from "../test-helpers/storage.ts";
import {
loadGatewaySessionSelection,
loadLocalUserIdentity,
loadSettings,
resolveApplicationStartupSettings,
@@ -180,39 +179,34 @@ describe("loadSettings default gateway URL derivation", () => {
pathname: "/",
});
sessionStorage.setItem("openclaw.control.token.v1", "legacy-session-token");
const gatewayUrl = "wss://gateway.example:8443/openclaw";
const scopedKey = `openclaw.control.settings.v1:${gatewayUrl}`;
localStorage.setItem(
"openclaw.control.settings.v1",
scopedKey,
JSON.stringify({
gatewayUrl: "wss://gateway.example:8443/openclaw",
gatewayUrl,
token: "persisted-token",
sessionKey: "agent",
}),
);
localStorage.setItem(
"openclaw.control.currentGateway.v1:wss://gateway.example:8443",
gatewayUrl,
);
const settings = loadSettings();
expect(settings.gatewayUrl).toBe("wss://gateway.example:8443/openclaw");
expect(settings.gatewayUrl).toBe(gatewayUrl);
expect(settings.token).toBe("");
expect(settings.sessionKey).toBe("agent");
const scopedKey = "openclaw.control.settings.v1:wss://gateway.example:8443/openclaw";
expect(JSON.parse(localStorage.getItem(scopedKey) ?? "{}")).toEqual({
gatewayUrl: "wss://gateway.example:8443/openclaw",
theme: "claw",
themeMode: "system",
chatShowThinking: true,
chatShowToolCalls: true,
chatPersistCommentary: false,
chatAutoScroll: "near-bottom",
splitRatio: 0.6,
navCollapsed: false,
navWidth: 258,
sidebarPinnedRoutes: ["overview"],
sidebarMoreExpanded: false,
textScale: 100,
sessionsByGateway: {
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent",
lastActiveSessionKey: "agent",
},
const rewritten = JSON.parse(localStorage.getItem(scopedKey) ?? "{}") as Record<
string,
unknown
>;
expect(rewritten.token).toBeUndefined();
expect(rewritten.sessionsByGateway).toEqual({
"wss://gateway.example:8443/openclaw": {
sessionKey: "agent",
lastActiveSessionKey: "agent",
},
});
expect(sessionStorage.length).toBe(0);
@@ -780,64 +774,6 @@ describe("loadSettings default gateway URL derivation", () => {
expect(localStorage.getItem("openclaw.control.settings.v1")).toBeNull();
});
it("leaves an ambiguous same-origin legacy sibling for its owning page", () => {
setTestLocation({ protocol: "https:", host: "multi.example:8443", pathname: "/gateway-b/" });
setControlUiBasePath("/gateway-b");
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl: "wss://multi.example:8443/gateway-a",
sessionKey: "agent:a:main",
}),
);
expect(loadSettings().gatewayUrl).toBe(expectedGatewayUrl("/gateway-b"));
expect(loadGatewaySessionSelection(expectedGatewayUrl("/gateway-b"))).toEqual({
sessionKey: "main",
lastActiveSessionKey: "main",
});
expect(localStorage.getItem("openclaw.control.settings.v1")).not.toBeNull();
});
it("migrates a legacy record that matches the current page", () => {
setTestLocation({ protocol: "https:", host: "multi.example:8443", pathname: "/gateway-b/" });
setControlUiBasePath("/gateway-b");
const gatewayUrl = expectedGatewayUrl("/gateway-b");
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({
gatewayUrl,
sessionKey: "agent:b:main",
lastActiveSessionKey: "agent:b:main",
}),
);
expect(loadSettings()).toMatchObject({ gatewayUrl, sessionKey: "agent:b:main" });
expect(
localStorage.getItem("openclaw.control.currentGateway.v1:wss://multi.example:8443/gateway-b"),
).toBe(gatewayUrl);
expect(localStorage.getItem(`openclaw.control.settings.v1:${gatewayUrl}`)).not.toBeNull();
expect(localStorage.getItem("openclaw.control.settings.v1")).toBeNull();
});
it("migrates a legacy custom gateway on a different host", () => {
setTestLocation({ protocol: "https:", host: "control.example:8443", pathname: "/openclaw/" });
setControlUiBasePath("/openclaw");
const remoteUrl = "wss://remote-gateway.example.com";
localStorage.setItem(
"openclaw.control.settings.v1",
JSON.stringify({ gatewayUrl: remoteUrl, sessionKey: "remote-session" }),
);
expect(loadSettings()).toMatchObject({ gatewayUrl: remoteUrl, sessionKey: "remote-session" });
expect(
localStorage.getItem(
"openclaw.control.currentGateway.v1:wss://control.example:8443/openclaw",
),
).toBe(remoteUrl);
expect(localStorage.getItem("openclaw.control.settings.v1")).toBeNull();
});
it("keeps custom gateway selections isolated per Control UI base path", () => {
setTestLocation({ protocol: "https:", host: "multi.example:8443", pathname: "/gateway-a/" });
setControlUiBasePath("/gateway-a");
+8 -60
View File
@@ -348,7 +348,6 @@ function getSessionStorage(): Storage | null {
type PersistedSettingsSource = {
gatewayUrl: string;
legacy: boolean;
parsed: PersistedUiSettings;
};
@@ -363,42 +362,17 @@ function parsePersistedSettings(raw: string | null): PersistedUiSettings | null
}
}
function settingsMatchGatewayTarget(
parsed: PersistedUiSettings,
targetUrl: string,
aliases: readonly string[] = [],
): boolean {
function settingsMatchGatewayTarget(parsed: PersistedUiSettings, targetUrl: string): boolean {
const storedUrl = normalizeOptionalString(parsed.gatewayUrl);
if (!storedUrl) {
return false;
}
const storedScope = normalizeGatewayTokenScope(storedUrl);
return [targetUrl, ...aliases].some(
(candidate) => normalizeGatewayTokenScope(candidate) === storedScope,
);
}
function isClaimableLegacyGateway(storedUrl: string | undefined, pageUrl: string): boolean {
if (!storedUrl) {
return false;
}
try {
const stored = new URL(normalizeGatewayTokenScope(storedUrl));
const page = new URL(normalizeGatewayTokenScope(pageUrl));
return stored.host !== page.host || page.pathname === "/";
} catch {
return false;
}
return normalizeGatewayTokenScope(storedUrl) === normalizeGatewayTokenScope(targetUrl);
}
function readSettingsForGateway(
storage: Storage | null,
targetUrl: string,
options: {
includeLegacy?: boolean;
legacyAliases?: readonly string[];
remoteLegacyPageUrl?: string;
} = {},
): PersistedSettingsSource | null {
const scoped = parsePersistedSettings(storage?.getItem(settingsKeyForGateway(targetUrl)) ?? null);
if (
@@ -407,31 +381,9 @@ function readSettingsForGateway(
) {
return {
gatewayUrl: normalizeOptionalString(scoped.gatewayUrl) ?? targetUrl,
legacy: false,
parsed: scoped,
};
}
if (!options.includeLegacy) {
return null;
}
for (const key of [`${SETTINGS_KEY_PREFIX}default`, LEGACY_SETTINGS_KEY]) {
const parsed = parsePersistedSettings(storage?.getItem(key) ?? null);
if (!parsed) {
continue;
}
const storedUrl = normalizeOptionalString(parsed.gatewayUrl);
const matchesTarget = settingsMatchGatewayTarget(parsed, targetUrl, options.legacyAliases);
const isRemote = options.remoteLegacyPageUrl
? isClaimableLegacyGateway(storedUrl, options.remoteLegacyPageUrl)
: false;
if (matchesTarget || isRemote) {
return {
gatewayUrl: storedUrl ?? targetUrl,
legacy: true,
parsed,
};
}
}
return null;
}
@@ -471,7 +423,7 @@ export function loadGatewaySessionSelection(gatewayUrl: string): ScopedSessionSe
const fallback = { sessionKey: "main", lastActiveSessionKey: "main" };
try {
const storage = getSafeLocalStorage();
const source = readSettingsForGateway(storage, gatewayUrl, { includeLegacy: true });
const source = readSettingsForGateway(storage, gatewayUrl);
return source ? resolveScopedSessionSelection(gatewayUrl, source.parsed, fallback) : fallback;
} catch {
return fallback;
@@ -557,13 +509,7 @@ export function loadSettings(): UiSettings {
const selected = selectedGatewayUrl
? readSettingsForGateway(storage, selectedGatewayUrl)
: null;
// Legacy state has no page owner. Only the current page target or a deliberate
// cross-origin remote can claim it; ambiguous same-origin sibling paths cannot.
const defaultSource = readSettingsForGateway(storage, defaultUrl, {
includeLegacy: true,
legacyAliases: [pageDerivedUrl],
remoteLegacyPageUrl: pageDerivedUrl,
});
const defaultSource = readSettingsForGateway(storage, defaultUrl);
const source = selected ?? defaultSource;
if (!source) {
return defaults;
@@ -628,7 +574,9 @@ export function loadSettings(): UiSettings {
...(parsed.lobsterPetVisits === false ? { lobsterPetVisits: false } : {}),
...(parsed.lobsterPetSounds === true ? { lobsterPetSounds: true } : {}),
};
if (source.legacy || "token" in parsed) {
// Scoped blobs from builds that persisted tokens durably get rewritten once
// so the plaintext token leaves localStorage.
if ("token" in parsed) {
persistSettings(settings, { selectGateway: true });
}
return settings;
@@ -667,7 +615,7 @@ function persistSettings(next: UiSettings, options: { selectGateway?: boolean }
const scopedKey = settingsKeyForGateway(next.gatewayUrl);
let existingSessionsByGateway: Record<string, ScopedSessionSelection> = {};
try {
const source = readSettingsForGateway(storage, next.gatewayUrl, { includeLegacy: true });
const source = readSettingsForGateway(storage, next.gatewayUrl);
if (source) {
const parsed = source.parsed;
if (parsed.sessionsByGateway && typeof parsed.sessionsByGateway === "object") {
+4 -4
View File
@@ -16,14 +16,14 @@ describe("resolveTheme", () => {
});
describe("parseThemeSelection", () => {
it("maps legacy stored values onto theme + mode", () => {
expect(parseThemeSelection("system", undefined)).toEqual({
it("falls back to defaults for unknown stored values", () => {
expect(parseThemeSelection("fieldmanual", "invalid-mode")).toEqual({
theme: "claw",
mode: "system",
});
expect(parseThemeSelection("fieldmanual", undefined)).toEqual({
expect(parseThemeSelection("dash", "light")).toEqual({
theme: "dash",
mode: "dark",
mode: "light",
});
});
});
+2 -22
View File
@@ -14,22 +14,6 @@ export type ResolvedTheme =
const VALID_THEME_NAMES = new Set<ThemeName>(["claw", "knot", "dash", "custom"]);
const VALID_THEME_MODES = new Set<ThemeMode>(["system", "light", "dark"]);
type ThemeSelection = { theme: ThemeName; mode: ThemeMode };
const LEGACY_MAP: Record<string, ThemeSelection> = {
defaultTheme: { theme: "claw", mode: "dark" },
docsTheme: { theme: "claw", mode: "light" },
lightTheme: { theme: "knot", mode: "dark" },
landingTheme: { theme: "knot", mode: "dark" },
newTheme: { theme: "knot", mode: "dark" },
dark: { theme: "claw", mode: "dark" },
light: { theme: "claw", mode: "light" },
openknot: { theme: "knot", mode: "dark" },
fieldmanual: { theme: "dash", mode: "dark" },
clawdash: { theme: "dash", mode: "light" },
system: { theme: "claw", mode: "system" },
};
function prefersLightScheme(): boolean {
if (typeof globalThis.matchMedia !== "function") {
return false;
@@ -44,12 +28,8 @@ export function parseThemeSelection(
const theme = typeof themeRaw === "string" ? themeRaw : "";
const mode = typeof modeRaw === "string" ? modeRaw : "";
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName)
? (theme as ThemeName)
: (LEGACY_MAP[theme]?.theme ?? "claw");
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode)
? (mode as ThemeMode)
: (LEGACY_MAP[theme]?.mode ?? "system");
const normalizedTheme = VALID_THEME_NAMES.has(theme as ThemeName) ? (theme as ThemeName) : "claw";
const normalizedMode = VALID_THEME_MODES.has(mode as ThemeMode) ? (mode as ThemeMode) : "system";
return { theme: normalizedTheme, mode: normalizedMode };
}
+2 -15
View File
@@ -3,7 +3,7 @@ import { stripInternalRuntimeContext } from "../../../../src/agents/internal-run
import { stripInboundMetadata } from "../../../../src/auto-reply/reply/strip-inbound-meta.js";
import { stripEnvelope } from "../../../../src/shared/chat-envelope.js";
import { extractAssistantVisibleText as extractSharedAssistantVisibleText } from "../../../../src/shared/chat-message-content.js";
import { normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "../string-coerce.ts";
import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
import { stripThinkingTags } from "../strip-thinking-tags.ts";
const textCache = new WeakMap<object, string | null>();
@@ -67,20 +67,7 @@ export function extractThinking(message: unknown): string | null {
}
}
}
if (parts.length > 0) {
return parts.join("\n");
}
// Back-compat: older logs may still have <think> tags inside text blocks.
const rawText = extractRawText(message);
if (!rawText) {
return null;
}
const matches = [
...rawText.matchAll(/<\s*think(?:ing)?\s*>([\s\S]*?)<\s*\/\s*think(?:ing)?\s*>/gi),
];
const extracted = normalizeStringEntries(matches.map((mLocal) => mLocal[1] ?? ""));
return extracted.length > 0 ? extracted.join("\n") : null;
return parts.length > 0 ? parts.join("\n") : null;
}
export function extractThinkingCached(message: unknown): string | null {
+4 -3
View File
@@ -13,6 +13,8 @@ const CHANNEL_LABELS: Record<string, string> = {
sms: "SMS",
};
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Human channel label for group headers and name fallbacks. */
export function channelDisplayLabel(channel: string): string {
const normalized = normalizeLowercaseStringOrEmpty(channel);
@@ -82,8 +84,6 @@ export function resolveSessionWorkSubtitle(row: SessionWorktreeDisplayRow): stri
return checkout ?? node;
}
const KNOWN_CHANNEL_KEYS = Object.keys(CHANNEL_LABELS);
/** Parsed type / context extracted from a session key. */
type SessionKeyInfo = {
/** Prefix for typed sessions (Subagent:/Cron:). Empty for others. */
@@ -141,7 +141,8 @@ function parseSessionKey(key: string): SessionKeyInfo {
return { prefix: "", fallbackName: `${channelLabel} Group` };
}
// Channel-prefixed legacy keys, for example "imessage:g-...".
// Channel-prefixed keys like "telegram:123": durable session rows written by
// pre-agent-scoped builds still surface in session lists; label, don't leak keys.
for (const ch of KNOWN_CHANNEL_KEYS) {
if (key === ch || key.startsWith(`${ch}:`)) {
return { prefix: "", fallbackName: `${CHANNEL_LABELS[ch]} Session` };