mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
test(matrix): cover destructive E2EE backup recovery flows (#71311)
Merged via squash.
Prepared head SHA: fd5fc06007
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
committed by
GitHub
parent
cbe5515b70
commit
d5166718bc
@@ -543,6 +543,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon.
|
||||
- WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital.
|
||||
- Plugins/CLI: expose channel plugin CLI descriptors during discovery-mode plugin loads so snapshot registries keep channel commands visible without activating full runtimes. (#71309) Thanks @gumadeiras.
|
||||
- Matrix: separate recovery-key, backup, and owner-trust diagnostics during E2EE recovery, add recovery-key rotation for backup reset, and cover destructive backup restore paths in QA. (#71311) Thanks @gumadeiras.
|
||||
- WhatsApp: deliver media generated by tool-result replies while still suppressing text-only tool chatter. (#60968) Thanks @adaclaw.
|
||||
- Config/agents: accept `agents.list[].contextTokens` in strict config validation so per-agent overrides survive hot reload, letting `/status` reflect the configured model window instead of the 200k fallback. Fixes #70692. (#71247) Thanks @statxc.
|
||||
- Heartbeat: include async exec completion details in heartbeat prompts so command-finished notifications relay the actual output. (#71213) Thanks @GodsBoy.
|
||||
|
||||
@@ -398,6 +398,12 @@ Restore room keys from server backup:
|
||||
openclaw matrix verify backup restore
|
||||
```
|
||||
|
||||
If the backup key is not already loaded on disk, pass the Matrix recovery key:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"
|
||||
```
|
||||
|
||||
Interactive self-verification flow:
|
||||
|
||||
```bash
|
||||
@@ -480,6 +486,8 @@ openclaw matrix verify status
|
||||
```
|
||||
|
||||
Add `--account <id>` to target a named account. This can also recreate secret storage if the current backup secret cannot be loaded safely.
|
||||
Add `--rotate-recovery-key` only when you intentionally want the old recovery
|
||||
key to stop unlocking the fresh backup baseline.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -501,6 +509,34 @@ openclaw matrix verify status
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Deleted or invalid Matrix device">
|
||||
If `verify status` says the current device is no longer listed on the
|
||||
homeserver, create a new OpenClaw Matrix device. For password login:
|
||||
|
||||
```bash
|
||||
openclaw matrix account add \
|
||||
--account assistant \
|
||||
--homeserver https://matrix.example.org \
|
||||
--user-id '@assistant:example.org' \
|
||||
--password '<password>' \
|
||||
--device-name OpenClaw-Gateway
|
||||
```
|
||||
|
||||
For token auth, create a fresh access token in your Matrix client or admin UI,
|
||||
then update OpenClaw:
|
||||
|
||||
```bash
|
||||
openclaw matrix account add \
|
||||
--account assistant \
|
||||
--homeserver https://matrix.example.org \
|
||||
--access-token '<token>'
|
||||
```
|
||||
|
||||
Replace `assistant` with the account ID from the failed command, or omit
|
||||
`--account` for the default account.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Device hygiene">
|
||||
Old OpenClaw-managed devices can accumulate. List and prune:
|
||||
|
||||
|
||||
@@ -93,16 +93,20 @@ If your old installation had local-only encrypted history that was never backed
|
||||
openclaw matrix verify backup status
|
||||
```
|
||||
|
||||
5. If OpenClaw tells you a recovery key is needed, run:
|
||||
5. Put the recovery key for the Matrix account you are repairing in an account-specific environment variable. For a single default account, `MATRIX_RECOVERY_KEY` is fine. For multiple accounts, use one variable per account, for example `MATRIX_RECOVERY_KEY_ASSISTANT`, and add `--account assistant` to the command.
|
||||
|
||||
6. If OpenClaw tells you a recovery key is needed, run the command for the matching account:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"
|
||||
printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin
|
||||
printf '%s\n' "$MATRIX_RECOVERY_KEY_ASSISTANT" | openclaw matrix verify backup restore --recovery-key-stdin --account assistant
|
||||
```
|
||||
|
||||
6. If this device is still unverified, run:
|
||||
7. If this device is still unverified, run the command for the matching account:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify device "<your-recovery-key>"
|
||||
printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin
|
||||
printf '%s\n' "$MATRIX_RECOVERY_KEY_ASSISTANT" | openclaw matrix verify device --recovery-key-stdin --account assistant
|
||||
```
|
||||
|
||||
If the recovery key is accepted and backup is usable, but `Cross-signing verified`
|
||||
@@ -116,13 +120,13 @@ If your old installation had local-only encrypted history that was never backed
|
||||
and type `yes` only when they match. The command exits successfully only
|
||||
after `Cross-signing verified` becomes `yes`.
|
||||
|
||||
7. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run:
|
||||
8. If you are intentionally abandoning unrecoverable old history and want a fresh backup baseline for future messages, run:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify backup reset --yes
|
||||
```
|
||||
|
||||
8. If no server-side key backup exists yet, create one for future recoveries:
|
||||
9. If no server-side key backup exists yet, create one for future recoveries:
|
||||
|
||||
```bash
|
||||
openclaw matrix verify bootstrap
|
||||
@@ -242,15 +246,15 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: some old room keys existed only in the old local store and had never been uploaded to Matrix backup.
|
||||
- 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 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.`
|
||||
`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-stdin" after upgrade if they have the recovery key.`
|
||||
|
||||
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
|
||||
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
- What to do: run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin`.
|
||||
|
||||
`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 `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
- 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 .../recovery-key.json already contains a different recovery key. Leaving the existing file unchanged.`
|
||||
|
||||
@@ -265,39 +269,39 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
`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 `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"` if needed.
|
||||
- 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.
|
||||
|
||||
### Manual recovery messages
|
||||
|
||||
`Backup key is not loaded on this device. Run 'openclaw matrix verify backup restore' to load it and restore old room keys.`
|
||||
|
||||
- Meaning: OpenClaw knows you should have a backup key, but it is not active on this device.
|
||||
- What to do: run `openclaw matrix verify backup restore`, or pass `--recovery-key` if needed.
|
||||
- What to do: run `openclaw matrix verify backup restore`, or set `MATRIX_RECOVERY_KEY` and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin` if needed.
|
||||
|
||||
`Store a recovery key with 'openclaw matrix verify device <key>', then run 'openclaw matrix verify backup restore'.`
|
||||
`Store a recovery key with 'openclaw matrix verify device --recovery-key-stdin', then run 'openclaw matrix verify backup restore'.`
|
||||
|
||||
- Meaning: this device does not currently have the recovery key stored.
|
||||
- What to do: verify the device with your recovery key first, then restore the backup.
|
||||
- What to do: set `MATRIX_RECOVERY_KEY`, run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`, then restore the backup.
|
||||
|
||||
`Backup key mismatch on this device. Re-run 'openclaw matrix verify device <key>' with the matching recovery key.`
|
||||
`Backup key mismatch on this device. Re-run 'openclaw matrix verify device --recovery-key-stdin' with the matching recovery key.`
|
||||
|
||||
- Meaning: the stored key does not match the active Matrix backup.
|
||||
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"` with the correct key.
|
||||
- What to do: set `MATRIX_RECOVERY_KEY` to the correct key and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`.
|
||||
|
||||
If you accept losing unrecoverable old encrypted history, you can instead reset the
|
||||
current backup baseline with `openclaw matrix verify backup reset --yes`. When the
|
||||
stored backup secret is broken, that reset may also recreate secret storage so the
|
||||
new backup key can load correctly after restart.
|
||||
|
||||
`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device <key>'.`
|
||||
`Backup trust chain is not verified on this device. Re-run 'openclaw matrix verify device --recovery-key-stdin'.`
|
||||
|
||||
- Meaning: the backup exists, but this device does not trust the cross-signing chain strongly enough yet.
|
||||
- What to do: rerun `openclaw matrix verify device "<your-recovery-key>"`.
|
||||
- What to do: set `MATRIX_RECOVERY_KEY` and run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`.
|
||||
|
||||
`Matrix recovery key is required`
|
||||
|
||||
- Meaning: you tried a recovery step without supplying a recovery key when one was required.
|
||||
- What to do: rerun the command with your recovery key.
|
||||
- What to do: rerun the command with `--recovery-key-stdin`, for example `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin`.
|
||||
|
||||
`Invalid Matrix recovery key: ...`
|
||||
|
||||
@@ -313,7 +317,7 @@ new backup key can load correctly after restart.
|
||||
- What to do: run `openclaw matrix verify self`, accept the request in another
|
||||
Matrix client, compare the SAS, and type `yes` only when it matches. The
|
||||
command waits for full Matrix identity trust before reporting success. Use
|
||||
`openclaw matrix verify bootstrap --recovery-key "<your-recovery-key>" --force-reset-cross-signing`
|
||||
`printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify bootstrap --recovery-key-stdin --force-reset-cross-signing`
|
||||
only when you intentionally want to replace the current cross-signing identity.
|
||||
|
||||
`Matrix key backup is not active on this device after loading from secret storage.`
|
||||
@@ -321,10 +325,10 @@ new backup key can load correctly after restart.
|
||||
- Meaning: secret storage did not produce an active backup session on this device.
|
||||
- What to do: verify the device first, then recheck with `openclaw matrix verify backup status`.
|
||||
|
||||
`Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device <key>' first.`
|
||||
`Matrix crypto backend cannot load backup keys from secret storage. Verify this device with 'openclaw matrix verify device --recovery-key-stdin' first.`
|
||||
|
||||
- Meaning: this device cannot restore from secret storage until device verification is complete.
|
||||
- What to do: run `openclaw matrix verify device "<your-recovery-key>"` first.
|
||||
- What to do: run `printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify device --recovery-key-stdin` first.
|
||||
|
||||
### Custom plugin install messages
|
||||
|
||||
@@ -340,7 +344,7 @@ Run these checks in order:
|
||||
```bash
|
||||
openclaw matrix verify status --verbose
|
||||
openclaw matrix verify backup status --verbose
|
||||
openclaw matrix verify backup restore --recovery-key "<your-recovery-key>" --verbose
|
||||
printf '%s\n' "$MATRIX_RECOVERY_KEY" | openclaw matrix verify backup restore --recovery-key-stdin --verbose
|
||||
```
|
||||
|
||||
If the backup restores successfully but some old rooms are still missing history, those missing keys were probably never backed up by the previous plugin.
|
||||
|
||||
@@ -34,6 +34,15 @@ const consoleLogMock = vi.fn();
|
||||
const consoleErrorMock = vi.fn();
|
||||
const stdoutWriteMock = vi.fn();
|
||||
|
||||
function mockRecoveryKeyStdin(value: string): void {
|
||||
vi.spyOn(process.stdin, Symbol.asyncIterator).mockReturnValue(
|
||||
(async function* (): AsyncGenerator<Buffer, undefined, unknown> {
|
||||
yield Buffer.from(value);
|
||||
return undefined;
|
||||
})(),
|
||||
);
|
||||
}
|
||||
|
||||
vi.mock("./matrix/actions/verification.js", () => ({
|
||||
acceptMatrixVerification: (...args: unknown[]) => acceptMatrixVerificationMock(...args),
|
||||
bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args),
|
||||
@@ -288,10 +297,10 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Device verified by owner: no");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self and follow the prompts from another Matrix client.",
|
||||
"- Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run openclaw matrix verify self, accept the request in another verified Matrix client, and confirm the SAS only if it matches.",
|
||||
);
|
||||
expect(consoleLogMock).toHaveBeenCalledWith(
|
||||
"- If you intend to replace the current cross-signing identity, run openclaw matrix verify bootstrap --recovery-key '<key>' --force-reset-cross-signing.",
|
||||
"- If you intend to replace the current cross-signing identity, run the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY\" | openclaw matrix verify bootstrap --recovery-key-stdin --force-reset-cross-signing.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -748,6 +757,35 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it("reads backup restore recovery key from stdin", async () => {
|
||||
restoreMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: true,
|
||||
backupVersion: "1",
|
||||
imported: 1,
|
||||
total: 1,
|
||||
loadedFromSecretStorage: false,
|
||||
backup: {
|
||||
serverVersion: "1",
|
||||
activeVersion: "1",
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
decryptionKeyCached: true,
|
||||
},
|
||||
});
|
||||
mockRecoveryKeyStdin("stdin-recovery-key\n");
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "backup", "restore", "--recovery-key-stdin"], {
|
||||
from: "user",
|
||||
});
|
||||
|
||||
expect(restoreMatrixRoomKeyBackupMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
recoveryKey: "stdin-recovery-key",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("sets non-zero exit code for backup reset failures in JSON mode", async () => {
|
||||
resetMatrixRoomKeyBackupMock.mockResolvedValue({
|
||||
success: false,
|
||||
@@ -1432,13 +1470,50 @@ describe("matrix CLI verification commands", () => {
|
||||
"Backup issue: backup decryption key is not loaded on this device (secret storage did not return a key)",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys.",
|
||||
"- Backup key is not loaded on this device. Run openclaw matrix verify backup restore to load it and restore old room keys. If restore still cannot load the key, run the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY\" | openclaw matrix verify backup restore --recovery-key-stdin.",
|
||||
);
|
||||
expect(console.log).not.toHaveBeenCalledWith(
|
||||
"- Backup is present but not trusted for this device. Re-run 'openclaw matrix verify device <key>'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("fails status with re-login guidance when the current Matrix device is missing on the server", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
verified: false,
|
||||
localVerified: true,
|
||||
crossSigningVerified: false,
|
||||
signedByOwner: false,
|
||||
userId: "@bot:example.org",
|
||||
deviceId: "DEVICE123",
|
||||
serverDeviceKnown: false,
|
||||
backupVersion: null,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
trusted: null,
|
||||
matchesDecryptionKey: null,
|
||||
decryptionKeyCached: true,
|
||||
keyLoadAttempted: false,
|
||||
keyLoadError: null,
|
||||
},
|
||||
recoveryKeyStored: true,
|
||||
recoveryKeyCreatedAt: "2026-02-25T20:10:11.000Z",
|
||||
pendingVerifications: 0,
|
||||
});
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"Device issue: current Matrix device is missing from the homeserver device list",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- This Matrix device is no longer listed on the homeserver. Create a new OpenClaw Matrix device with openclaw matrix account add --homeserver '<url>' --user-id '<@user:server>' --password '<password>' --device-name OpenClaw-Gateway. If you use token auth, create a fresh Matrix access token in your Matrix client or admin UI, then run openclaw matrix account add --homeserver '<url>' --access-token '<token>'.",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes key load failure details in status output", async () => {
|
||||
getMatrixVerificationStatusMock.mockResolvedValue({
|
||||
encryptionEnabled: true,
|
||||
@@ -1499,7 +1574,7 @@ describe("matrix CLI verification commands", () => {
|
||||
await program.parseAsync(["matrix", "verify", "status"], { from: "user" });
|
||||
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. This may also repair secret storage so the new backup key can be loaded after restart.",
|
||||
"- If you want a fresh backup baseline and accept losing unrecoverable history, run openclaw matrix verify backup reset --yes. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1513,7 +1588,7 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(resetMatrixRoomKeyBackupMock).not.toHaveBeenCalled();
|
||||
expect(console.error).toHaveBeenCalledWith(
|
||||
"Backup reset failed: Refusing to reset Matrix room-key backup without --yes",
|
||||
"Backup reset failed: Refusing to reset Matrix room-key backup without --yes. If you accept losing unrecoverable history, re-run openclaw matrix verify backup reset --yes.",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1527,6 +1602,7 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
rotateRecoveryKey: false,
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Reset success: yes");
|
||||
expect(console.log).toHaveBeenCalledWith("Previous backup version: 1");
|
||||
@@ -1535,6 +1611,23 @@ describe("matrix CLI verification commands", () => {
|
||||
expect(console.log).toHaveBeenCalledWith("Backup: active and trusted on this device");
|
||||
});
|
||||
|
||||
it("passes recovery-key rotation through backup reset", async () => {
|
||||
const program = buildProgram();
|
||||
|
||||
await program.parseAsync(
|
||||
["matrix", "verify", "backup", "reset", "--yes", "--rotate-recovery-key"],
|
||||
{
|
||||
from: "user",
|
||||
},
|
||||
);
|
||||
|
||||
expect(resetMatrixRoomKeyBackupMock).toHaveBeenCalledWith({
|
||||
accountId: "default",
|
||||
cfg: {},
|
||||
rotateRecoveryKey: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("prints resolved account-aware guidance when a named Matrix account is selected implicitly", async () => {
|
||||
resolveMatrixAuthContextMock.mockImplementation(
|
||||
({ cfg, accountId }: { cfg: unknown; accountId?: string | null }) => ({
|
||||
@@ -1577,7 +1670,7 @@ describe("matrix CLI verification commands", () => {
|
||||
});
|
||||
expect(console.log).toHaveBeenCalledWith("Account: assistant");
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify device '<key>' --account assistant to verify this device.",
|
||||
"- Run the shown printf pipeline with the Matrix recovery key env var for this account: printf '%s\\n' \"$MATRIX_RECOVERY_KEY_ASSISTANT\" | openclaw matrix verify device --recovery-key-stdin --account assistant. If you do not have the recovery key but still have another verified Matrix client, run openclaw matrix verify self --account assistant instead.",
|
||||
);
|
||||
expect(console.log).toHaveBeenCalledWith(
|
||||
"- Run openclaw matrix verify bootstrap --account assistant to create a room key backup.",
|
||||
|
||||
@@ -74,6 +74,44 @@ function markCliFailure(): void {
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function readMatrixCliRecoveryKeyFromStdin(): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of process.stdin) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
|
||||
}
|
||||
const recoveryKey = Buffer.concat(chunks).toString("utf8").trim();
|
||||
if (!recoveryKey) {
|
||||
throw new Error("Matrix recovery key was requested from stdin, but stdin was empty.");
|
||||
}
|
||||
return recoveryKey;
|
||||
}
|
||||
|
||||
async function resolveMatrixCliRecoveryKeyInput(options: {
|
||||
recoveryKey?: string;
|
||||
recoveryKeyStdin?: boolean;
|
||||
}): Promise<string | undefined> {
|
||||
if (options.recoveryKey && options.recoveryKeyStdin === true) {
|
||||
throw new Error("Use either --recovery-key or --recovery-key-stdin, not both.");
|
||||
}
|
||||
if (options.recoveryKeyStdin === true) {
|
||||
return await readMatrixCliRecoveryKeyFromStdin();
|
||||
}
|
||||
return options.recoveryKey;
|
||||
}
|
||||
|
||||
async function requireMatrixCliRecoveryKeyInput(options: {
|
||||
recoveryKey?: string;
|
||||
recoveryKeyStdin?: boolean;
|
||||
}): Promise<string> {
|
||||
const recoveryKey = await resolveMatrixCliRecoveryKeyInput(options);
|
||||
if (!recoveryKey) {
|
||||
throw new Error(
|
||||
"Matrix recovery key is required. Pass --recovery-key-stdin to read it from stdin.",
|
||||
);
|
||||
}
|
||||
return recoveryKey;
|
||||
}
|
||||
|
||||
function toErrorMessage(err: unknown): string {
|
||||
return formatMatrixErrorMessage(err);
|
||||
}
|
||||
@@ -123,6 +161,15 @@ function formatMatrixCliCommand(command: string, accountId?: string): string {
|
||||
return formatMatrixCliCommandParts(command.split(" "), accountId);
|
||||
}
|
||||
|
||||
function formatMatrixCliRecoveryKeyStdinCommand(command: string, accountId?: string): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const envName =
|
||||
normalizedAccountId === "default"
|
||||
? "MATRIX_RECOVERY_KEY"
|
||||
: `MATRIX_RECOVERY_KEY_${normalizedAccountId.replace(/[^A-Za-z0-9]/g, "_").toUpperCase()}`;
|
||||
return `printf '%s\\n' "$${envName}" | ${formatMatrixCliCommand(command, accountId)}`;
|
||||
}
|
||||
|
||||
function formatMatrixCliCommandParts(parts: string[], accountId?: string): string {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const command = ["openclaw", "matrix", ...parts];
|
||||
@@ -490,6 +537,7 @@ type MatrixCliCommandConfig<TResult> = {
|
||||
shouldFail?: (result: TResult) => boolean;
|
||||
errorPrefix: string;
|
||||
onJsonError?: (message: string) => unknown;
|
||||
onTextError?: (message: string) => void;
|
||||
};
|
||||
|
||||
async function runMatrixCliCommand<TResult>(
|
||||
@@ -512,6 +560,7 @@ async function runMatrixCliCommand<TResult>(
|
||||
printJson(config.onJsonError ? config.onJsonError(message) : { error: message });
|
||||
} else {
|
||||
console.error(`${config.errorPrefix}: ${formatMatrixCliText(message)}`);
|
||||
config.onTextError?.(message);
|
||||
}
|
||||
markCliFailure();
|
||||
} finally {
|
||||
@@ -539,6 +588,7 @@ type MatrixCliVerificationStatus = {
|
||||
signedByOwner: boolean;
|
||||
backupVersion: string | null;
|
||||
backup?: MatrixCliBackupStatus;
|
||||
serverDeviceKnown?: boolean | null;
|
||||
recoveryKeyStored: boolean;
|
||||
recoveryKeyCreatedAt: string | null;
|
||||
pendingVerifications: number;
|
||||
@@ -1023,6 +1073,12 @@ async function runMatrixCliSelfVerificationCommand(
|
||||
}
|
||||
console.log("Self-verification complete.");
|
||||
},
|
||||
onTextError: () => {
|
||||
printGuidance([
|
||||
`Run ${formatMatrixCliCommand("verify self", accountId)} again and accept the request in another verified Matrix client for this account.`,
|
||||
`Then run ${formatMatrixCliCommand("verify status --verbose", accountId)} to confirm Cross-signing verified: yes and Signed by owner: yes.`,
|
||||
]);
|
||||
},
|
||||
errorPrefix: "Self-verification failed",
|
||||
});
|
||||
}
|
||||
@@ -1031,6 +1087,14 @@ function printVerificationGuidance(status: MatrixCliVerificationStatus, accountI
|
||||
printGuidance(buildVerificationGuidance(status, accountId));
|
||||
}
|
||||
|
||||
function printBackupGuidance(
|
||||
backup: MatrixCliBackupStatus,
|
||||
accountId?: string,
|
||||
options: { recoveryKeyStored?: boolean } = {},
|
||||
): void {
|
||||
printGuidance(buildBackupGuidance(backup, accountId, options));
|
||||
}
|
||||
|
||||
function printBackupSummary(backup: MatrixCliBackupStatus): void {
|
||||
const issue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
console.log(`Backup: ${issue.summary}`);
|
||||
@@ -1044,22 +1108,46 @@ function buildVerificationGuidance(
|
||||
accountId?: string,
|
||||
): string[] {
|
||||
const backup = resolveBackupStatus(status);
|
||||
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
const nextSteps = new Set<string>();
|
||||
if (!status.verified) {
|
||||
if (status.recoveryKeyAccepted === true && status.backupUsable === true) {
|
||||
nextSteps.add(
|
||||
`Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run ${formatMatrixCliCommand("verify self", accountId)} and follow the prompts from another Matrix client.`,
|
||||
`Recovery key can unlock the room-key backup, but full Matrix identity trust is still incomplete. Run ${formatMatrixCliCommand("verify self", accountId)}, accept the request in another verified Matrix client, and confirm the SAS only if it matches.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you intend to replace the current cross-signing identity, run ${formatMatrixCliCommand("verify bootstrap --recovery-key <key> --force-reset-cross-signing", accountId)}.`,
|
||||
`If you intend to replace the current cross-signing identity, run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify bootstrap --recovery-key-stdin --force-reset-cross-signing", accountId)}.`,
|
||||
);
|
||||
} else {
|
||||
nextSteps.add(
|
||||
`Run ${formatMatrixCliCommand("verify device <key>", accountId)} to verify this device.`,
|
||||
`Run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify device --recovery-key-stdin", accountId)}. If you do not have the recovery key but still have another verified Matrix client, run ${formatMatrixCliCommand("verify self", accountId)} instead.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (status.serverDeviceKnown === false) {
|
||||
nextSteps.add(
|
||||
`This Matrix device is no longer listed on the homeserver. Create a new OpenClaw Matrix device with ${formatMatrixCliCommand("account add --homeserver <url> --user-id <@user:server> --password <password> --device-name OpenClaw-Gateway", accountId)}. If you use token auth, create a fresh Matrix access token in your Matrix client or admin UI, then run ${formatMatrixCliCommand("account add --homeserver <url> --access-token <token>", accountId)}.`,
|
||||
);
|
||||
}
|
||||
for (const step of buildBackupGuidance(backup, accountId, {
|
||||
recoveryKeyStored: status.recoveryKeyStored,
|
||||
})) {
|
||||
nextSteps.add(step);
|
||||
}
|
||||
if (status.pendingVerifications > 0) {
|
||||
nextSteps.add(
|
||||
`Review pending verification requests with ${formatMatrixCliCommand("verify list", accountId)}. Complete each active request with ${formatMatrixCliCommand("verify sas <id>", accountId)} and ${formatMatrixCliCommand("verify confirm-sas <id>", accountId)}, or cancel stale requests with ${formatMatrixCliCommand("verify cancel <id>", accountId)}.`,
|
||||
);
|
||||
}
|
||||
return Array.from(nextSteps);
|
||||
}
|
||||
|
||||
function buildBackupGuidance(
|
||||
backup: MatrixCliBackupStatus,
|
||||
accountId?: string,
|
||||
options: { recoveryKeyStored?: boolean } = {},
|
||||
): string[] {
|
||||
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
const nextSteps = new Set<string>();
|
||||
if (backupIssue.code === "missing-server-backup") {
|
||||
nextSteps.add(
|
||||
`Run ${formatMatrixCliCommand("verify bootstrap", accountId)} to create a room key backup.`,
|
||||
@@ -1069,37 +1157,37 @@ function buildVerificationGuidance(
|
||||
backupIssue.code === "key-not-loaded" ||
|
||||
backupIssue.code === "inactive"
|
||||
) {
|
||||
if (status.recoveryKeyStored) {
|
||||
if (options.recoveryKeyStored) {
|
||||
nextSteps.add(
|
||||
`Backup key is not loaded on this device. Run ${formatMatrixCliCommand("verify backup restore", accountId)} to load it and restore old room keys.`,
|
||||
`Backup key is not loaded on this device. Run ${formatMatrixCliCommand("verify backup restore", accountId)} to load it and restore old room keys. If restore still cannot load the key, run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)}.`,
|
||||
);
|
||||
} else {
|
||||
nextSteps.add(
|
||||
`Store a recovery key with ${formatMatrixCliCommand("verify device <key>", accountId)}, then run ${formatMatrixCliCommand("verify backup restore", accountId)}.`,
|
||||
`Run the shown printf pipeline with the Matrix recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)} to load the server backup and store the key for future restores.`,
|
||||
);
|
||||
}
|
||||
} else if (backupIssue.code === "key-mismatch") {
|
||||
nextSteps.add(
|
||||
`Backup key mismatch on this device. Re-run ${formatMatrixCliCommand("verify device <key>", accountId)} with the matching recovery key.`,
|
||||
`Backup key mismatch on this device. Run the shown printf pipeline with the active server backup recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify backup restore --recovery-key-stdin", accountId)}.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`,
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.`,
|
||||
);
|
||||
} else if (backupIssue.code === "untrusted-signature") {
|
||||
nextSteps.add(
|
||||
`Backup trust chain is not verified on this device. Re-run ${formatMatrixCliCommand("verify device <key>", accountId)} if you have the correct recovery key.`,
|
||||
`Backup trust chain is not verified on this device. Run the shown printf pipeline with the correct recovery key env var for this account: ${formatMatrixCliRecoveryKeyStdinCommand("verify device --recovery-key-stdin", accountId)}.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. This may also repair secret storage so the new backup key can be loaded after restart.`,
|
||||
`If device identity trust remains incomplete after that, run ${formatMatrixCliCommand("verify self", accountId)} from another verified Matrix client.`,
|
||||
);
|
||||
nextSteps.add(
|
||||
`If you want a fresh backup baseline and accept losing unrecoverable history, run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}. Add --rotate-recovery-key only when the old recovery key should stop unlocking the fresh backup.`,
|
||||
);
|
||||
} else if (backupIssue.code === "indeterminate") {
|
||||
nextSteps.add(
|
||||
`Run ${formatMatrixCliCommand("verify status --verbose", accountId)} to inspect backup trust diagnostics.`,
|
||||
);
|
||||
}
|
||||
if (status.pendingVerifications > 0) {
|
||||
nextSteps.add(`Complete ${status.pendingVerifications} pending verification request(s).`);
|
||||
}
|
||||
return Array.from(nextSteps);
|
||||
}
|
||||
|
||||
@@ -1119,6 +1207,9 @@ function printVerificationStatus(
|
||||
accountId?: string,
|
||||
): void {
|
||||
console.log(`Verified by owner: ${status.verified ? "yes" : "no"}`);
|
||||
if (status.serverDeviceKnown === false) {
|
||||
console.log("Device issue: current Matrix device is missing from the homeserver device list");
|
||||
}
|
||||
const backup = resolveBackupStatus(status);
|
||||
const backupIssue = resolveMatrixRoomKeyBackupIssue(backup);
|
||||
printVerificationBackupSummary(status);
|
||||
@@ -1128,6 +1219,9 @@ function printVerificationStatus(
|
||||
if (verbose) {
|
||||
console.log("Diagnostics:");
|
||||
printVerificationIdentity(status);
|
||||
if (status.serverDeviceKnown !== undefined) {
|
||||
console.log(`Device present on server: ${yesNoUnknown(status.serverDeviceKnown ?? null)}`);
|
||||
}
|
||||
printVerificationTrustDiagnostics(status);
|
||||
printVerificationBackupStatus(status);
|
||||
console.log(`Recovery key stored: ${status.recoveryKeyStored ? "yes" : "no"}`);
|
||||
@@ -1649,6 +1743,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
printAccountLabel(accountId);
|
||||
printVerificationStatus(status, verbose, accountId);
|
||||
},
|
||||
shouldFail: (status) => status.serverDeviceKnown === false,
|
||||
errorPrefix: "Error",
|
||||
});
|
||||
},
|
||||
@@ -1674,6 +1769,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
if (verbose) {
|
||||
printBackupStatus(status);
|
||||
}
|
||||
printBackupGuidance(status, accountId);
|
||||
},
|
||||
errorPrefix: "Backup status failed",
|
||||
});
|
||||
@@ -1686,19 +1782,32 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
)
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--yes", "Confirm destructive backup reset", false)
|
||||
.option("--rotate-recovery-key", "Create a new Matrix recovery key for the fresh backup")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: { account?: string; yes?: boolean; verbose?: boolean; json?: boolean }) => {
|
||||
async (options: {
|
||||
account?: string;
|
||||
yes?: boolean;
|
||||
rotateRecoveryKey?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => {
|
||||
if (options.yes !== true) {
|
||||
throw new Error("Refusing to reset Matrix room-key backup without --yes");
|
||||
throw new Error(
|
||||
`Refusing to reset Matrix room-key backup without --yes. If you accept losing unrecoverable history, re-run ${formatMatrixCliCommand("verify backup reset --yes", accountId)}.`,
|
||||
);
|
||||
}
|
||||
return await resetMatrixRoomKeyBackup({ accountId, cfg });
|
||||
return await resetMatrixRoomKeyBackup({
|
||||
accountId,
|
||||
cfg,
|
||||
rotateRecoveryKey: options.rotateRecoveryKey === true,
|
||||
});
|
||||
},
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
@@ -1720,6 +1829,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
printTimestamp("Reset at", result.resetAt);
|
||||
printBackupStatus(result.backup);
|
||||
}
|
||||
printBackupGuidance(result.backup, accountId);
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Backup reset failed",
|
||||
@@ -1732,13 +1842,18 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.command("restore")
|
||||
.description("Restore encrypted room keys from server backup")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Optional recovery key to load before restoring")
|
||||
.option(
|
||||
"--recovery-key <key>",
|
||||
"Optional recovery key to load before restoring (prefer --recovery-key-stdin)",
|
||||
)
|
||||
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (options: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
recoveryKeyStdin?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
}) => {
|
||||
@@ -1750,7 +1865,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
await restoreMatrixRoomKeyBackup({
|
||||
accountId,
|
||||
cfg,
|
||||
recoveryKey: options.recoveryKey,
|
||||
recoveryKey: await resolveMatrixCliRecoveryKeyInput(options),
|
||||
}),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
@@ -1768,6 +1883,9 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
printTimestamp("Restored at", result.restoredAt);
|
||||
printBackupStatus(result.backup);
|
||||
}
|
||||
printBackupGuidance(result.backup, accountId, {
|
||||
recoveryKeyStored: result.loadedFromSecretStorage,
|
||||
});
|
||||
},
|
||||
shouldFail: (result) => !result.success,
|
||||
errorPrefix: "Backup restore failed",
|
||||
@@ -1780,7 +1898,11 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
.command("bootstrap")
|
||||
.description("Bootstrap Matrix cross-signing and device verification state")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key <key>", "Recovery key to apply before bootstrap")
|
||||
.option(
|
||||
"--recovery-key <key>",
|
||||
"Recovery key to apply before bootstrap (prefer --recovery-key-stdin)",
|
||||
)
|
||||
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
|
||||
.option("--force-reset-cross-signing", "Force reset cross-signing identity before bootstrap")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
@@ -1788,6 +1910,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
async (options: {
|
||||
account?: string;
|
||||
recoveryKey?: string;
|
||||
recoveryKeyStdin?: boolean;
|
||||
forceResetCrossSigning?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
@@ -1800,7 +1923,7 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
await bootstrapMatrixVerification({
|
||||
accountId,
|
||||
cfg,
|
||||
recoveryKey: options.recoveryKey,
|
||||
recoveryKey: await resolveMatrixCliRecoveryKeyInput(options),
|
||||
forceResetCrossSigning: options.forceResetCrossSigning === true,
|
||||
}),
|
||||
onText: (result, verbose) => {
|
||||
@@ -1841,18 +1964,34 @@ export function registerMatrixCli(params: { program: Command }): void {
|
||||
);
|
||||
|
||||
verify
|
||||
.command("device <key>")
|
||||
.command("device [key]")
|
||||
.description("Verify device using a Matrix recovery key")
|
||||
.option("--account <id>", "Account ID (for multi-account setups)")
|
||||
.option("--recovery-key-stdin", "Read the Matrix recovery key from stdin")
|
||||
.option("--verbose", "Show detailed diagnostics")
|
||||
.option("--json", "Output as JSON")
|
||||
.action(
|
||||
async (key: string, options: { account?: string; verbose?: boolean; json?: boolean }) => {
|
||||
async (
|
||||
key: string | undefined,
|
||||
options: {
|
||||
account?: string;
|
||||
recoveryKeyStdin?: boolean;
|
||||
verbose?: boolean;
|
||||
json?: boolean;
|
||||
},
|
||||
) => {
|
||||
const { accountId, cfg } = resolveMatrixCliAccountContext(options.account);
|
||||
await runMatrixCliCommand({
|
||||
verbose: options.verbose === true,
|
||||
json: options.json === true,
|
||||
run: async () => await verifyMatrixRecoveryKey(key, { accountId, cfg }),
|
||||
run: async () =>
|
||||
await verifyMatrixRecoveryKey(
|
||||
await requireMatrixCliRecoveryKeyInput({
|
||||
recoveryKey: key,
|
||||
recoveryKeyStdin: options.recoveryKeyStdin,
|
||||
}),
|
||||
{ accountId, cfg },
|
||||
),
|
||||
onText: (result, verbose) => {
|
||||
printAccountLabel(accountId);
|
||||
if (!result.success) {
|
||||
|
||||
29
extensions/matrix/src/group-mentions.test.ts
Normal file
29
extensions/matrix/src/group-mentions.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||
|
||||
describe("Matrix group policy", () => {
|
||||
it("resolves room tool policy from the case-preserved Matrix room id", () => {
|
||||
const policy = resolveMatrixGroupToolPolicy({
|
||||
accountId: "default",
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
default: {
|
||||
groups: {
|
||||
"!RoomABC:example.org": {
|
||||
tools: { allow: ["sessions_spawn"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
groupId: "!roomabc:example.org",
|
||||
groupChannel: "!RoomABC:example.org",
|
||||
});
|
||||
|
||||
expect(policy).toEqual({ allow: ["sessions_spawn"] });
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ let listMatrixVerifications: typeof import("./verification.js").listMatrixVerifi
|
||||
let getMatrixEncryptionStatus: typeof import("./verification.js").getMatrixEncryptionStatus;
|
||||
let getMatrixRoomKeyBackupStatus: typeof import("./verification.js").getMatrixRoomKeyBackupStatus;
|
||||
let getMatrixVerificationStatus: typeof import("./verification.js").getMatrixVerificationStatus;
|
||||
let restoreMatrixRoomKeyBackup: typeof import("./verification.js").restoreMatrixRoomKeyBackup;
|
||||
let runMatrixSelfVerification: typeof import("./verification.js").runMatrixSelfVerification;
|
||||
let startMatrixVerification: typeof import("./verification.js").startMatrixVerification;
|
||||
|
||||
@@ -45,6 +46,7 @@ describe("matrix verification actions", () => {
|
||||
getMatrixRoomKeyBackupStatus,
|
||||
getMatrixVerificationStatus,
|
||||
listMatrixVerifications,
|
||||
restoreMatrixRoomKeyBackup,
|
||||
runMatrixSelfVerification,
|
||||
startMatrixVerification,
|
||||
} = await import("./verification.js"));
|
||||
@@ -262,6 +264,24 @@ describe("matrix verification actions", () => {
|
||||
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores room-key backup without startup crypto auto-repair", async () => {
|
||||
const restoreRoomKeyBackup = vi.fn(async () => ({
|
||||
success: true,
|
||||
imported: 1,
|
||||
total: 1,
|
||||
}));
|
||||
withResolvedActionClientMock.mockImplementation(async (_opts, run) => {
|
||||
return await run({ restoreRoomKeyBackup });
|
||||
});
|
||||
|
||||
const restored = await restoreMatrixRoomKeyBackup({ recoveryKey: " key " });
|
||||
|
||||
expect(restored).toMatchObject({ success: true });
|
||||
expect(restoreRoomKeyBackup).toHaveBeenCalledWith({ recoveryKey: "key" });
|
||||
expect(withResolvedActionClientMock).toHaveBeenCalledTimes(1);
|
||||
expect(withStartedActionClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rehydrates DM verification requests before follow-up actions", async () => {
|
||||
const tracked = {
|
||||
completed: false,
|
||||
|
||||
@@ -521,7 +521,7 @@ export async function restoreMatrixRoomKeyBackup(
|
||||
recoveryKey?: string;
|
||||
} = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
return await withResolvedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.restoreRoomKeyBackup({
|
||||
@@ -530,8 +530,16 @@ export async function restoreMatrixRoomKeyBackup(
|
||||
);
|
||||
}
|
||||
|
||||
export async function resetMatrixRoomKeyBackup(opts: MatrixActionClientOpts = {}) {
|
||||
return await withStartedActionClient(opts, async (client) => await client.resetRoomKeyBackup());
|
||||
export async function resetMatrixRoomKeyBackup(
|
||||
opts: MatrixActionClientOpts & { rotateRecoveryKey?: boolean } = {},
|
||||
) {
|
||||
return await withStartedActionClient(
|
||||
opts,
|
||||
async (client) =>
|
||||
await client.resetRoomKeyBackup({
|
||||
rotateRecoveryKey: opts.rotateRecoveryKey,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function bootstrapMatrixVerification(
|
||||
|
||||
50
extensions/matrix/src/matrix/client/logging.test.ts
Normal file
50
extensions/matrix/src/matrix/client/logging.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ensureMatrixSdkLoggingConfigured, setMatrixSdkLogMode } from "./logging.js";
|
||||
|
||||
type MatrixJsSdkTestLogger = typeof matrixJsSdkRootLogger & {
|
||||
getLevel?: () => number | string;
|
||||
levels: { WARN: number };
|
||||
methodFactory?: unknown;
|
||||
rebuild?: () => void;
|
||||
setLevel?: (level: number | string, persist?: boolean) => void;
|
||||
};
|
||||
|
||||
describe("Matrix SDK logging", () => {
|
||||
it("restores the Matrix JS SDK global logger level after quiet mode", () => {
|
||||
const logger = matrixJsSdkRootLogger as MatrixJsSdkTestLogger;
|
||||
const originalLevel = logger.getLevel?.();
|
||||
const originalMethodFactory = logger.methodFactory;
|
||||
try {
|
||||
logger.setLevel?.("warn", false);
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
setMatrixSdkLogMode("quiet");
|
||||
setMatrixSdkLogMode("default");
|
||||
|
||||
expect(logger.getLevel?.()).toBe(logger.levels.WARN);
|
||||
expect(logger.methodFactory).toBe(originalMethodFactory);
|
||||
} finally {
|
||||
if (typeof originalLevel === "string" || typeof originalLevel === "number") {
|
||||
logger.setLevel?.(originalLevel, false);
|
||||
}
|
||||
logger.methodFactory = originalMethodFactory;
|
||||
logger.rebuild?.();
|
||||
setMatrixSdkLogMode("default");
|
||||
}
|
||||
});
|
||||
|
||||
it("quiets the Matrix JS SDK global logger for JSON-safe CLI commands", () => {
|
||||
const debugSpy = vi.spyOn(console, "debug").mockImplementation(() => undefined);
|
||||
try {
|
||||
ensureMatrixSdkLoggingConfigured();
|
||||
setMatrixSdkLogMode("quiet");
|
||||
|
||||
matrixJsSdkRootLogger.getChild("[MatrixRTCSession test]").debug("noisy diagnostic");
|
||||
|
||||
expect(debugSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
setMatrixSdkLogMode("default");
|
||||
debugSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import { logger as matrixJsSdkRootLogger } from "matrix-js-sdk/lib/logger.js";
|
||||
import { ConsoleLogger, LogService, setMatrixConsoleLogging } from "../sdk/logger.js";
|
||||
|
||||
let matrixSdkLoggingConfigured = false;
|
||||
let matrixSdkLogMode: "default" | "quiet" = "default";
|
||||
const matrixSdkBaseLogger = new ConsoleLogger();
|
||||
let matrixJsSdkRootLoggerSnapshot: MatrixJsSdkRootLoggerSnapshot | null = null;
|
||||
|
||||
type MatrixJsSdkLogger = {
|
||||
trace: (...messageOrObject: unknown[]) => void;
|
||||
@@ -13,6 +15,18 @@ type MatrixJsSdkLogger = {
|
||||
getChild: (namespace: string) => MatrixJsSdkLogger;
|
||||
};
|
||||
|
||||
type MatrixJsSdkLoglevelLogger = {
|
||||
getLevel?: () => number | string;
|
||||
methodFactory?: unknown;
|
||||
rebuild?: () => void;
|
||||
setLevel?: (level: number | string, persist?: boolean) => void;
|
||||
};
|
||||
|
||||
type MatrixJsSdkRootLoggerSnapshot = {
|
||||
level?: number | string;
|
||||
methodFactory?: unknown;
|
||||
};
|
||||
|
||||
function shouldSuppressMatrixHttpNotFound(module: string, messageOrObject: unknown[]): boolean {
|
||||
if (!module.includes("MatrixHttpClient")) {
|
||||
return false;
|
||||
@@ -50,6 +64,7 @@ export function createMatrixJsSdkClientLogger(prefix = "matrix"): MatrixJsSdkLog
|
||||
|
||||
function applyMatrixSdkLogger(): void {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
setMatrixJsSdkRootLoggerLevel("silent");
|
||||
LogService.setLogger({
|
||||
trace: () => {},
|
||||
debug: () => {},
|
||||
@@ -60,6 +75,7 @@ function applyMatrixSdkLogger(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
setMatrixJsSdkRootLoggerLevel("debug");
|
||||
LogService.setLogger({
|
||||
trace: (module, ...messageOrObject) => matrixSdkBaseLogger.trace(module, ...messageOrObject),
|
||||
debug: (module, ...messageOrObject) => matrixSdkBaseLogger.debug(module, ...messageOrObject),
|
||||
@@ -74,6 +90,26 @@ function applyMatrixSdkLogger(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function setMatrixJsSdkRootLoggerLevel(level: "debug" | "silent"): void {
|
||||
const logger = matrixJsSdkRootLogger as unknown as MatrixJsSdkLoglevelLogger;
|
||||
matrixJsSdkRootLoggerSnapshot ??= {
|
||||
level: logger.getLevel?.(),
|
||||
methodFactory: logger.methodFactory,
|
||||
};
|
||||
if (level === "silent") {
|
||||
logger.methodFactory = () => () => undefined;
|
||||
logger.setLevel?.("silent", false);
|
||||
logger.rebuild?.();
|
||||
return;
|
||||
}
|
||||
logger.methodFactory = matrixJsSdkRootLoggerSnapshot.methodFactory;
|
||||
const previousLevel = matrixJsSdkRootLoggerSnapshot.level;
|
||||
if (typeof previousLevel === "string" || typeof previousLevel === "number") {
|
||||
logger.setLevel?.(previousLevel, false);
|
||||
}
|
||||
logger.rebuild?.();
|
||||
}
|
||||
|
||||
function createMatrixJsSdkLoggerInstance(prefix: string): MatrixJsSdkLogger {
|
||||
const log = (method: keyof ConsoleLogger, ...messageOrObject: unknown[]): void => {
|
||||
if (matrixSdkLogMode === "quiet") {
|
||||
|
||||
@@ -56,6 +56,8 @@ type CommandResult = {
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
let defaultMatrixCryptoRuntimeEnsurePromise: Promise<void> | null = null;
|
||||
|
||||
async function runFixedCommandWithTimeout(params: {
|
||||
argv: string[];
|
||||
cwd: string;
|
||||
@@ -149,6 +151,25 @@ function isMissingMatrixCryptoRuntimeError(error: unknown): boolean {
|
||||
export async function ensureMatrixCryptoRuntime(
|
||||
params: MatrixCryptoRuntimeDeps = {},
|
||||
): Promise<void> {
|
||||
const usesDefaultRuntime =
|
||||
!params.requireFn && !params.runCommand && !params.resolveFn && !params.nodeExecutable;
|
||||
if (usesDefaultRuntime && defaultMatrixCryptoRuntimeEnsurePromise) {
|
||||
await defaultMatrixCryptoRuntimeEnsurePromise;
|
||||
return;
|
||||
}
|
||||
const ensurePromise = ensureMatrixCryptoRuntimeOnce(params);
|
||||
if (!usesDefaultRuntime) {
|
||||
await ensurePromise;
|
||||
return;
|
||||
}
|
||||
defaultMatrixCryptoRuntimeEnsurePromise = ensurePromise.catch((error: unknown) => {
|
||||
defaultMatrixCryptoRuntimeEnsurePromise = null;
|
||||
throw error;
|
||||
});
|
||||
await defaultMatrixCryptoRuntimeEnsurePromise;
|
||||
}
|
||||
|
||||
async function ensureMatrixCryptoRuntimeOnce(params: MatrixCryptoRuntimeDeps): Promise<void> {
|
||||
const requireFn = params.requireFn ?? defaultRequireFn;
|
||||
try {
|
||||
requireFn("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
|
||||
@@ -1260,11 +1260,11 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
const finalized = vi.mocked(finalizeInboundContext).mock.calls.at(-1)?.[0];
|
||||
expect(finalized).toEqual(
|
||||
expect.objectContaining({
|
||||
GroupChannel: "!room:example.org",
|
||||
GroupSubject: "Ops Room",
|
||||
GroupId: "!room:example.org",
|
||||
}),
|
||||
);
|
||||
expect(finalized).not.toHaveProperty("GroupChannel");
|
||||
});
|
||||
|
||||
it("routes bound Matrix threads to the target session key", async () => {
|
||||
|
||||
@@ -1298,6 +1298,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
SenderUsername: senderId.split(":")[0]?.replace(/^@/, ""),
|
||||
GroupSubject: isRoom ? (roomName ?? roomId) : undefined,
|
||||
GroupId: isRoom ? roomId : undefined,
|
||||
GroupChannel: isRoom ? roomId : undefined,
|
||||
GroupSystemPrompt: isRoom ? groupSystemPrompt : undefined,
|
||||
Provider: "matrix" as const,
|
||||
Surface: "matrix" as const,
|
||||
|
||||
@@ -34,6 +34,7 @@ function createVerificationStatus(
|
||||
keyLoadError: null,
|
||||
},
|
||||
...overrides,
|
||||
serverDeviceKnown: overrides.serverDeviceKnown ?? true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ type MatrixJsClientStub = {
|
||||
sendStateEvent: ReturnType<typeof vi.fn>;
|
||||
redactEvent: ReturnType<typeof vi.fn>;
|
||||
getProfileInfo: ReturnType<typeof vi.fn>;
|
||||
getDevices: ReturnType<typeof vi.fn>;
|
||||
joinRoom: ReturnType<typeof vi.fn>;
|
||||
mxcUrlToHttp: ReturnType<typeof vi.fn>;
|
||||
uploadContent: ReturnType<typeof vi.fn>;
|
||||
@@ -178,6 +179,9 @@ function createMatrixJsClientStub(): MatrixJsClientStub {
|
||||
client.sendStateEvent = vi.fn(async () => ({ event_id: "$state" }));
|
||||
client.redactEvent = vi.fn(async () => ({ event_id: "$redact" }));
|
||||
client.getProfileInfo = vi.fn(async () => ({}));
|
||||
client.getDevices = vi.fn(async () => ({
|
||||
devices: [{ device_id: "DEVICE123", display_name: "OpenClaw" }],
|
||||
}));
|
||||
client.joinRoom = vi.fn(async () => ({}));
|
||||
client.mxcUrlToHttp = vi.fn(() => null);
|
||||
client.uploadContent = vi.fn(async () => ({ content_uri: "mxc://example/file" }));
|
||||
@@ -1337,6 +1341,7 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
recoveryKeyCreatedAt: null,
|
||||
recoveryKeyId: null,
|
||||
backupVersion: null,
|
||||
serverDeviceKnown: true,
|
||||
backup: {
|
||||
serverVersion: null,
|
||||
activeVersion: null,
|
||||
@@ -1547,6 +1552,61 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(status.verified).toBe(true);
|
||||
expect(status.userId).toBe("@bot:example.org");
|
||||
expect(status.deviceId).toBe("DEVICE123");
|
||||
expect(status.serverDeviceKnown).toBe(true);
|
||||
});
|
||||
|
||||
it("reports when the current Matrix device is missing from the homeserver device list", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getDevices = vi.fn(async () => ({
|
||||
devices: [{ device_id: "OTHERDEVICE" }],
|
||||
}));
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getDeviceVerificationStatus: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.deviceId).toBe("DEVICE123");
|
||||
expect(status.serverDeviceKnown).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps verification diagnostics when the homeserver device list cannot be read", async () => {
|
||||
matrixJsClient.getUserId = vi.fn(() => "@bot:example.org");
|
||||
matrixJsClient.getDeviceId = vi.fn(() => "DEVICE123");
|
||||
matrixJsClient.getDevices = vi.fn(async () => {
|
||||
throw new Error("device list unavailable");
|
||||
});
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapCrossSigning: vi.fn(async () => {}),
|
||||
bootstrapSecretStorage: vi.fn(async () => {}),
|
||||
requestOwnUserVerification: vi.fn(async () => null),
|
||||
getDeviceVerificationStatus: vi.fn(async () => ({
|
||||
isVerified: () => true,
|
||||
localVerified: true,
|
||||
crossSigningVerified: true,
|
||||
signedByOwner: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
await client.start();
|
||||
|
||||
const status = await client.getOwnDeviceVerificationStatus();
|
||||
expect(status.verified).toBe(true);
|
||||
expect(status.backup).toBeDefined();
|
||||
expect(status.serverDeviceKnown).toBeNull();
|
||||
});
|
||||
|
||||
it("does not treat local-only trust as Matrix identity trust", async () => {
|
||||
@@ -2473,6 +2533,60 @@ describe("MatrixClient crypto bootstrapping", () => {
|
||||
expect(checkKeyBackupAndEnable).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rotates the recovery key when resetting room-key backup with rotation requested", async () => {
|
||||
const checkKeyBackupAndEnable = vi.fn(async () => {});
|
||||
const bootstrapSecretStorage = vi.fn(
|
||||
async (opts?: { createSecretStorageKey?: () => Promise<unknown> }) => {
|
||||
await opts?.createSecretStorageKey?.();
|
||||
},
|
||||
);
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () => ({
|
||||
keyId: "ROTATED",
|
||||
keyInfo: { name: "Rotated recovery key" },
|
||||
privateKey: new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)),
|
||||
encodedPrivateKey: "rotated-key",
|
||||
}));
|
||||
matrixJsClient.getCrypto = vi.fn(() => ({
|
||||
on: vi.fn(),
|
||||
bootstrapSecretStorage,
|
||||
checkKeyBackupAndEnable,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
getActiveSessionBackupVersion: vi.fn(async () => "21870"),
|
||||
getSessionBackupPrivateKey: vi.fn(async () => new Uint8Array([1])),
|
||||
getSecretStorageStatus: vi.fn(async () => ({ ready: true, defaultKeyId: "OLD" })),
|
||||
getKeyBackupInfo: vi.fn(async () => ({
|
||||
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
|
||||
auth_data: {},
|
||||
version: "21870",
|
||||
})),
|
||||
isKeyBackupTrusted: vi.fn(async () => ({
|
||||
trusted: true,
|
||||
matchesDecryptionKey: true,
|
||||
})),
|
||||
}));
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token", {
|
||||
encryption: true,
|
||||
});
|
||||
vi.spyOn(client, "doRequest").mockImplementation(async (method, endpoint) => {
|
||||
if (method === "GET" && endpoint.includes("/room_keys/version")) {
|
||||
return { version: "21869" };
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const result = await client.resetRoomKeyBackup({ rotateRecoveryKey: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(bootstrapSecretStorage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
setupNewKeyBackup: true,
|
||||
setupNewSecretStorage: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("reloads the new backup decryption key after reset when the old cached key mismatches", async () => {
|
||||
const checkKeyBackupAndEnable = vi.fn(async () => {});
|
||||
const bootstrapSecretStorage = vi.fn(async () => {});
|
||||
|
||||
@@ -83,6 +83,7 @@ export type MatrixOwnDeviceVerificationStatus = {
|
||||
recoveryKeyId: string | null;
|
||||
backupVersion: string | null;
|
||||
backup: MatrixRoomKeyBackupStatus;
|
||||
serverDeviceKnown: boolean | null;
|
||||
};
|
||||
|
||||
export type MatrixDeviceVerificationStatus = {
|
||||
@@ -183,6 +184,10 @@ export type MatrixOwnDeviceInfo = {
|
||||
current: boolean;
|
||||
};
|
||||
|
||||
export type MatrixRoomKeyBackupResetOptions = {
|
||||
rotateRecoveryKey?: boolean;
|
||||
};
|
||||
|
||||
export type MatrixOwnDeviceDeleteResult = {
|
||||
currentDeviceId: string | null;
|
||||
deletedDeviceIds: string[];
|
||||
@@ -1122,8 +1127,14 @@ export class MatrixClient {
|
||||
const recoveryKey = this.recoveryKeyStore.getRecoveryKeySummary();
|
||||
const userId = this.client.getUserId() ?? this.selfUserId ?? null;
|
||||
const deviceId = this.client.getDeviceId()?.trim() || null;
|
||||
const backup = await this.getRoomKeyBackupStatus();
|
||||
const deviceVerification = await this.getDeviceVerificationStatus(userId, deviceId);
|
||||
const [backup, deviceVerification, ownDevices] = await Promise.all([
|
||||
this.getRoomKeyBackupStatus(),
|
||||
this.getDeviceVerificationStatus(userId, deviceId),
|
||||
this.listOwnDevices().catch(() => null),
|
||||
]);
|
||||
const serverDeviceKnown = deviceId
|
||||
? (ownDevices?.some((device) => device.deviceId === deviceId) ?? null)
|
||||
: null;
|
||||
|
||||
return {
|
||||
...deviceVerification,
|
||||
@@ -1133,6 +1144,7 @@ export class MatrixClient {
|
||||
recoveryKeyId: recoveryKey?.keyId ?? null,
|
||||
backupVersion: backup.serverVersion,
|
||||
backup,
|
||||
serverDeviceKnown,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1263,9 +1275,13 @@ export class MatrixClient {
|
||||
!stagedRecoveryKeyConfirmedBySecretStorage &&
|
||||
!backupUsableBeforeStagedRecovery &&
|
||||
backupUsable;
|
||||
const storedRecoveryKeyMatches =
|
||||
this.recoveryKeyStore.getRecoveryKeySummary()?.encodedPrivateKey?.trim() ===
|
||||
trimmedRecoveryKey;
|
||||
const stagedRecoveryKeyValidated =
|
||||
stagedRecoveryKeyUsed &&
|
||||
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup);
|
||||
(stagedRecoveryKeyUsed &&
|
||||
(stagedRecoveryKeyConfirmedBySecretStorage || stagedRecoveryKeyUnlockedBackup)) ||
|
||||
(storedRecoveryKeyMatches && backupUsable);
|
||||
const recoveryKeyAccepted = stagedRecoveryKeyValidated && (status.verified || backupUsable);
|
||||
if (!status.verified) {
|
||||
if (backupUsable && stagedRecoveryKeyValidated) {
|
||||
@@ -1405,7 +1421,9 @@ export class MatrixClient {
|
||||
}
|
||||
}
|
||||
|
||||
async resetRoomKeyBackup(): Promise<MatrixRoomKeyBackupResetResult> {
|
||||
async resetRoomKeyBackup(
|
||||
options: MatrixRoomKeyBackupResetOptions = {},
|
||||
): Promise<MatrixRoomKeyBackupResetResult> {
|
||||
let previousVersion: string | null = null;
|
||||
let deletedVersion: string | null = null;
|
||||
const fail = async (error: string): Promise<MatrixRoomKeyBackupResetResult> => {
|
||||
@@ -1436,7 +1454,8 @@ export class MatrixClient {
|
||||
// focused on durable secret-storage health instead of the broader backup status flow,
|
||||
// and still catches stale SSSS/recovery-key state even when the server backup is gone.
|
||||
const forceNewSecretStorage =
|
||||
await this.shouldForceSecretStorageRecreationForBackupReset(crypto);
|
||||
options.rotateRecoveryKey === true ||
|
||||
(await this.shouldForceSecretStorageRecreationForBackupReset(crypto));
|
||||
|
||||
try {
|
||||
if (previousVersion) {
|
||||
@@ -1458,6 +1477,7 @@ export class MatrixClient {
|
||||
// Force SSSS recreation when the existing SSSS key is broken (bad MAC), so
|
||||
// the new backup key is written into a fresh SSSS consistent with recovery_key.json.
|
||||
forceNewSecretStorage,
|
||||
forceNewRecoveryKey: options.rotateRecoveryKey === true,
|
||||
// Also allow recreation if bootstrapSecretStorage itself surfaces a repairable
|
||||
// error (e.g. bad MAC from a different SSSS entry).
|
||||
allowSecretStorageRecreateWithoutRecoveryKey: true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ensureMatrixCryptoRuntime } from "../deps.js";
|
||||
import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js";
|
||||
import type { EncryptedFile } from "./types.js";
|
||||
import type {
|
||||
@@ -69,10 +70,19 @@ let matrixCryptoNodeRuntimePromise: Promise<MatrixCryptoNodeRuntime> | null = nu
|
||||
|
||||
async function loadMatrixCryptoNodeRuntime(): Promise<MatrixCryptoNodeRuntime> {
|
||||
// Keep the native crypto package out of the main CLI startup graph.
|
||||
matrixCryptoNodeRuntimePromise ??= import("./crypto-node.runtime.js");
|
||||
matrixCryptoNodeRuntimePromise ??= import("./crypto-node.runtime.js").catch((error: unknown) => {
|
||||
matrixCryptoNodeRuntimePromise = null;
|
||||
throw error;
|
||||
});
|
||||
return await matrixCryptoNodeRuntimePromise;
|
||||
}
|
||||
|
||||
async function loadMatrixCryptoNodeBindings() {
|
||||
await ensureMatrixCryptoRuntime();
|
||||
const runtime = await loadMatrixCryptoNodeRuntime();
|
||||
return runtime.loadMatrixCryptoNodeBindings();
|
||||
}
|
||||
|
||||
function trackInProgressToDeviceVerifications(deps: {
|
||||
client: MatrixCryptoFacadeClient;
|
||||
verificationManager: MatrixVerificationManager;
|
||||
@@ -133,7 +143,7 @@ export function createMatrixCryptoFacade(deps: {
|
||||
encryptMedia: async (
|
||||
buffer: Buffer,
|
||||
): Promise<{ buffer: Buffer; file: Omit<EncryptedFile, "url"> }> => {
|
||||
const { Attachment } = await loadMatrixCryptoNodeRuntime();
|
||||
const { Attachment } = await loadMatrixCryptoNodeBindings();
|
||||
const encrypted = Attachment.encrypt(new Uint8Array(buffer));
|
||||
const mediaInfoJson = encrypted.mediaEncryptionInfo;
|
||||
if (!mediaInfoJson) {
|
||||
@@ -154,7 +164,7 @@ export function createMatrixCryptoFacade(deps: {
|
||||
file: EncryptedFile,
|
||||
opts?: { maxBytes?: number; readIdleTimeoutMs?: number },
|
||||
): Promise<Buffer> => {
|
||||
const { Attachment, EncryptedAttachment } = await loadMatrixCryptoNodeRuntime();
|
||||
const { Attachment, EncryptedAttachment } = await loadMatrixCryptoNodeBindings();
|
||||
const encrypted = await deps.downloadContent(file.url, opts);
|
||||
const metadata: EncryptedFile = {
|
||||
url: file.url,
|
||||
|
||||
@@ -21,7 +21,9 @@ describe("crypto-node runtime bundling", () => {
|
||||
|
||||
expect(bundled).toContain('from "node:module"');
|
||||
expect(bundled).toContain("createRequire(import.meta.url)");
|
||||
expect(bundled).toMatch(/require\d*\("@matrix-org\/matrix-sdk-crypto-nodejs"\)/);
|
||||
expect(bundled).toMatch(
|
||||
/function loadMatrixCryptoNodeBindings\(\) \{[\s\S]*require\d*\("@matrix-org\/matrix-sdk-crypto-nodejs"\)/,
|
||||
);
|
||||
expect(bundled).not.toContain('from "@matrix-org/matrix-sdk-crypto-nodejs"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,15 @@ import { createRequire } from "node:module";
|
||||
// Load via createRequire so the CJS package gets __dirname (its index.js
|
||||
// uses __dirname to locate platform-specific native .node bindings).
|
||||
const require = createRequire(import.meta.url);
|
||||
const { Attachment, EncryptedAttachment } =
|
||||
require("@matrix-org/matrix-sdk-crypto-nodejs") as typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
type MatrixCryptoNodePackage = typeof import("@matrix-org/matrix-sdk-crypto-nodejs");
|
||||
|
||||
export { Attachment, EncryptedAttachment };
|
||||
export type MatrixCryptoNodeBindings = Pick<
|
||||
MatrixCryptoNodePackage,
|
||||
"Attachment" | "EncryptedAttachment"
|
||||
>;
|
||||
|
||||
export function loadMatrixCryptoNodeBindings(): MatrixCryptoNodeBindings {
|
||||
const { Attachment, EncryptedAttachment } =
|
||||
require("@matrix-org/matrix-sdk-crypto-nodejs") as MatrixCryptoNodePackage;
|
||||
return { Attachment, EncryptedAttachment };
|
||||
}
|
||||
|
||||
@@ -382,4 +382,57 @@ describe("MatrixRecoveryKeyStore", () => {
|
||||
expect(persisted.keyId).toBe("OLD");
|
||||
expect(persisted.encodedPrivateKey).toBe(storedEncoded);
|
||||
});
|
||||
|
||||
it("generates a fresh recovery key when secret storage is explicitly rotated", async () => {
|
||||
const recoveryKeyPath = createTempRecoveryKeyPath();
|
||||
const oldEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
recoveryKeyPath,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
createdAt: "2026-03-12T00:00:00.000Z",
|
||||
keyId: "OLD",
|
||||
encodedPrivateKey: oldEncoded,
|
||||
privateKeyBase64: Buffer.from(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 1)),
|
||||
).toString("base64"),
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const freshEncoded = encodeRecoveryKey(
|
||||
new Uint8Array(Array.from({ length: 32 }, (_, i) => i + 101)),
|
||||
) as string;
|
||||
const bootstrapSecretStorage = createBootstrapSecretStorageMock();
|
||||
const createRecoveryKeyFromPassphrase = vi.fn(async () =>
|
||||
createGeneratedRecoveryKey({
|
||||
keyId: "NEW",
|
||||
name: "Fresh key",
|
||||
bytes: Array.from({ length: 32 }, (_, i) => i + 101),
|
||||
encodedPrivateKey: freshEncoded,
|
||||
}),
|
||||
);
|
||||
const crypto = createRecoveryKeyCrypto({
|
||||
bootstrapSecretStorage,
|
||||
createRecoveryKeyFromPassphrase,
|
||||
status: { ready: true, defaultKeyId: "OLD" },
|
||||
});
|
||||
const store = new MatrixRecoveryKeyStore(recoveryKeyPath);
|
||||
|
||||
await store.bootstrapSecretStorageWithRecoveryKey(crypto, {
|
||||
forceNewRecoveryKey: true,
|
||||
forceNewSecretStorage: true,
|
||||
});
|
||||
|
||||
const persisted = JSON.parse(fs.readFileSync(recoveryKeyPath, "utf8")) as {
|
||||
keyId?: string;
|
||||
encodedPrivateKey?: string;
|
||||
};
|
||||
expect(createRecoveryKeyFromPassphrase).toHaveBeenCalledTimes(1);
|
||||
expect(persisted.keyId).toBe("NEW");
|
||||
expect(persisted.encodedPrivateKey).toBe(freshEncoded);
|
||||
expect(persisted.encodedPrivateKey).not.toBe(oldEncoded);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -228,6 +228,7 @@ export class MatrixRecoveryKeyStore {
|
||||
setupNewKeyBackup?: boolean;
|
||||
allowSecretStorageRecreateWithoutRecoveryKey?: boolean;
|
||||
forceNewSecretStorage?: boolean;
|
||||
forceNewRecoveryKey?: boolean;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
let status: MatrixSecretStorageStatus | null = null;
|
||||
@@ -247,7 +248,8 @@ export class MatrixRecoveryKeyStore {
|
||||
let generatedRecoveryKey = false;
|
||||
const storedRecovery = this.loadStoredRecoveryKey();
|
||||
const stagedRecovery = this.stagedRecoveryKey;
|
||||
const sourceRecovery = stagedRecovery ?? storedRecovery;
|
||||
const sourceRecovery =
|
||||
options.forceNewRecoveryKey === true ? null : (stagedRecovery ?? storedRecovery);
|
||||
let recoveryKey: MatrixGeneratedSecretStorageKey | null = sourceRecovery
|
||||
? {
|
||||
keyInfo: sourceRecovery.keyInfo,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { registerMatrixSubagentHooks } from "../../subagent-hooks-api.js";
|
||||
// Hoisted stubs referenced in vi.mock factories below
|
||||
const bindMock = vi.hoisted(() => vi.fn());
|
||||
const unbindMock = vi.hoisted(() => vi.fn());
|
||||
const getCapabilitiesMock = vi.hoisted(() => vi.fn());
|
||||
const getManagerMock = vi.hoisted(() => vi.fn());
|
||||
const listAllBindingsMock = vi.hoisted(() => vi.fn((): any[] => []));
|
||||
const listBindingsForAccountMock = vi.hoisted(() => vi.fn((): any[] => []));
|
||||
@@ -17,7 +18,11 @@ const resolveMatrixBaseConfigMock = vi.hoisted(() => vi.fn((): any => ({})));
|
||||
const findMatrixAccountConfigMock = vi.hoisted(() => vi.fn((): any => undefined));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-binding-runtime", () => ({
|
||||
getSessionBindingService: () => ({ bind: bindMock, unbind: unbindMock }),
|
||||
getSessionBindingService: () => ({
|
||||
bind: bindMock,
|
||||
getCapabilities: getCapabilitiesMock,
|
||||
unbind: unbindMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./account-config.js", () => ({
|
||||
@@ -81,6 +86,7 @@ function makeSpawnEvent(
|
||||
describe("handleMatrixSubagentSpawning", () => {
|
||||
beforeEach(() => {
|
||||
bindMock.mockReset();
|
||||
getCapabilitiesMock.mockReset();
|
||||
getManagerMock.mockReset();
|
||||
resolveMatrixBaseConfigMock.mockReset();
|
||||
findMatrixAccountConfigMock.mockReset();
|
||||
@@ -89,7 +95,12 @@ describe("handleMatrixSubagentSpawning", () => {
|
||||
threadBindings: { enabled: true, spawnSubagentSessions: true },
|
||||
});
|
||||
findMatrixAccountConfigMock.mockReturnValue(undefined);
|
||||
// Default: manager exists
|
||||
getCapabilitiesMock.mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
unbindSupported: true,
|
||||
});
|
||||
getManagerMock.mockReturnValue({ persist: vi.fn() });
|
||||
// Default: bind resolves ok
|
||||
bindMock.mockResolvedValue({
|
||||
@@ -188,15 +199,21 @@ describe("handleMatrixSubagentSpawning", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns error when no binding manager is available for the account", async () => {
|
||||
getManagerMock.mockReturnValue(null);
|
||||
it("returns error when no binding adapter is available for the account", async () => {
|
||||
getCapabilitiesMock.mockReturnValue({
|
||||
adapterAvailable: false,
|
||||
bindSupported: false,
|
||||
placements: [],
|
||||
unbindSupported: false,
|
||||
});
|
||||
const result = await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent());
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
status: "error",
|
||||
error: expect.stringContaining("No Matrix thread binding manager"),
|
||||
error: expect.stringContaining("No Matrix session binding adapter"),
|
||||
}),
|
||||
);
|
||||
expect(bindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls bind with the resolved room id and returns ok", async () => {
|
||||
@@ -255,7 +272,10 @@ describe("handleMatrixSubagentSpawning", () => {
|
||||
},
|
||||
});
|
||||
await handleMatrixSubagentSpawning(fakeApi, makeSpawnEvent({ accountId: undefined as never }));
|
||||
expect(getManagerMock).toHaveBeenCalledWith("default");
|
||||
expect(getCapabilitiesMock).toHaveBeenCalledWith({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(bindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
conversation: expect.objectContaining({ accountId: "default" }),
|
||||
@@ -295,6 +315,7 @@ describe("handleMatrixSubagentSpawning", () => {
|
||||
describe("matrix subagent hook registration", () => {
|
||||
beforeEach(() => {
|
||||
bindMock.mockReset();
|
||||
getCapabilitiesMock.mockReset();
|
||||
getManagerMock.mockReset();
|
||||
resolveMatrixBaseConfigMock.mockReset();
|
||||
findMatrixAccountConfigMock.mockReset();
|
||||
@@ -304,6 +325,12 @@ describe("matrix subagent hook registration", () => {
|
||||
threadBindings: { enabled: true, spawnSubagentSessions: true },
|
||||
});
|
||||
findMatrixAccountConfigMock.mockReturnValue(undefined);
|
||||
getCapabilitiesMock.mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
unbindSupported: true,
|
||||
});
|
||||
getManagerMock.mockReturnValue({ persist: vi.fn() });
|
||||
bindMock.mockResolvedValue({
|
||||
conversation: {
|
||||
@@ -752,6 +779,7 @@ describe("concurrent spawns across accounts", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
bindMock.mockReset();
|
||||
getCapabilitiesMock.mockReset();
|
||||
getManagerMock.mockReset();
|
||||
resolveMatrixBaseConfigMock.mockReset();
|
||||
findMatrixAccountConfigMock.mockReset();
|
||||
@@ -759,6 +787,12 @@ describe("concurrent spawns across accounts", () => {
|
||||
threadBindings: { enabled: true, spawnSubagentSessions: true },
|
||||
});
|
||||
findMatrixAccountConfigMock.mockReturnValue(undefined);
|
||||
getCapabilitiesMock.mockReturnValue({
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
placements: ["current", "child"],
|
||||
unbindSupported: true,
|
||||
});
|
||||
getManagerMock.mockReturnValue({ persist: vi.fn() });
|
||||
});
|
||||
|
||||
|
||||
@@ -167,14 +167,18 @@ export async function handleMatrixSubagentSpawning(
|
||||
};
|
||||
}
|
||||
|
||||
// Verify the thread binding manager is running for this account. The manager
|
||||
// holds the captured Matrix client the SessionBindingAdapter needs to send
|
||||
// the intro message that bootstraps the thread.
|
||||
const manager = getMatrixThreadBindingManager(accountId);
|
||||
if (!manager) {
|
||||
const bindingService = getSessionBindingService();
|
||||
const capabilities = bindingService.getCapabilities({ channel: "matrix", accountId });
|
||||
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
|
||||
return {
|
||||
status: "error",
|
||||
error: `No Matrix thread binding manager available for account "${accountId}". Is the Matrix channel running?`,
|
||||
error: `No Matrix session binding adapter available for account "${accountId}". Is the Matrix channel running?`,
|
||||
};
|
||||
}
|
||||
if (!capabilities.placements.includes("child")) {
|
||||
return {
|
||||
status: "error",
|
||||
error: `Matrix session binding adapter for account "${accountId}" does not support child thread bindings.`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ export async function handleMatrixSubagentSpawning(
|
||||
//
|
||||
// We do NOT call setBindingRecord here — the adapter's bind() handles
|
||||
// record creation, thread creation, and persistence atomically.
|
||||
const binding = await getSessionBindingService().bind({
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: event.childSessionKey,
|
||||
targetKind: "subagent",
|
||||
conversation: {
|
||||
|
||||
@@ -73,7 +73,7 @@ const THREAD_SUBAGENT_TOOL_ERROR =
|
||||
"thread=true requested but thread delivery is unavailable in this test harness.";
|
||||
|
||||
function threadSubagentTask(token: string) {
|
||||
return `Reply exactly \`${token}\`. This is the marker.`;
|
||||
return `Finish with exactly ${token}.`;
|
||||
}
|
||||
|
||||
function explicitSessionsSpawnPrompt(token: string) {
|
||||
@@ -707,7 +707,7 @@ describe("qa mock openai server", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces sessions_spawn tool errors instead of echoing child-task markers", async () => {
|
||||
it("surfaces sessions_spawn tool errors instead of echoing child-task tokens", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
const body = await expectResponsesJson<{
|
||||
@@ -743,6 +743,61 @@ describe("qa mock openai server", () => {
|
||||
expect(text).not.toContain(THREAD_SUBAGENT_CHILD_ERROR_TOKEN);
|
||||
});
|
||||
|
||||
it("does not echo child-task tokens after sessions_spawn accepts the request", async () => {
|
||||
const server = await startMockServer();
|
||||
const childToken = "QA_SUBAGENT_CHILD_ACCEPTED";
|
||||
|
||||
const body = await expectResponsesJson<{
|
||||
output?: Array<{ content?: Array<{ text?: string }> }>;
|
||||
}>(server, {
|
||||
stream: false,
|
||||
tools: [SESSIONS_SPAWN_TOOL],
|
||||
input: [
|
||||
makeUserInput(explicitSessionsSpawnPrompt(childToken)),
|
||||
{
|
||||
type: "function_call",
|
||||
name: "sessions_spawn",
|
||||
arguments: JSON.stringify({
|
||||
task: threadSubagentTask(childToken),
|
||||
label: "qa-thread-subagent",
|
||||
thread: true,
|
||||
mode: "session",
|
||||
runTimeoutSeconds: 30,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "function_call_output",
|
||||
output: JSON.stringify({
|
||||
status: "accepted",
|
||||
threadRootEventId: "$thread-root",
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const text = body.output?.[0]?.content?.[0]?.text ?? "";
|
||||
expect(text).toContain("Protocol note");
|
||||
expect(text).not.toContain(childToken);
|
||||
});
|
||||
|
||||
it("lets child subagent prompts finish with an exact token", async () => {
|
||||
const server = await startMockServer();
|
||||
const childToken = "QA_SUBAGENT_CHILD_DIRECT";
|
||||
|
||||
await expect(
|
||||
expectResponsesJson<{ output?: Array<{ content?: Array<{ text?: string }> }> }>(server, {
|
||||
stream: false,
|
||||
input: [makeUserInput(threadSubagentTask(childToken))],
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
output: [
|
||||
{
|
||||
content: [{ text: childToken }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("plans memory tools and serves mock image generations", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
@@ -1445,6 +1500,96 @@ describe("qa mock openai server", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("recognizes OpenAI-compatible image_url parts as image inputs", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
model: "mock-openai/gpt-5.4",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Image understanding check: what do you see?" },
|
||||
{
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: `data:image/png;base64,${QA_IMAGE_PNG_BASE64}`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await response.json()) as {
|
||||
output?: Array<{ content?: Array<{ text?: string }> }>;
|
||||
};
|
||||
const text = payload.output?.[0]?.content?.[0]?.text ?? "";
|
||||
expect(text.toLowerCase()).toContain("red");
|
||||
expect(text.toLowerCase()).toContain("blue");
|
||||
|
||||
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
|
||||
expect(debug.status).toBe(200);
|
||||
expect(await debug.json()).toMatchObject({
|
||||
imageInputCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("handles deeply nested image input shapes without recursive traversal failure", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
});
|
||||
|
||||
let content: unknown = {
|
||||
type: "input_image",
|
||||
source: {
|
||||
type: "base64",
|
||||
mime_type: "image/png",
|
||||
data: QA_IMAGE_PNG_BASE64,
|
||||
},
|
||||
};
|
||||
for (let index = 0; index < 4_000; index += 1) {
|
||||
content = [{ type: "input_text", text: "nested" }, content];
|
||||
}
|
||||
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
stream: false,
|
||||
model: "mock-openai/gpt-5.4",
|
||||
input: [
|
||||
{
|
||||
role: "user",
|
||||
content,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const debug = await fetch(`${server.baseUrl}/debug/last-request`);
|
||||
expect(debug.status).toBe(200);
|
||||
expect(await debug.json()).toMatchObject({
|
||||
imageInputCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("describes reattached generated images in the roundtrip flow", async () => {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
|
||||
@@ -338,21 +338,33 @@ function extractAllRequestTexts(input: ResponsesInputItem[], body: Record<string
|
||||
return texts.join("\n");
|
||||
}
|
||||
|
||||
function countImageInputs(input: ResponsesInputItem[]) {
|
||||
function countImageInputs(value: unknown): number {
|
||||
const seen = new WeakSet<object>();
|
||||
const stack = [value];
|
||||
let count = 0;
|
||||
for (const item of input) {
|
||||
if (!Array.isArray(item.content)) {
|
||||
let visited = 0;
|
||||
while (stack.length > 0 && visited < 50_000) {
|
||||
visited += 1;
|
||||
const current = stack.pop();
|
||||
if (Array.isArray(current)) {
|
||||
for (const entry of current) {
|
||||
stack.push(entry);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (const entry of item.content) {
|
||||
if (
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
(entry as { type?: unknown }).type === "input_image"
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
if (!current || typeof current !== "object") {
|
||||
continue;
|
||||
}
|
||||
if (seen.has(current)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(current);
|
||||
const record = current as Record<string, unknown>;
|
||||
const type = typeof record.type === "string" ? record.type : "";
|
||||
if (type === "input_image" || type === "image" || type === "image_url" || type === "media") {
|
||||
count += 1;
|
||||
}
|
||||
stack.push(record.content, record.image_url, record.source);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
@@ -522,6 +534,14 @@ function extractExactReplyDirective(text: string) {
|
||||
return extractLastCapture(text, /reply(?: with)? exactly:\s*([^\n]+)/i);
|
||||
}
|
||||
|
||||
function extractFinishExactlyDirective(text: string) {
|
||||
const backtickedMatch = extractLastCapture(text, /finish with exactly\s+`([^`]+)`/i);
|
||||
if (backtickedMatch) {
|
||||
return backtickedMatch;
|
||||
}
|
||||
return extractLastCapture(text, /finish with exactly\s+([^\s`.,;:!?]+)/i);
|
||||
}
|
||||
|
||||
function extractExactMarkerDirective(text: string) {
|
||||
const backtickedMatch = extractLastCapture(text, /exact marker:\s*`([^`]+)`/i);
|
||||
if (backtickedMatch) {
|
||||
@@ -648,6 +668,8 @@ function buildAssistantText(
|
||||
const mediaPath = /MEDIA:([^\n]+)/.exec(toolOutput)?.[1]?.trim();
|
||||
const exactReplyDirective =
|
||||
extractExactReplyDirective(prompt) ?? extractExactReplyDirective(allInputText);
|
||||
const finishExactlyDirective =
|
||||
extractFinishExactlyDirective(prompt) ?? extractFinishExactlyDirective(allInputText);
|
||||
const exactMarkerDirective =
|
||||
extractExactMarkerDirective(prompt) ?? extractExactMarkerDirective(allInputText);
|
||||
const imageInputCount = countImageInputs(input);
|
||||
@@ -811,6 +833,9 @@ function buildAssistantText(
|
||||
const snippet = toolOutput.replace(/\s+/g, " ").trim().slice(0, 220);
|
||||
return `Protocol note: I reviewed the requested material. Evidence snippet: ${snippet || "no content"}`;
|
||||
}
|
||||
if (finishExactlyDirective) {
|
||||
return finishExactlyDirective;
|
||||
}
|
||||
if (prompt) {
|
||||
return `Protocol note: acknowledged. Continue with the QA scenario plan and report worked, failed, and blocked items.`;
|
||||
}
|
||||
|
||||
@@ -751,6 +751,7 @@ export async function runMatrixQaLive(params: {
|
||||
observerDeviceId: provisioning.observer.deviceId,
|
||||
observerPassword: provisioning.observer.password,
|
||||
observerUserId: provisioning.observer.userId,
|
||||
gatewayRuntimeEnv: scenarioGateway.harness.gateway.runtimeEnv,
|
||||
gatewayStateDir: scenarioGateway.harness.gateway.runtimeEnv?.OPENCLAW_STATE_DIR,
|
||||
outputDir,
|
||||
restartGateway: async () => {
|
||||
|
||||
@@ -60,6 +60,17 @@ export type MatrixQaScenarioId =
|
||||
| "matrix-e2ee-recovery-key-lifecycle"
|
||||
| "matrix-e2ee-recovery-owner-verification-required"
|
||||
| "matrix-e2ee-cli-self-verification"
|
||||
| "matrix-e2ee-state-loss-external-recovery-key"
|
||||
| "matrix-e2ee-state-loss-stored-recovery-key"
|
||||
| "matrix-e2ee-state-loss-no-recovery-key"
|
||||
| "matrix-e2ee-stale-recovery-key-after-backup-reset"
|
||||
| "matrix-e2ee-server-backup-deleted-local-state-intact"
|
||||
| "matrix-e2ee-server-backup-deleted-local-reupload-restores"
|
||||
| "matrix-e2ee-corrupt-crypto-idb-snapshot"
|
||||
| "matrix-e2ee-server-device-deleted-local-state-intact"
|
||||
| "matrix-e2ee-sync-state-loss-crypto-intact"
|
||||
| "matrix-e2ee-wrong-account-recovery-key"
|
||||
| "matrix-e2ee-history-exists-backup-empty"
|
||||
| "matrix-e2ee-device-sas-verification"
|
||||
| "matrix-e2ee-qr-verification"
|
||||
| "matrix-e2ee-stale-device-hygiene"
|
||||
@@ -258,7 +269,7 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
groupsByKey: {
|
||||
[MATRIX_QA_MAIN_ROOM_KEY]: {
|
||||
tools: {
|
||||
allow: ["sessions_spawn"],
|
||||
allow: ["sessions_spawn", "sessions_yield"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -589,6 +600,106 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-state-loss-external-recovery-key",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE total state loss restores backup with an external recovery key",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-state-loss-external-recovery-key",
|
||||
name: "Matrix QA E2EE State Loss External Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-state-loss-stored-recovery-key",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE crypto state loss restores backup from a surviving recovery key",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-state-loss-stored-recovery-key",
|
||||
name: "Matrix QA E2EE State Loss Stored Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-state-loss-no-recovery-key",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE total state loss without a recovery key fails closed",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-state-loss-no-recovery-key",
|
||||
name: "Matrix QA E2EE State Loss No Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-stale-recovery-key-after-backup-reset",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE stale recovery key is rejected after server backup reset",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-stale-recovery-key-after-backup-reset",
|
||||
name: "Matrix QA E2EE Stale Recovery Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-server-backup-deleted-local-state-intact",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE local crypto survives server backup deletion",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-server-backup-deleted-local-state-intact",
|
||||
name: "Matrix QA E2EE Server Backup Deleted Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-server-backup-deleted-local-reupload-restores",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE local keys re-upload after server backup deletion",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-server-backup-deleted-local-reupload-restores",
|
||||
name: "Matrix QA E2EE Server Backup Reupload Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-corrupt-crypto-idb-snapshot",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE corrupt crypto snapshot repairs through backup restore",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-corrupt-crypto-idb-snapshot",
|
||||
name: "Matrix QA E2EE Corrupt IDB Snapshot Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-server-device-deleted-local-state-intact",
|
||||
timeoutMs: 120_000,
|
||||
title: "Matrix E2EE server-side device deletion invalidates surviving local state",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-server-device-deleted-local-state-intact",
|
||||
name: "Matrix QA E2EE Server Device Deleted Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-sync-state-loss-crypto-intact",
|
||||
timeoutMs: MATRIX_QA_E2EE_REPLY_TIMEOUT_MS,
|
||||
title: "Matrix E2EE sync cursor loss keeps crypto decryptability intact",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-sync-state-loss-crypto-intact",
|
||||
name: "Matrix QA E2EE Sync State Loss Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-history-exists-backup-empty",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE backup reset preserves encrypted history via local key re-upload",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-history-exists-backup-empty",
|
||||
name: "Matrix QA E2EE Empty Backup Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-device-sas-verification",
|
||||
timeoutMs: 90_000,
|
||||
@@ -676,6 +787,16 @@ export const MATRIX_QA_SCENARIOS: MatrixQaScenarioDefinition[] = [
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
{
|
||||
id: "matrix-e2ee-wrong-account-recovery-key",
|
||||
timeoutMs: 180_000,
|
||||
title: "Matrix E2EE rejects a recovery key from a different account",
|
||||
topology: buildMatrixQaE2eeScenarioTopology({
|
||||
scenarioId: "matrix-e2ee-wrong-account-recovery-key",
|
||||
name: "Matrix QA E2EE Wrong Account Key Room",
|
||||
}),
|
||||
configOverrides: MATRIX_QA_E2EE_CONFIG,
|
||||
},
|
||||
];
|
||||
|
||||
export const MATRIX_QA_STANDARD_SCENARIO_IDS = collectLiveTransportStandardScenarioCoverage({
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatMatrixQaCliCommand,
|
||||
redactMatrixQaCliOutput,
|
||||
resolveMatrixQaOpenClawCliEntryPath,
|
||||
runMatrixQaOpenClawCli,
|
||||
startMatrixQaOpenClawCli,
|
||||
} from "./scenario-runtime-cli.js";
|
||||
|
||||
describe("Matrix QA CLI runtime", () => {
|
||||
@@ -23,6 +25,9 @@ describe("Matrix QA CLI runtime", () => {
|
||||
expect(formatMatrixQaCliCommand(["matrix", "account", "add", "--access-token=token-123"])).toBe(
|
||||
"openclaw matrix account add --access-token=[REDACTED]",
|
||||
);
|
||||
expect(
|
||||
formatMatrixQaCliCommand(["matrix", "verify", "device", "abcdef1234567890ghij", "--json"]),
|
||||
).toBe("openclaw matrix verify device [REDACTED] --json");
|
||||
});
|
||||
|
||||
it("redacts Matrix token output before diagnostics and artifacts", () => {
|
||||
@@ -32,7 +37,7 @@ describe("Matrix QA CLI runtime", () => {
|
||||
});
|
||||
|
||||
it("prefers the ESM OpenClaw CLI entrypoint when present", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "matrix-qa-cli-entry-"));
|
||||
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-entry-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(path.join(root, "dist", "index.mjs"), "");
|
||||
@@ -41,4 +46,98 @@ describe("Matrix QA CLI runtime", () => {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can preserve expected non-zero CLI output for negative scenarios", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-nonzero-"),
|
||||
);
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"process.stdout.write(JSON.stringify({ success: false, error: 'expected failure' }));",
|
||||
"process.exit(7);",
|
||||
].join("\n"),
|
||||
);
|
||||
const result = await runMatrixQaOpenClawCli({
|
||||
allowNonZero: true,
|
||||
args: ["matrix", "verify", "backup", "restore", "--json"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(result.exitCode).toBe(7);
|
||||
expect(result.stdout).toContain('"success":false');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can pass stdin to CLI commands", async () => {
|
||||
const root = await mkdtemp(path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-stdin-"));
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"let input = '';",
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk; });",
|
||||
"process.stdin.on('end', () => {",
|
||||
" process.stdout.write(JSON.stringify({ input: input.trim() }));",
|
||||
"});",
|
||||
].join("\n"),
|
||||
);
|
||||
const result = await runMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "backup", "restore", "--recovery-key-stdin", "--json"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
stdin: "stdin-recovery-key\n",
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
expect(result.stdout).toContain('"input":"stdin-recovery-key"');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("can close stdin after interactive CLI prompts", async () => {
|
||||
const root = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "matrix-qa-cli-interactive-"),
|
||||
);
|
||||
try {
|
||||
await mkdir(path.join(root, "dist"));
|
||||
await writeFile(
|
||||
path.join(root, "dist", "index.mjs"),
|
||||
[
|
||||
"let input = '';",
|
||||
"process.stdin.setEncoding('utf8');",
|
||||
"process.stdin.on('data', (chunk) => { input += chunk; process.stdout.write('prompt answered\\n'); });",
|
||||
"process.stdin.on('end', () => {",
|
||||
" process.stdout.write(JSON.stringify({ input: input.trim(), ended: true }));",
|
||||
"});",
|
||||
].join("\n"),
|
||||
);
|
||||
const session = startMatrixQaOpenClawCli({
|
||||
args: ["matrix", "verify", "self"],
|
||||
cwd: root,
|
||||
env: process.env,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
await session.writeStdin("yes\n");
|
||||
await session.waitForOutput(
|
||||
(output) => output.text.includes("prompt answered"),
|
||||
"interactive prompt acknowledgement",
|
||||
5_000,
|
||||
);
|
||||
session.endStdin();
|
||||
const result = await session.wait();
|
||||
|
||||
expect(result.stdout).toContain('"input":"yes"');
|
||||
expect(result.stdout).toContain('"ended":true');
|
||||
} finally {
|
||||
await rm(root, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { spawn as startOpenClawCliProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
|
||||
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
|
||||
|
||||
export type MatrixQaCliRunResult = {
|
||||
args: string[];
|
||||
@@ -14,6 +17,7 @@ export type MatrixQaCliRunResult = {
|
||||
|
||||
export type MatrixQaCliSession = {
|
||||
args: string[];
|
||||
endStdin: () => void;
|
||||
output: () => { stderr: string; stdout: string };
|
||||
wait: () => Promise<MatrixQaCliRunResult>;
|
||||
waitForOutput: (
|
||||
@@ -27,6 +31,10 @@ export type MatrixQaCliSession = {
|
||||
|
||||
const MATRIX_QA_CLI_SECRET_ARG_FLAGS = new Set(["--access-token", "--password", "--recovery-key"]);
|
||||
|
||||
function isMatrixQaCliSecretPositionalArg(args: string[], index: number): boolean {
|
||||
return args[0] === "matrix" && args[1] === "verify" && args[2] === "device" && index === 3;
|
||||
}
|
||||
|
||||
function redactMatrixQaCliArgs(args: string[]): string[] {
|
||||
return args.map((arg, index) => {
|
||||
const [flag] = arg.split("=", 1);
|
||||
@@ -37,6 +45,9 @@ function redactMatrixQaCliArgs(args: string[]): string[] {
|
||||
if (previous && MATRIX_QA_CLI_SECRET_ARG_FLAGS.has(previous)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
if (isMatrixQaCliSecretPositionalArg(args, index)) {
|
||||
return "[REDACTED]";
|
||||
}
|
||||
return arg;
|
||||
});
|
||||
}
|
||||
@@ -81,9 +92,11 @@ function formatMatrixQaCliExitError(result: MatrixQaCliRunResult) {
|
||||
}
|
||||
|
||||
export function startMatrixQaOpenClawCli(params: {
|
||||
allowNonZero?: boolean;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
timeoutMs: number;
|
||||
}): MatrixQaCliSession {
|
||||
const cwd = params.cwd ?? process.cwd();
|
||||
@@ -139,6 +152,9 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
|
||||
child.stdout.on("data", (chunk) => stdout.push(Buffer.from(chunk)));
|
||||
child.stderr.on("data", (chunk) => stderr.push(Buffer.from(chunk)));
|
||||
if (params.stdin !== undefined) {
|
||||
child.stdin.end(params.stdin);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
finish(
|
||||
@@ -157,7 +173,7 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
exitCode: exitCode ?? 1,
|
||||
output: readOutput(),
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
if (result.exitCode !== 0 && params.allowNonZero !== true) {
|
||||
finish(result, new Error(formatMatrixQaCliExitError(result)));
|
||||
return;
|
||||
}
|
||||
@@ -166,11 +182,16 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
|
||||
return {
|
||||
args: params.args,
|
||||
endStdin: () => {
|
||||
if (!child.stdin.destroyed) {
|
||||
child.stdin.end();
|
||||
}
|
||||
},
|
||||
output: readOutput,
|
||||
wait: async () =>
|
||||
await new Promise<MatrixQaCliRunResult>((resolve, reject) => {
|
||||
if (closed && closeResult) {
|
||||
if (closeResult.exitCode === 0) {
|
||||
if (closeResult.exitCode === 0 || params.allowNonZero === true) {
|
||||
resolve(closeResult);
|
||||
} else {
|
||||
reject(new Error(formatMatrixQaCliExitError(closeResult)));
|
||||
@@ -215,10 +236,124 @@ export function startMatrixQaOpenClawCli(params: {
|
||||
}
|
||||
|
||||
export async function runMatrixQaOpenClawCli(params: {
|
||||
allowNonZero?: boolean;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
stdin?: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<MatrixQaCliRunResult> {
|
||||
return await startMatrixQaOpenClawCli(params).wait();
|
||||
}
|
||||
|
||||
async function assertMatrixQaPrivatePathMode(pathToCheck: string, label: string) {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const mode = (await stat(pathToCheck)).mode & 0o777;
|
||||
if ((mode & 0o077) !== 0) {
|
||||
throw new Error(`${label} permissions are too broad: ${mode.toString(8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMatrixQaOpenClawCliRuntime(params: {
|
||||
accountId: string;
|
||||
accessToken: string;
|
||||
artifactLabel: string;
|
||||
baseUrl: string;
|
||||
deviceId: string;
|
||||
displayName: string;
|
||||
outputDir: string;
|
||||
runtimeEnv: NodeJS.ProcessEnv;
|
||||
userId: string;
|
||||
}) {
|
||||
const rootDir = await mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-matrix-cli-qa-"),
|
||||
);
|
||||
const artifactDir = path.join(
|
||||
params.outputDir,
|
||||
params.artifactLabel.replace(/[^A-Za-z0-9_-]/g, "-"),
|
||||
randomUUID().replaceAll("-", "").slice(0, 12),
|
||||
);
|
||||
const stateDir = path.join(rootDir, "state");
|
||||
const configPath = path.join(rootDir, "config.json");
|
||||
await chmod(rootDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(rootDir, "Matrix QA CLI temp directory");
|
||||
await mkdir(artifactDir, { mode: 0o700, recursive: true });
|
||||
await chmod(artifactDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(artifactDir, "Matrix QA CLI artifact directory");
|
||||
await mkdir(stateDir, { mode: 0o700, recursive: true });
|
||||
await chmod(stateDir, 0o700).catch(() => undefined);
|
||||
await assertMatrixQaPrivatePathMode(stateDir, "Matrix QA CLI state directory");
|
||||
await writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
allow: ["matrix"],
|
||||
entries: {
|
||||
matrix: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: params.accountId,
|
||||
accounts: {
|
||||
[params.accountId]: {
|
||||
accessToken: params.accessToken,
|
||||
deviceId: params.deviceId,
|
||||
encryption: true,
|
||||
homeserver: params.baseUrl,
|
||||
initialSyncLimit: 0,
|
||||
name: params.displayName,
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
},
|
||||
startupVerification: "off",
|
||||
userId: params.userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ flag: "wx", mode: 0o600 },
|
||||
);
|
||||
await assertMatrixQaPrivatePathMode(configPath, "Matrix QA CLI config file");
|
||||
const env = {
|
||||
...params.runtimeEnv,
|
||||
FORCE_COLOR: "0",
|
||||
NO_COLOR: "1",
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
return {
|
||||
artifactDir,
|
||||
configPath,
|
||||
dispose: async () => {
|
||||
await rm(rootDir, { force: true, recursive: true });
|
||||
},
|
||||
run: async (
|
||||
args: string[],
|
||||
opts: { allowNonZero?: boolean; stdin?: string; timeoutMs: number },
|
||||
): Promise<MatrixQaCliRunResult> =>
|
||||
await runMatrixQaOpenClawCli({
|
||||
allowNonZero: opts.allowNonZero,
|
||||
args,
|
||||
env,
|
||||
stdin: opts.stdin,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
start: (args: string[], opts: { allowNonZero?: boolean; timeoutMs: number }) =>
|
||||
startMatrixQaOpenClawCli({
|
||||
allowNonZero: opts.allowNonZero,
|
||||
args,
|
||||
env,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
}),
|
||||
stateDir,
|
||||
};
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -140,6 +140,10 @@ function isMatrixQaRepairableBackupBootstrapError(error: string | undefined) {
|
||||
);
|
||||
}
|
||||
|
||||
const MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS = {
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
} as const;
|
||||
|
||||
async function assertMatrixQaPeerDeviceTrusted(params: {
|
||||
client: MatrixQaE2eeScenarioClient;
|
||||
deviceId: string;
|
||||
@@ -159,15 +163,15 @@ async function ensureMatrixQaE2eeOwnDeviceVerified(params: {
|
||||
client: MatrixQaE2eeScenarioClient;
|
||||
label: string;
|
||||
}) {
|
||||
let bootstrap = await params.client.bootstrapOwnDeviceVerification({
|
||||
forceResetCrossSigning: true,
|
||||
});
|
||||
let bootstrap = await params.client.bootstrapOwnDeviceVerification(
|
||||
MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS,
|
||||
);
|
||||
if (!bootstrap.success && isMatrixQaRepairableBackupBootstrapError(bootstrap.error)) {
|
||||
const reset = await params.client.resetRoomKeyBackup();
|
||||
if (reset.success) {
|
||||
bootstrap = await params.client.bootstrapOwnDeviceVerification({
|
||||
forceResetCrossSigning: true,
|
||||
});
|
||||
bootstrap = await params.client.bootstrapOwnDeviceVerification(
|
||||
MATRIX_QA_PRESERVE_IDENTITY_BOOTSTRAP_OPTIONS,
|
||||
);
|
||||
}
|
||||
}
|
||||
assertMatrixQaBootstrapSucceeded(params.label, bootstrap);
|
||||
@@ -413,6 +417,12 @@ async function createMatrixQaCliSelfVerificationRuntime(params: {
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
plugins: {
|
||||
allow: ["matrix"],
|
||||
entries: {
|
||||
matrix: { enabled: true },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: params.accountId,
|
||||
@@ -422,7 +432,7 @@ async function createMatrixQaCliSelfVerificationRuntime(params: {
|
||||
deviceId: params.deviceId,
|
||||
encryption: true,
|
||||
homeserver: params.context.baseUrl,
|
||||
initialSyncLimit: 1,
|
||||
initialSyncLimit: 0,
|
||||
name: "Matrix QA CLI self-verification",
|
||||
network: {
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
@@ -448,10 +458,11 @@ async function createMatrixQaCliSelfVerificationRuntime(params: {
|
||||
OPENCLAW_DISABLE_AUTO_UPDATE: "1",
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
const run = async (args: string[], timeoutMs = params.context.timeoutMs) =>
|
||||
const run = async (args: string[], timeoutMs = params.context.timeoutMs, stdin?: string) =>
|
||||
await runMatrixQaOpenClawCli({
|
||||
args,
|
||||
env,
|
||||
stdin,
|
||||
timeoutMs,
|
||||
});
|
||||
const start = (args: string[], timeoutMs = params.context.timeoutMs) =>
|
||||
@@ -1047,6 +1058,18 @@ export async function runMatrixQaE2eeRecoveryKeyLifecycleScenario(
|
||||
`Matrix E2EE room-key backup reset failed: ${reset.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
const resetRecoveryKey = await recoveryClient.getRecoveryKey();
|
||||
const resetEncodedRecoveryKey = resetRecoveryKey?.encodedPrivateKey?.trim();
|
||||
if (resetEncodedRecoveryKey && resetEncodedRecoveryKey !== encodedRecoveryKey) {
|
||||
const ownerRecovery = await client.verifyWithRecoveryKey(resetEncodedRecoveryKey);
|
||||
if (!ownerRecovery.success) {
|
||||
throw new Error(
|
||||
`Matrix E2EE owner could not refresh recovery key after backup reset: ${
|
||||
ownerRecovery.error ?? "unknown error"
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await recoveryClient.stop();
|
||||
await client.deleteOwnDevices([recoveryDevice.deviceId]).catch(() => undefined);
|
||||
cleanupRecoveryDevice = false;
|
||||
@@ -1205,17 +1228,20 @@ export async function runMatrixQaE2eeCliSelfVerificationScenario(
|
||||
userId: cliDevice.userId,
|
||||
});
|
||||
try {
|
||||
const restoreResult = await cli.run([
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--account",
|
||||
accountId,
|
||||
"--recovery-key",
|
||||
encodedRecoveryKey,
|
||||
"--json",
|
||||
]);
|
||||
const restoreResult = await cli.run(
|
||||
[
|
||||
"matrix",
|
||||
"verify",
|
||||
"backup",
|
||||
"restore",
|
||||
"--account",
|
||||
accountId,
|
||||
"--recovery-key-stdin",
|
||||
"--json",
|
||||
],
|
||||
context.timeoutMs,
|
||||
`${encodedRecoveryKey}\n`,
|
||||
);
|
||||
const restoreArtifacts = await writeMatrixQaCliOutputArtifacts({
|
||||
label: "verify-backup-restore",
|
||||
result: restoreResult,
|
||||
@@ -1288,8 +1314,9 @@ export async function runMatrixQaE2eeCliSelfVerificationScenario(
|
||||
cliSas,
|
||||
owner: ownerSas,
|
||||
});
|
||||
await session.writeStdin("yes\n");
|
||||
await owner.confirmVerificationSas(ownerSas.id);
|
||||
await session.writeStdin("yes\n");
|
||||
session.endStdin();
|
||||
const completedCli = await session.wait();
|
||||
const selfVerificationArtifacts = await writeMatrixQaCliOutputArtifacts({
|
||||
label: "verify-self",
|
||||
|
||||
@@ -76,10 +76,12 @@ function buildMatrixQaThreadArtifacts(result: MatrixQaThreadScenarioResult) {
|
||||
}
|
||||
|
||||
function failIfMatrixSubagentThreadHookError(event: MatrixQaObservedEvent) {
|
||||
if (MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE.test(event.body ?? "")) {
|
||||
throw new Error(
|
||||
`Matrix subagent thread spawn hit missing hook error: ${event.body ?? "<empty>"}`,
|
||||
);
|
||||
const body = event.body ?? "";
|
||||
if (MATRIX_SUBAGENT_THREAD_HOOK_ERROR_RE.test(body)) {
|
||||
throw new Error(`Matrix subagent thread spawn hit missing hook error: ${body || "<empty>"}`);
|
||||
}
|
||||
if (/\bsessions_spawn failed:/i.test(body)) {
|
||||
throw new Error(`Matrix subagent thread spawn failed: ${body || "<empty>"}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,9 +300,9 @@ export async function runSubagentThreadSpawnScenario(context: MatrixQaScenarioCo
|
||||
const childToken = buildMatrixQaToken("MATRIX_QA_SUBAGENT_CHILD");
|
||||
const triggerBody = [
|
||||
`${context.sutUserId} Call sessions_spawn now for this QA check.`,
|
||||
`Use task="Reply exactly \`${childToken}\`. This is the marker."`,
|
||||
`Use task="Finish with exactly ${childToken}."`,
|
||||
"Use label=matrix-thread-subagent thread=true mode=session runTimeoutSeconds=60.",
|
||||
"Do not answer with the marker yourself.",
|
||||
"Do not send the child token from this parent session.",
|
||||
].join(" ");
|
||||
const driverEventId = await client.sendTextMessage({
|
||||
body: triggerBody,
|
||||
|
||||
@@ -8,6 +8,19 @@ import {
|
||||
runDmSharedSessionNoticeScenario,
|
||||
runDmThreadReplyOverrideScenario,
|
||||
} from "./scenario-runtime-dm.js";
|
||||
import {
|
||||
runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario,
|
||||
runMatrixQaE2eeHistoryExistsBackupEmptyScenario,
|
||||
runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario,
|
||||
runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario,
|
||||
runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario,
|
||||
runMatrixQaE2eeStateLossExternalRecoveryKeyScenario,
|
||||
runMatrixQaE2eeStateLossNoRecoveryKeyScenario,
|
||||
runMatrixQaE2eeStateLossStoredRecoveryKeyScenario,
|
||||
runMatrixQaE2eeSyncStateLossCryptoIntactScenario,
|
||||
runMatrixQaE2eeWrongAccountRecoveryKeyScenario,
|
||||
} from "./scenario-runtime-e2ee-destructive.js";
|
||||
import {
|
||||
runMatrixQaE2eeArtifactRedactionScenario,
|
||||
runMatrixQaE2eeBasicReplyScenario,
|
||||
@@ -314,6 +327,28 @@ export async function runMatrixQaScenario(
|
||||
return await runMatrixQaE2eeRecoveryOwnerVerificationRequiredScenario(context);
|
||||
case "matrix-e2ee-cli-self-verification":
|
||||
return await runMatrixQaE2eeCliSelfVerificationScenario(context);
|
||||
case "matrix-e2ee-state-loss-external-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossExternalRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-state-loss-stored-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossStoredRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-state-loss-no-recovery-key":
|
||||
return await runMatrixQaE2eeStateLossNoRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-stale-recovery-key-after-backup-reset":
|
||||
return await runMatrixQaE2eeStaleRecoveryKeyAfterBackupResetScenario(context);
|
||||
case "matrix-e2ee-server-backup-deleted-local-state-intact":
|
||||
return await runMatrixQaE2eeServerBackupDeletedLocalStateIntactScenario(context);
|
||||
case "matrix-e2ee-server-backup-deleted-local-reupload-restores":
|
||||
return await runMatrixQaE2eeServerBackupDeletedLocalReuploadRestoresScenario(context);
|
||||
case "matrix-e2ee-corrupt-crypto-idb-snapshot":
|
||||
return await runMatrixQaE2eeCorruptCryptoIdbSnapshotScenario(context);
|
||||
case "matrix-e2ee-server-device-deleted-local-state-intact":
|
||||
return await runMatrixQaE2eeServerDeviceDeletedLocalStateIntactScenario(context);
|
||||
case "matrix-e2ee-sync-state-loss-crypto-intact":
|
||||
return await runMatrixQaE2eeSyncStateLossCryptoIntactScenario(context);
|
||||
case "matrix-e2ee-wrong-account-recovery-key":
|
||||
return await runMatrixQaE2eeWrongAccountRecoveryKeyScenario(context);
|
||||
case "matrix-e2ee-history-exists-backup-empty":
|
||||
return await runMatrixQaE2eeHistoryExistsBackupEmptyScenario(context);
|
||||
case "matrix-e2ee-device-sas-verification":
|
||||
return await runMatrixQaE2eeDeviceSasVerificationScenario(context);
|
||||
case "matrix-e2ee-qr-verification":
|
||||
|
||||
@@ -96,23 +96,49 @@ export type MatrixQaScenarioArtifacts = {
|
||||
bootstrapErrorPreview?: string;
|
||||
bootstrapSuccess?: boolean;
|
||||
backupCreatedVersion?: string | null;
|
||||
backupDeletedHttpStatus?: number;
|
||||
backupPreviousVersion?: string | null;
|
||||
backupRestored?: boolean;
|
||||
backupReset?: boolean;
|
||||
completedVerificationId?: string;
|
||||
completedVerificationIds?: string[];
|
||||
currentDeviceId?: string | null;
|
||||
accountRoot?: string;
|
||||
corruptedPath?: string;
|
||||
deletedSyncStorePath?: string;
|
||||
deletedDeviceIds?: string[];
|
||||
deletedDeviceId?: string;
|
||||
deletedBackupVersion?: string | null;
|
||||
faultedEndpoint?: string;
|
||||
faultHitCount?: number;
|
||||
faultRuleId?: string;
|
||||
historyEventId?: string;
|
||||
observerRecoveryDeviceId?: string;
|
||||
qrBytes?: number;
|
||||
recoveryDeviceId?: string;
|
||||
recoveryKeyPreserved?: boolean;
|
||||
recoveryKeyId?: string | null;
|
||||
recoveryKeyStored?: boolean;
|
||||
rotatedRecoveryKeyId?: string | null;
|
||||
remainingDeviceIds?: string[];
|
||||
restoreError?: string;
|
||||
restoreErrorAfterDelete?: string;
|
||||
restoreExitCode?: number;
|
||||
restoreImported?: number;
|
||||
restoreTotal?: number;
|
||||
sasEmoji?: string[];
|
||||
secondaryDeviceId?: string;
|
||||
seededEventId?: string;
|
||||
replyEventId?: string;
|
||||
statusError?: string;
|
||||
statusExitCode?: number;
|
||||
serverDeviceKnown?: boolean | null;
|
||||
selfVerificationTransactionId?: string | null;
|
||||
transportInterruption?: string;
|
||||
verificationRoomId?: string;
|
||||
joinedRoomId?: string;
|
||||
localEventId?: string;
|
||||
verificationExitCode?: number;
|
||||
};
|
||||
|
||||
export type MatrixQaScenarioExecution = {
|
||||
|
||||
@@ -169,6 +169,16 @@ describe("matrix live qa scenarios", () => {
|
||||
"matrix-e2ee-recovery-key-lifecycle",
|
||||
"matrix-e2ee-recovery-owner-verification-required",
|
||||
"matrix-e2ee-cli-self-verification",
|
||||
"matrix-e2ee-state-loss-external-recovery-key",
|
||||
"matrix-e2ee-state-loss-stored-recovery-key",
|
||||
"matrix-e2ee-state-loss-no-recovery-key",
|
||||
"matrix-e2ee-stale-recovery-key-after-backup-reset",
|
||||
"matrix-e2ee-server-backup-deleted-local-state-intact",
|
||||
"matrix-e2ee-server-backup-deleted-local-reupload-restores",
|
||||
"matrix-e2ee-corrupt-crypto-idb-snapshot",
|
||||
"matrix-e2ee-server-device-deleted-local-state-intact",
|
||||
"matrix-e2ee-sync-state-loss-crypto-intact",
|
||||
"matrix-e2ee-history-exists-backup-empty",
|
||||
"matrix-e2ee-device-sas-verification",
|
||||
"matrix-e2ee-qr-verification",
|
||||
"matrix-e2ee-stale-device-hygiene",
|
||||
@@ -178,9 +188,29 @@ describe("matrix live qa scenarios", () => {
|
||||
"matrix-e2ee-artifact-redaction",
|
||||
"matrix-e2ee-media-image",
|
||||
"matrix-e2ee-key-bootstrap-failure",
|
||||
"matrix-e2ee-wrong-account-recovery-key",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps account-mutating E2EE negative coverage at the suite tail", () => {
|
||||
const scenarioIds = scenarioTesting.findMatrixQaScenarios().map((scenario) => scenario.id);
|
||||
const destructiveScenarioId = "matrix-e2ee-wrong-account-recovery-key";
|
||||
const destructiveIndex = scenarioIds.indexOf(destructiveScenarioId);
|
||||
|
||||
expect(scenarioIds.at(-1)).toBe(destructiveScenarioId);
|
||||
const protectedScenarioIds = [
|
||||
"matrix-e2ee-state-loss-external-recovery-key",
|
||||
"matrix-e2ee-state-loss-stored-recovery-key",
|
||||
"matrix-e2ee-device-sas-verification",
|
||||
"matrix-e2ee-qr-verification",
|
||||
"matrix-e2ee-dm-sas-verification",
|
||||
"matrix-e2ee-media-image",
|
||||
] satisfies (typeof scenarioIds)[number][];
|
||||
for (const scenarioId of protectedScenarioIds) {
|
||||
expect(destructiveIndex).toBeGreaterThan(scenarioIds.indexOf(scenarioId));
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the repo-wide exact marker prompt shape for Matrix mentions", () => {
|
||||
expect(
|
||||
scenarioTesting.buildMentionPrompt("@sut:matrix-qa.test", "MATRIX_QA_CANARY_TOKEN"),
|
||||
@@ -203,6 +233,17 @@ describe("matrix live qa scenarios", () => {
|
||||
expect(scenarios.get("matrix-e2ee-media-image")?.timeoutMs).toBeGreaterThanOrEqual(180_000);
|
||||
});
|
||||
|
||||
it("keeps the Matrix subagent room policy compatible with leaf child sessions", () => {
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-subagent-thread-spawn",
|
||||
);
|
||||
|
||||
expect(scenario?.configOverrides?.groupsByKey?.main?.tools?.allow).toEqual([
|
||||
"sessions_spawn",
|
||||
"sessions_yield",
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires Matrix replies to match the exact marker body", () => {
|
||||
expect(
|
||||
scenarioTesting.buildMatrixReplyArtifact(
|
||||
@@ -1311,7 +1352,7 @@ describe("matrix live qa scenarios", () => {
|
||||
}))
|
||||
.mockImplementationOnce(async () => {
|
||||
const childToken =
|
||||
/task="Reply exactly `([^`]+)`/.exec(
|
||||
/task="Finish with exactly ([^".]+)\./.exec(
|
||||
String(sendTextMessage.mock.calls[0]?.[0]?.body),
|
||||
)?.[1] ?? "MATRIX_QA_SUBAGENT_CHILD_FIXED";
|
||||
return {
|
||||
@@ -1458,6 +1499,43 @@ describe("matrix live qa scenarios", () => {
|
||||
expect(waitForRoomEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("fails the subagent thread spawn scenario on surfaced tool errors", async () => {
|
||||
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
|
||||
const sendTextMessage = vi.fn().mockResolvedValue("$subagent-spawn-trigger");
|
||||
const waitForRoomEvent = vi.fn().mockImplementationOnce(async (options) => {
|
||||
const event = {
|
||||
kind: "message",
|
||||
roomId: "!main:matrix-qa.test",
|
||||
eventId: "$sessions-spawn-error",
|
||||
sender: "@sut:matrix-qa.test",
|
||||
type: "m.room.message",
|
||||
body: "Protocol note: sessions_spawn failed: Matrix thread bind failed: no adapter",
|
||||
} satisfies MatrixQaObservedEvent;
|
||||
options.predicate(event);
|
||||
return {
|
||||
event,
|
||||
since: "driver-sync-error",
|
||||
};
|
||||
});
|
||||
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
primeRoom,
|
||||
sendTextMessage,
|
||||
waitForRoomEvent,
|
||||
});
|
||||
|
||||
const scenario = MATRIX_QA_SCENARIOS.find(
|
||||
(entry) => entry.id === "matrix-subagent-thread-spawn",
|
||||
);
|
||||
expect(scenario).toBeDefined();
|
||||
|
||||
await expect(runMatrixQaScenario(scenario!, matrixQaScenarioContext())).rejects.toThrow(
|
||||
"sessions_spawn failed",
|
||||
);
|
||||
|
||||
expect(waitForRoomEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("captures quiet preview notices before the finalized Matrix reply", async () => {
|
||||
const primeRoom = vi.fn().mockResolvedValue("driver-sync-start");
|
||||
const sendTextMessage = vi.fn().mockResolvedValue("$quiet-stream-trigger");
|
||||
@@ -2624,6 +2702,19 @@ describe("matrix live qa scenarios", () => {
|
||||
previousVersion: "backup-v1",
|
||||
success: true,
|
||||
});
|
||||
const ownerBootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
const driverStop = vi.fn().mockResolvedValue(undefined);
|
||||
const recoveryStop = vi.fn().mockResolvedValue(undefined);
|
||||
createMatrixQaClient.mockReturnValue({
|
||||
@@ -2636,19 +2727,7 @@ describe("matrix live qa scenarios", () => {
|
||||
});
|
||||
createMatrixQaE2eeScenarioClient
|
||||
.mockResolvedValueOnce({
|
||||
bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
}),
|
||||
bootstrapOwnDeviceVerification: ownerBootstrapOwnDeviceVerification,
|
||||
deleteOwnDevices: vi.fn().mockResolvedValue(undefined),
|
||||
getRecoveryKey: vi.fn().mockResolvedValue({
|
||||
encodedPrivateKey: "encoded-recovery-key",
|
||||
@@ -2658,6 +2737,10 @@ describe("matrix live qa scenarios", () => {
|
||||
stop: driverStop,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
getRecoveryKey: vi.fn().mockResolvedValue({
|
||||
encodedPrivateKey: "encoded-recovery-key",
|
||||
keyId: "SSSS",
|
||||
}),
|
||||
resetRoomKeyBackup,
|
||||
restoreRoomKeyBackup,
|
||||
stop: recoveryStop,
|
||||
@@ -2719,6 +2802,9 @@ describe("matrix live qa scenarios", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(ownerBootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
});
|
||||
expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key");
|
||||
expect(verifyWithRecoveryKey.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restoreRoomKeyBackup.mock.invocationCallOrder[0] ?? Number.MAX_SAFE_INTEGER,
|
||||
@@ -2751,6 +2837,19 @@ describe("matrix live qa scenarios", () => {
|
||||
ruleId: "owner-signature-upload-blocked",
|
||||
},
|
||||
]);
|
||||
const ownerBootstrapOwnDeviceVerification = vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
});
|
||||
startMatrixQaFaultProxy.mockResolvedValue({
|
||||
baseUrl: "http://127.0.0.1:39877",
|
||||
hits: proxyHits,
|
||||
@@ -2766,19 +2865,7 @@ describe("matrix live qa scenarios", () => {
|
||||
});
|
||||
createMatrixQaE2eeScenarioClient
|
||||
.mockResolvedValueOnce({
|
||||
bootstrapOwnDeviceVerification: vi.fn().mockResolvedValue({
|
||||
crossSigning: {
|
||||
published: true,
|
||||
},
|
||||
success: true,
|
||||
verification: {
|
||||
backupVersion: "backup-v1",
|
||||
crossSigningVerified: true,
|
||||
recoveryKeyStored: true,
|
||||
signedByOwner: true,
|
||||
verified: true,
|
||||
},
|
||||
}),
|
||||
bootstrapOwnDeviceVerification: ownerBootstrapOwnDeviceVerification,
|
||||
deleteOwnDevices: driverDeleteOwnDevices,
|
||||
getRecoveryKey: vi.fn().mockResolvedValue({
|
||||
encodedPrivateKey: "encoded-recovery-key",
|
||||
@@ -2889,6 +2976,9 @@ describe("matrix live qa scenarios", () => {
|
||||
scenarioId: "matrix-e2ee-recovery-owner-verification-required",
|
||||
}),
|
||||
);
|
||||
expect(ownerBootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
});
|
||||
expect(verifyWithRecoveryKey).toHaveBeenCalledWith("encoded-recovery-key");
|
||||
expect(restoreRoomKeyBackup).toHaveBeenCalledWith({
|
||||
recoveryKey: "encoded-recovery-key",
|
||||
@@ -3017,8 +3107,10 @@ describe("matrix live qa scenarios", () => {
|
||||
"Verification id: verification-1\nCompleted: yes\nDevice verified by owner: yes\nCross-signing verified: yes\n",
|
||||
});
|
||||
const kill = vi.fn();
|
||||
const endStdin = vi.fn();
|
||||
startMatrixQaOpenClawCli.mockReturnValue({
|
||||
args: ["matrix", "verify", "self", "--account", "cli"],
|
||||
endStdin,
|
||||
kill,
|
||||
output: vi.fn(() => ({ stderr: "", stdout: "" })),
|
||||
wait,
|
||||
@@ -3026,7 +3118,7 @@ describe("matrix live qa scenarios", () => {
|
||||
writeStdin,
|
||||
});
|
||||
let cliAccountConfigDuringRun: Record<string, unknown> | null = null;
|
||||
runMatrixQaOpenClawCli.mockImplementation(async ({ args, env }) => {
|
||||
runMatrixQaOpenClawCli.mockImplementation(async ({ args, env, stdin }) => {
|
||||
if (!cliAccountConfigDuringRun && env.OPENCLAW_CONFIG_PATH) {
|
||||
const cliConfig = JSON.parse(
|
||||
await readFile(String(env.OPENCLAW_CONFIG_PATH), "utf8"),
|
||||
@@ -3036,8 +3128,16 @@ describe("matrix live qa scenarios", () => {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
};
|
||||
};
|
||||
plugins?: {
|
||||
allow?: string[];
|
||||
entries?: Record<string, { enabled?: boolean }>;
|
||||
};
|
||||
};
|
||||
cliAccountConfigDuringRun = {
|
||||
...cliConfig.channels?.matrix?.accounts?.cli,
|
||||
pluginAllow: cliConfig.plugins?.allow,
|
||||
pluginEnabled: cliConfig.plugins?.entries?.matrix?.enabled,
|
||||
};
|
||||
cliAccountConfigDuringRun = cliConfig.channels?.matrix?.accounts?.cli ?? null;
|
||||
}
|
||||
const joined = args.join(" ");
|
||||
if (joined === "matrix verify status --account cli --json") {
|
||||
@@ -3060,10 +3160,8 @@ describe("matrix live qa scenarios", () => {
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (
|
||||
joined ===
|
||||
"matrix verify backup restore --account cli --recovery-key encoded-recovery-key --json"
|
||||
) {
|
||||
if (joined === "matrix verify backup restore --account cli --recovery-key-stdin --json") {
|
||||
expect(stdin).toBe("encoded-recovery-key\n");
|
||||
return {
|
||||
args,
|
||||
exitCode: 0,
|
||||
@@ -3118,6 +3216,7 @@ describe("matrix live qa scenarios", () => {
|
||||
]);
|
||||
expect(waitForOutput).toHaveBeenCalledTimes(2);
|
||||
expect(writeStdin).toHaveBeenCalledWith("yes\n");
|
||||
expect(endStdin).toHaveBeenCalledTimes(1);
|
||||
expect(wait).toHaveBeenCalledTimes(1);
|
||||
expect(kill).toHaveBeenCalledTimes(1);
|
||||
expect(runMatrixQaOpenClawCli).toHaveBeenCalledTimes(2);
|
||||
@@ -3129,12 +3228,12 @@ describe("matrix live qa scenarios", () => {
|
||||
"restore",
|
||||
"--account",
|
||||
"cli",
|
||||
"--recovery-key",
|
||||
"encoded-recovery-key",
|
||||
"--recovery-key-stdin",
|
||||
"--json",
|
||||
],
|
||||
["matrix", "verify", "status", "--account", "cli", "--json"],
|
||||
]);
|
||||
expect(runMatrixQaOpenClawCli.mock.calls[0]?.[0].stdin).toBe("encoded-recovery-key\n");
|
||||
const cliEnv = startMatrixQaOpenClawCli.mock.calls[0]?.[0].env;
|
||||
expect(cliEnv?.OPENCLAW_STATE_DIR).toContain("openclaw-matrix-cli-qa-");
|
||||
expect(cliEnv?.OPENCLAW_CONFIG_PATH).toContain("openclaw-matrix-cli-qa-");
|
||||
@@ -3144,6 +3243,8 @@ describe("matrix live qa scenarios", () => {
|
||||
deviceId: "CLIDEVICE",
|
||||
encryption: true,
|
||||
homeserver: "http://127.0.0.1:28008/",
|
||||
pluginAllow: expect.arrayContaining(["matrix"]),
|
||||
pluginEnabled: true,
|
||||
startupVerification: "off",
|
||||
userId: "@driver:matrix-qa.test",
|
||||
});
|
||||
@@ -3174,6 +3275,9 @@ describe("matrix live qa scenarios", () => {
|
||||
await expect(
|
||||
readFile(path.join(cliArtifactDir, "verify-status.stdout.txt"), "utf8"),
|
||||
).resolves.toContain('"crossSigningVerified":true');
|
||||
expect(bootstrapOwnDeviceVerification).toHaveBeenCalledWith({
|
||||
allowAutomaticCrossSigningReset: false,
|
||||
});
|
||||
} finally {
|
||||
await rm(outputDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
@@ -73,7 +73,9 @@ export type MatrixQaE2eeScenarioClient = {
|
||||
roomId?: string;
|
||||
userId?: string;
|
||||
}): Promise<MatrixVerificationSummary>;
|
||||
resetRoomKeyBackup(): Promise<MatrixRoomKeyBackupResetResult>;
|
||||
resetRoomKeyBackup(params?: {
|
||||
rotateRecoveryKey?: boolean;
|
||||
}): Promise<MatrixRoomKeyBackupResetResult>;
|
||||
restoreRoomKeyBackup(params?: {
|
||||
recoveryKey?: string;
|
||||
}): Promise<MatrixRoomKeyBackupRestoreResult>;
|
||||
@@ -301,8 +303,8 @@ export async function createMatrixQaE2eeScenarioClient(
|
||||
async requestVerification(opts) {
|
||||
return await requireCrypto().requestVerification(opts);
|
||||
},
|
||||
async resetRoomKeyBackup() {
|
||||
return await client.resetRoomKeyBackup();
|
||||
async resetRoomKeyBackup(params) {
|
||||
return await client.resetRoomKeyBackup(params);
|
||||
},
|
||||
async restoreRoomKeyBackup(opts) {
|
||||
return await client.restoreRoomKeyBackup(opts);
|
||||
|
||||
@@ -11,7 +11,7 @@ export async function requestMatrixJson<T>(params: {
|
||||
body?: unknown;
|
||||
endpoint: string;
|
||||
fetchImpl: MatrixQaFetchLike;
|
||||
method: "GET" | "POST" | "PUT";
|
||||
method: "DELETE" | "GET" | "POST" | "PUT";
|
||||
okStatuses?: number[];
|
||||
query?: Record<string, string | number | undefined>;
|
||||
timeoutMs?: number;
|
||||
|
||||
@@ -90,7 +90,7 @@ function createBundledChannelEntry(params: {
|
||||
}
|
||||
|
||||
describe("defineBundledChannelEntry", () => {
|
||||
it("keeps runtime sidecars out of discovery registration", () => {
|
||||
it("loads runtime sidecars during discovery registration", () => {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-runtime-"));
|
||||
tempDirs.push(tempRoot);
|
||||
const runtimeMarker = path.join(tempRoot, "runtime-loaded");
|
||||
@@ -115,7 +115,7 @@ describe("defineBundledChannelEntry", () => {
|
||||
expect(api.registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerCliMetadata).toHaveBeenCalledWith(api);
|
||||
expect(registerFull).not.toHaveBeenCalled();
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(false);
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps setup-runtime and full registration wired to runtime sidecars", () => {
|
||||
|
||||
@@ -487,11 +487,11 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
|
||||
profile("bundled-register:registerChannel", () =>
|
||||
api.registerChannel({ plugin: channelPlugin as ChannelPlugin }),
|
||||
);
|
||||
profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime));
|
||||
if (api.registrationMode === "discovery") {
|
||||
profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api));
|
||||
return;
|
||||
}
|
||||
profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime));
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi
|
||||
}
|
||||
|
||||
describe("defineChannelPluginEntry", () => {
|
||||
it("keeps runtime helpers out of discovery registration", () => {
|
||||
it("wires runtime helpers during discovery registration", () => {
|
||||
const setRuntime = vi.fn<(runtime: PluginRuntime) => void>();
|
||||
const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>();
|
||||
const registerFull = vi.fn<(api: OpenClawPluginApi) => void>();
|
||||
@@ -51,7 +51,7 @@ describe("defineChannelPluginEntry", () => {
|
||||
|
||||
expect(api.registerChannel).toHaveBeenCalledTimes(1);
|
||||
expect(registerCliMetadata).toHaveBeenCalledTimes(1);
|
||||
expect(setRuntime).not.toHaveBeenCalled();
|
||||
expect(setRuntime).toHaveBeenCalledWith(api.runtime);
|
||||
expect(registerFull).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -504,11 +504,11 @@ export function defineChannelPluginEntry<TPlugin>({
|
||||
return;
|
||||
}
|
||||
api.registerChannel({ plugin: plugin as ChannelPlugin });
|
||||
setRuntime?.(api.runtime);
|
||||
if (api.registrationMode === "discovery") {
|
||||
registerCliMetadata?.(api);
|
||||
return;
|
||||
}
|
||||
setRuntime?.(api.runtime);
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
defineBundledChannelEntry,
|
||||
type OpenClawPluginApi,
|
||||
} from "../plugin-sdk/channel-entry-contract.js";
|
||||
import { loadOpenClawPluginCliRegistry, loadOpenClawPlugins } from "./loader.js";
|
||||
import {
|
||||
cleanupPluginLoaderFixturesForTest,
|
||||
@@ -650,12 +655,93 @@ module.exports = {
|
||||
|
||||
expect(fs.readFileSync(modeMarker, "utf-8")).toBe("discovery");
|
||||
expect(fs.existsSync(fullMarker)).toBe(false);
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(false);
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(true);
|
||||
expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain(
|
||||
"discovery-cli-metadata-channel",
|
||||
);
|
||||
});
|
||||
|
||||
it("sets bundled channel runtime before discovery CLI metadata registration", () => {
|
||||
const pluginDir = makeTempDir();
|
||||
const runtimeMarker = path.join(pluginDir, "runtime-set.txt");
|
||||
const channelPluginPath = path.join(pluginDir, "channel.cjs");
|
||||
const runtimePath = path.join(pluginDir, "runtime.cjs");
|
||||
fs.writeFileSync(
|
||||
channelPluginPath,
|
||||
`exports.plugin = {
|
||||
id: "bundled-discovery-cli",
|
||||
meta: {
|
||||
id: "bundled-discovery-cli",
|
||||
label: "Bundled Discovery CLI",
|
||||
selectionLabel: "Bundled Discovery CLI",
|
||||
docsPath: "/channels/bundled-discovery-cli",
|
||||
blurb: "bundled discovery cli",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
fs.writeFileSync(
|
||||
runtimePath,
|
||||
`exports.setRuntime = () => {
|
||||
require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8");
|
||||
};`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const commands: string[] = [];
|
||||
const channels: string[] = [];
|
||||
const entry = defineBundledChannelEntry({
|
||||
id: "bundled-discovery-cli",
|
||||
name: "Bundled Discovery CLI",
|
||||
description: "bundled discovery cli",
|
||||
importMetaUrl: pathToFileURL(path.join(pluginDir, "index.cjs")).href,
|
||||
plugin: {
|
||||
specifier: "./channel.cjs",
|
||||
exportName: "plugin",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime.cjs",
|
||||
exportName: "setRuntime",
|
||||
},
|
||||
registerCliMetadata(api) {
|
||||
api.registerCli(() => {}, {
|
||||
descriptors: [
|
||||
{
|
||||
name: "bundled-discovery-cli",
|
||||
description: "Bundled discovery CLI metadata",
|
||||
hasSubcommands: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
},
|
||||
registerFull() {
|
||||
throw new Error("full registration should not run during discovery");
|
||||
},
|
||||
});
|
||||
|
||||
entry.register({
|
||||
registrationMode: "discovery",
|
||||
runtime: {} as OpenClawPluginApi["runtime"],
|
||||
registerChannel: (registration) => {
|
||||
const plugin = "plugin" in registration ? registration.plugin : registration;
|
||||
channels.push(plugin.id);
|
||||
},
|
||||
registerCli: (_register, options) => {
|
||||
commands.push(...(options?.descriptors ?? []).map((descriptor) => descriptor.name));
|
||||
},
|
||||
} as OpenClawPluginApi);
|
||||
|
||||
expect(channels).toEqual(["bundled-discovery-cli"]);
|
||||
expect(fs.existsSync(runtimeMarker)).toBe(true);
|
||||
expect(commands).toEqual(["bundled-discovery-cli"]);
|
||||
});
|
||||
|
||||
it("sanitizes plugin CLI descriptor descriptions and rejects unsafe command names", async () => {
|
||||
useNoBundledPlugins();
|
||||
const unsafeDescription =
|
||||
|
||||
@@ -56,11 +56,11 @@ export function inlineChannelPluginEntryFactorySource(): string {
|
||||
return;
|
||||
}
|
||||
api.registerChannel({ plugin: options.plugin });
|
||||
options.setRuntime?.(api.runtime);
|
||||
if (api.registrationMode === "discovery") {
|
||||
options.registerCliMetadata?.(api);
|
||||
return;
|
||||
}
|
||||
options.setRuntime?.(api.runtime);
|
||||
if (api.registrationMode !== "full") {
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user