mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -21,8 +21,6 @@ export {
|
||||
resolveMatrixCredentialsFilename,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixHomeserverKey,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
resolveMatrixLegacyFlatStoreRoot,
|
||||
sanitizeMatrixPathSegment,
|
||||
} from "./src/storage-paths.js";
|
||||
export {
|
||||
|
||||
@@ -25,8 +25,6 @@ export {
|
||||
resolveMatrixCredentialsFilename,
|
||||
resolveMatrixCredentialsPath,
|
||||
resolveMatrixHomeserverKey,
|
||||
resolveMatrixLegacyFlatStoragePaths,
|
||||
resolveMatrixLegacyFlatStoreRoot,
|
||||
sanitizeMatrixPathSegment,
|
||||
} from "./src/storage-paths.js";
|
||||
export { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./src/matrix/deps.js";
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// Matrix API module exposes the plugin public contract.
|
||||
export * from "./src/matrix-migration.runtime.js";
|
||||
@@ -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 (
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,7 +4,6 @@ export {
|
||||
buildOllamaChatRequest,
|
||||
createConfiguredOllamaCompatStreamWrapper,
|
||||
convertToOllamaMessages,
|
||||
createConfiguredOllamaCompatNumCtxWrapper,
|
||||
createConfiguredOllamaStreamFn,
|
||||
createOllamaStreamFn,
|
||||
isOllamaCompatProvider,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 +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)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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";',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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") {
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` };
|
||||
|
||||
Reference in New Issue
Block a user