Merge branch 'main' into docs/zh-cn-onboarding-sync

This commit is contained in:
Dclef 2026-03-19 22:54:32 +08:00 committed by GitHub
commit 6f6e24ed43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
120 changed files with 3208 additions and 535 deletions

View File

@ -62,9 +62,9 @@ jobs:
run: |
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
# This smoke validates that the build-arg path preinstalls selected
# extension deps and that matrix plugin discovery stays healthy in the
# final runtime image.
# This smoke validates that the build-arg path preinstalls the matrix
# runtime deps declared by the plugin and that matrix discovery stays
# healthy in the final runtime image.
- name: Build extension Dockerfile smoke image
uses: useblacksmith/build-push-action@v2
with:
@ -84,9 +84,17 @@ jobs:
openclaw --version &&
node -e "
const Module = require(\"node:module\");
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
if (runtimeDeps.length === 0) {
throw new Error(
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
);
}
for (const dep of runtimeDeps) {
requireFromMatrix.resolve(dep);
}
const { spawnSync } = require(\"node:child_process\");
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
if (run.status !== 0) {

View File

@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat.
### Fixes
@ -161,6 +162,7 @@ Docs: https://docs.openclaw.ai
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
### Breaking

View File

@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowAlwaysPatterns: [String]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
command: command,
rawCommand: rawCommand)
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
rawCommand: rawCommand,
rawCommand: allowlistRawCommand,
cwd: cwd,
env: env)
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowAlwaysPatterns: allowAlwaysPatterns,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,

View File

@ -378,7 +378,7 @@ private enum ExecHostExecutor {
let context = await self.buildContext(
request: request,
command: validatedRequest.command,
rawCommand: validatedRequest.displayCommand)
rawCommand: validatedRequest.evaluationRawCommand)
switch ExecHostRequestEvaluator.evaluate(
context: context,
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
{
guard decision == .allowAlways, context.security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in context.allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(
command: context.command,
resolution: candidate)
else {
continue
}
for pattern in context.allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
}

View File

@ -52,6 +52,23 @@ struct ExecCommandResolution {
return [resolution]
}
static func resolveAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?) -> [String]
{
var patterns: [String] = []
var seen = Set<String>()
self.collectAllowAlwaysPatterns(
command: command,
cwd: cwd,
env: env,
depth: 0,
patterns: &patterns,
seen: &seen)
return patterns
}
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
@ -101,6 +118,115 @@ struct ExecCommandResolution {
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
}
private static func collectAllowAlwaysPatterns(
command: [String],
cwd: String?,
env: [String: String]?,
depth: Int,
patterns: inout [String],
seen: inout Set<String>)
{
guard depth < 3, !command.isEmpty else {
return
}
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
ExecCommandToken.basenameLower(token0) == "env",
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
!envUnwrapped.isEmpty
{
self.collectAllowAlwaysPatterns(
command: envUnwrapped,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
self.collectAllowAlwaysPatterns(
command: shellMultiplexer,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
return
}
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
if shell.isWrapper {
guard let shellCommand = shell.command,
let segments = self.splitShellCommandChain(shellCommand)
else {
return
}
for segment in segments {
let tokens = self.tokenizeShellWords(segment)
guard !tokens.isEmpty else {
continue
}
self.collectAllowAlwaysPatterns(
command: tokens,
cwd: cwd,
env: env,
depth: depth + 1,
patterns: &patterns,
seen: &seen)
}
return
}
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
seen.insert(pattern).inserted
else {
return
}
patterns.append(pattern)
}
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
return nil
}
let wrapper = ExecCommandToken.basenameLower(token0)
guard wrapper == "busybox" || wrapper == "toybox" else {
return nil
}
var appletIndex = 1
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
appletIndex += 1
}
guard appletIndex < argv.count else {
return nil
}
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
guard !applet.isEmpty else {
return nil
}
let normalizedApplet = ExecCommandToken.basenameLower(applet)
let shellWrappers = Set([
"ash",
"bash",
"dash",
"fish",
"ksh",
"powershell",
"pwsh",
"sh",
"zsh",
])
guard shellWrappers.contains(normalizedApplet) else {
return nil
}
return Array(argv[appletIndex...])
}
private static func parseFirstToken(_ command: String) -> String? {
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }

View File

@ -12,14 +12,24 @@ enum ExecCommandToken {
enum ExecEnvInvocationUnwrapper {
static let maxWrapperDepth = 4
struct UnwrapResult {
let command: [String]
let usesModifiers: Bool
}
private static func isEnvAssignment(_ token: String) -> Bool {
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
return token.range(of: pattern, options: .regularExpression) != nil
}
static func unwrap(_ command: [String]) -> [String]? {
self.unwrapWithMetadata(command)?.command
}
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
var idx = 1
var expectsOptionValue = false
var usesModifiers = false
while idx < command.count {
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
if token.isEmpty {
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
let lower = token.lowercased()
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
if ExecEnvOptions.flagOnly.contains(flag) {
usesModifiers = true
idx += 1
continue
}
if ExecEnvOptions.withValue.contains(flag) {
usesModifiers = true
if !lower.contains("=") {
expectsOptionValue = true
}
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
lower.hasPrefix("--ignore-signal=") ||
lower.hasPrefix("--block-signal=")
{
usesModifiers = true
idx += 1
continue
}
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
}
break
}
guard idx < command.count else { return nil }
return Array(command[idx...])
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
}
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
break
}
current = unwrapped
if unwrapped.usesModifiers {
break
}
current = unwrapped.command
depth += 1
}
return current

View File

@ -3,6 +3,7 @@ import Foundation
struct ExecHostValidatedRequest {
let command: [String]
let displayCommand: String
let evaluationRawCommand: String?
}
enum ExecHostPolicyDecision {
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
rawCommand: request.rawCommand)
switch validatedCommand {
case let .ok(resolved):
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
return .success(ExecHostValidatedRequest(
command: command,
displayCommand: resolved.displayCommand,
evaluationRawCommand: resolved.evaluationRawCommand))
case let .invalid(message):
return .failure(
ExecHostError(

View File

@ -3,6 +3,7 @@ import Foundation
enum ExecSystemRunCommandValidator {
struct ResolvedCommand {
let displayCommand: String
let evaluationRawCommand: String?
}
enum ValidationResult {
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
let formattedArgv = ExecCommandFormatter.displayString(for: command)
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
ExecCommandFormatter.displayString(for: command)
nil
}
if let raw = normalizedRaw, raw != inferred {
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
}
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
return .ok(ResolvedCommand(
displayCommand: formattedArgv,
evaluationRawCommand: self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)))
}
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
let normalizedRaw = self.normalizeRaw(rawCommand)
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
shellCommand
} else {
nil
}
return self.allowlistEvaluationRawCommand(
normalizedRaw: normalizedRaw,
shellIsWrapper: shell.isWrapper,
previewCommand: previewCommand)
}
private static func normalizeRaw(_ rawCommand: String?) -> String? {
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
return trimmed.isEmpty ? nil : trimmed
}
private static func allowlistEvaluationRawCommand(
normalizedRaw: String?,
shellIsWrapper: Bool,
previewCommand: String?) -> String?
{
guard shellIsWrapper else {
return normalizedRaw
}
guard let normalizedRaw else {
return nil
}
return normalizedRaw == previewCommand ? normalizedRaw : nil
}
private static func normalizeExecutableToken(_ token: String) -> String {
let base = ExecCommandToken.basenameLower(token)
if base.hasSuffix(".exe") {

View File

@ -507,8 +507,7 @@ actor MacNodeRuntime {
persistAllowlist: persistAllowlist,
security: evaluation.security,
agentId: evaluation.agentId,
command: command,
allowlistResolutions: evaluation.allowlistResolutions)
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
await self.emitExecEvent(
@ -795,15 +794,11 @@ extension MacNodeRuntime {
persistAllowlist: Bool,
security: ExecSecurity,
agentId: String?,
command: [String],
allowlistResolutions: [ExecCommandResolution])
allowAlwaysPatterns: [String])
{
guard persistAllowlist, security == .allowlist else { return }
var seenPatterns = Set<String>()
for candidate in allowlistResolutions {
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
continue
}
for pattern in allowAlwaysPatterns {
if seenPatterns.insert(pattern).inserted {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}

View File

@ -45,7 +45,7 @@ import Testing
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
try makeExecutableForTests(at: nodePath)
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
try makeExecutableForTests(at: scriptPath)

View File

@ -240,7 +240,7 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
#expect(resolutions[0].executableName == "touch")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
#expect(resolutions[0].executableName == "env")
}
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let evaluation = await ExecApprovalEvaluator.evaluate(
command: command,
rawCommand: rawCommand,
cwd: nil,
envOverrides: ["PATH": "/usr/bin:/bin"],
agentId: nil)
#expect(evaluation.displayCommand == rawCommand)
#expect(evaluation.allowlistResolutions.count == 1)
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
}
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(patterns == ["/usr/bin/printf"])
}
@Test func `match all requires every segment to match`() {

View File

@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
try await self.withTempStateDir { _ in
_ = ExecApprovalsStore.ensureFile()
let url = ExecApprovalsStore.fileURL()
let firstWriteDate = try Self.modificationDate(at: url)
let firstIdentity = try Self.fileIdentity(at: url)
try await Task.sleep(nanoseconds: 1_100_000_000)
_ = ExecApprovalsStore.ensureFile()
let secondWriteDate = try Self.modificationDate(at: url)
let secondIdentity = try Self.fileIdentity(at: url)
#expect(firstWriteDate == secondWriteDate)
#expect(firstIdentity == secondIdentity)
}
}
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private static func modificationDate(at url: URL) throws -> Date {
private static func fileIdentity(at url: URL) throws -> Int {
let attributes = try FileManager().attributesOfItem(atPath: url.path)
guard let date = attributes[.modificationDate] as? Date else {
struct MissingDateError: Error {}
throw MissingDateError()
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
struct MissingIdentifierError: Error {}
throw MissingIdentifierError()
}
return date
return identifier
}
}

View File

@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
env: [:],
resolution: nil,
allowlistResolutions: [],
allowAlwaysPatterns: [],
allowlistMatches: [],
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: nil,

View File

@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
}
}
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
switch result {
case let .ok(resolved):
#expect(resolved.displayCommand == rawCommand)
#expect(resolved.evaluationRawCommand == nil)
case let .invalid(message):
Issue.record("unexpected invalid result: \(message)")
}
}
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
let fixtureURL = try self.findContractFixtureURL()
let data = try Data(contentsOf: fixtureURL)

View File

@ -372,7 +372,7 @@ Planned improvement:
## Automatic verification notices
Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages.
Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages.
That includes:
- verification request notices
@ -381,7 +381,8 @@ That includes:
- SAS details (emoji and decimal) when available
Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw.
When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side.
For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side.
For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally.
You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification.
OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending.

View File

@ -767,6 +767,14 @@
"source": "/gcp",
"destination": "/install/gcp"
},
{
"source": "/azure",
"destination": "/install/azure"
},
{
"source": "/install/azure/azure",
"destination": "/install/azure"
},
{
"source": "/platforms/fly",
"destination": "/install/fly"
@ -779,6 +787,10 @@
"source": "/platforms/gcp",
"destination": "/install/gcp"
},
{
"source": "/platforms/azure",
"destination": "/install/azure"
},
{
"source": "/platforms/macos-vm",
"destination": "/install/macos-vm"
@ -872,6 +884,7 @@
"install/fly",
"install/hetzner",
"install/gcp",
"install/azure",
"install/macos-vm",
"install/exe-dev",
"install/railway",

169
docs/install/azure.md Normal file
View File

@ -0,0 +1,169 @@
---
summary: "Run OpenClaw Gateway 24/7 on an Azure Linux VM with durable state"
read_when:
- You want OpenClaw running 24/7 on Azure with Network Security Group hardening
- You want a production-grade, always-on OpenClaw Gateway on your own Azure Linux VM
- You want secure administration with Azure Bastion SSH
- You want repeatable deployments with Azure Resource Manager templates
title: "Azure"
---
# OpenClaw on Azure Linux VM
This guide sets up an Azure Linux VM, applies Network Security Group (NSG) hardening, configures Azure Bastion (managed Azure SSH entry point), and installs OpenClaw.
## What youll do
- Deploy Azure compute and network resources with Azure Resource Manager (ARM) templates
- Apply Azure Network Security Group (NSG) rules so VM SSH is allowed only from Azure Bastion
- Use Azure Bastion for SSH access
- Install OpenClaw with the installer script
- Verify the Gateway
## Before you start
Youll need:
- An Azure subscription with permission to create compute and network resources
- Azure CLI installed (see [Azure CLI install steps](https://learn.microsoft.com/cli/azure/install-azure-cli) if needed)
## 1) Sign in to Azure CLI
```bash
az login # Sign in and select your Azure subscription
az extension add -n ssh # Extension required for Azure Bastion SSH management
```
## 2) Register required resource providers (one-time)
```bash
az provider register --namespace Microsoft.Compute
az provider register --namespace Microsoft.Network
```
Verify Azure resource provider registration. Wait until both show `Registered`.
```bash
az provider show --namespace Microsoft.Compute --query registrationState -o tsv
az provider show --namespace Microsoft.Network --query registrationState -o tsv
```
## 3) Set deployment variables
```bash
RG="rg-openclaw"
LOCATION="westus2"
TEMPLATE_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.json"
PARAMS_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.parameters.json"
```
## 4) Select SSH key
Use your existing public key if you have one:
```bash
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
```
If you dont have an SSH key yet, run the following:
```bash
ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -C "you@example.com"
SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)"
```
## 5) Select VM size and OS disk size
Set VM and disk sizing variables:
```bash
VM_SIZE="Standard_B2as_v2"
OS_DISK_SIZE_GB=64
```
Choose a VM size and OS disk size that are available in your Azure subscription/region and matches your workload:
- Start smaller for light usage and scale up later
- Use more vCPU/RAM/OS disk size for heavier automation, more channels, or larger model/tool workloads
- If a VM size is unavailable in your region or subscription quota, pick the closest available SKU
List VM sizes available in your target region:
```bash
az vm list-skus --location "${LOCATION}" --resource-type virtualMachines -o table
```
Check your current VM vCPU and OS disk size usage/quota:
```bash
az vm list-usage --location "${LOCATION}" -o table
```
## 6) Create the resource group
```bash
az group create -n "${RG}" -l "${LOCATION}"
```
## 7) Deploy resources
This command applies your selected SSH key, VM size, and OS disk size.
```bash
az deployment group create \
-g "${RG}" \
--template-uri "${TEMPLATE_URI}" \
--parameters "${PARAMS_URI}" \
--parameters location="${LOCATION}" \
--parameters vmSize="${VM_SIZE}" \
--parameters osDiskSizeGb="${OS_DISK_SIZE_GB}" \
--parameters sshPublicKey="${SSH_PUB_KEY}"
```
## 8) SSH into the VM through Azure Bastion
```bash
RG="rg-openclaw"
VM_NAME="vm-openclaw"
BASTION_NAME="bas-openclaw"
ADMIN_USERNAME="openclaw"
VM_ID="$(az vm show -g "${RG}" -n "${VM_NAME}" --query id -o tsv)"
az network bastion ssh \
--name "${BASTION_NAME}" \
--resource-group "${RG}" \
--target-resource-id "${VM_ID}" \
--auth-type ssh-key \
--username "${ADMIN_USERNAME}" \
--ssh-key ~/.ssh/id_ed25519
```
## 9) Install OpenClaw (in the VM shell)
```bash
curl -fsSL https://openclaw.ai/install.sh -o /tmp/openclaw-install.sh
bash /tmp/openclaw-install.sh
rm -f /tmp/openclaw-install.sh
openclaw --version
```
The installer script handles Node detection/installation and runs onboarding by default.
## 10) Verify the Gateway
After onboarding completes:
```bash
openclaw gateway status
```
Most enterprise Azure teams already have GitHub Copilot licenses. If that is your case, we recommend choosing the GitHub Copilot provider in the OpenClaw onboarding wizard. See [GitHub Copilot provider](/providers/github-copilot).
The included ARM template uses Ubuntu image `version: "latest"` for convenience. If you need reproducible builds, pin a specific image version in `infra/azure/templates/azuredeploy.json` (you can list versions with `az vm image list --publisher Canonical --offer ubuntu-24_04-lts --sku server --all -o table`).
## Next steps
- Set up messaging channels: [Channels](/channels)
- Pair local devices as nodes: [Nodes](/nodes)
- Configure the Gateway: [Gateway configuration](/gateway/configuration)
- For more details on OpenClaw Azure deployment with the GitHub Copilot model provider: [OpenClaw on Azure with GitHub Copilot](https://github.com/johnsonshi/openclaw-azure-github-copilot)

View File

@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
`- Failed creating a Matrix migration snapshot before repair: ...`
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- 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>"`.
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
`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>"`.

View File

@ -29,6 +29,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
- Fly.io: [Fly.io](/install/fly)
- Hetzner (Docker): [Hetzner](/install/hetzner)
- GCP (Compute Engine): [GCP](/install/gcp)
- Azure (Linux VM): [Azure](/install/azure)
- exe.dev (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
## Common links

View File

@ -1,5 +1,5 @@
---
summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/exe.dev)"
summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/Azure/exe.dev)"
read_when:
- You want to run the Gateway in the cloud
- You need a quick map of VPS/hosting guides
@ -19,6 +19,7 @@ deployments work at a high level.
- **Fly.io**: [Fly.io](/install/fly)
- **Hetzner (Docker)**: [Hetzner](/install/hetzner)
- **GCP (Compute Engine)**: [GCP](/install/gcp)
- **Azure (Linux VM)**: [Azure](/install/azure)
- **exe.dev** (VM + HTTPS proxy): [exe.dev](/install/exe-dev)
- **AWS (EC2/Lightsail/free tier)**: works well too. Video guide:
[https://x.com/techfrenAJ/status/2014934471095812547](https://x.com/techfrenAJ/status/2014934471095812547)

View File

@ -1,16 +1,14 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
hasConfiguredSecretInput,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/config-runtime";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core";
import {
mergeDiscordAccountConfig,
resolveDefaultDiscordAccountId,
resolveDiscordAccountConfig,
} from "./accounts.js";
import {
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
hasConfiguredSecretInput,
normalizeSecretInputString,
type OpenClawConfig,
type DiscordAccountConfig,
} from "./runtime-api.js";
export type DiscordCredentialStatus = "available" | "configured_unavailable" | "missing";

View File

@ -1,12 +1,14 @@
import {
createAccountActionGate,
createAccountListHelpers,
normalizeAccountId,
resolveAccountEntry,
type OpenClawConfig,
type DiscordAccountConfig,
type DiscordActionConfig,
} from "./runtime-api.js";
} from "openclaw/plugin-sdk/account-helpers";
import { normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type {
DiscordAccountConfig,
DiscordActionConfig,
OpenClawConfig,
} from "openclaw/plugin-sdk/discord-core";
import { resolveAccountEntry } from "openclaw/plugin-sdk/routing";
import { resolveDiscordToken } from "./token.js";
export type ResolvedDiscordAccount = {

View File

@ -15,6 +15,9 @@ export {
resolvePollMaxSelections,
type ActionGate,
type ChannelPlugin,
type DiscordAccountConfig,
type DiscordActionConfig,
type DiscordConfig,
type OpenClawConfig,
} from "openclaw/plugin-sdk/discord-core";
export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core";
@ -42,8 +45,6 @@ export type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
} from "openclaw/plugin-sdk/channel-runtime";
export type { DiscordConfig } from "openclaw/plugin-sdk/discord";
export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord";
export {
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,

View File

@ -1,4 +1,4 @@
// Private runtime barrel for the bundled Google Chat extension.
// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface.
export * from "../../src/plugin-sdk/googlechat.js";
export * from "openclaw/plugin-sdk/googlechat";

View File

@ -1,3 +1,8 @@
export * from "./src/setup-core.js";
export * from "./src/setup-surface.js";
export {
createMatrixThreadBindingManager,
getMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js";

View File

@ -1,3 +1,5 @@
import path from "node:path";
import { createJiti } from "jiti";
import { beforeEach, describe, expect, it, vi } from "vitest";
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
@ -14,6 +16,20 @@ describe("matrix plugin registration", () => {
vi.clearAllMocks();
});
it("loads the matrix runtime api through Jiti", () => {
const jiti = createJiti(import.meta.url, {
interopDefault: true,
tryNative: false,
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
});
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
expect(jiti(runtimeApiPath)).toMatchObject({
requiresExplicitMatrixDefaultAccount: expect.any(Function),
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
});
});
it("registers the channel without bootstrapping crypto runtime", () => {
const runtime = {} as never;
matrixPlugin.register({

View File

@ -1,3 +1,14 @@
export * from "openclaw/plugin-sdk/matrix";
export * from "./src/auth-precedence.js";
export * from "./helper-api.js";
export {
findMatrixAccountEntry,
hashMatrixAccessToken,
listMatrixEnvAccountIds,
resolveConfiguredMatrixAccountIds,
resolveMatrixChannelConfig,
resolveMatrixCredentialsFilename,
resolveMatrixEnvAccountToken,
resolveMatrixHomeserverKey,
resolveMatrixLegacyFlatStoreRoot,
sanitizeMatrixPathSegment,
} from "./helper-api.js";

View File

@ -59,7 +59,7 @@ describe("matrixMessageActions", () => {
const discovery = describeMessageTool!({
cfg: createConfiguredMatrixConfig(),
} as never);
} as never) ?? { actions: [] };
const actions = discovery.actions;
expect(actions).toContain("poll");
@ -74,7 +74,7 @@ describe("matrixMessageActions", () => {
const discovery = describeMessageTool!({
cfg: createConfiguredMatrixConfig(),
} as never);
} as never) ?? { actions: [], schema: null };
const actions = discovery.actions;
const properties =
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
@ -87,64 +87,66 @@ describe("matrixMessageActions", () => {
});
it("hides gated actions when the default Matrix account disables them", () => {
const actions = matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
const actions =
matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
defaultAccount: "assistant",
actions: {
messages: true,
reactions: true,
pins: true,
profile: true,
memberInfo: true,
channelInfo: true,
verification: true,
},
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
encryption: true,
actions: {
messages: false,
reactions: false,
pins: false,
profile: false,
memberInfo: false,
channelInfo: false,
verification: false,
},
},
},
},
},
},
} as CoreConfig,
} as never).actions;
} as CoreConfig,
} as never)?.actions ?? [];
expect(actions).toEqual(["poll", "poll-vote"]);
});
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
const actions = matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
const actions =
matrixMessageActions.describeMessageTool!({
cfg: {
channels: {
matrix: {
accounts: {
assistant: {
homeserver: "https://matrix.example.org",
accessToken: "assistant-token",
},
ops: {
homeserver: "https://matrix.example.org",
accessToken: "ops-token",
},
},
},
},
},
} as CoreConfig,
} as never).actions;
} as CoreConfig,
} as never)?.actions ?? [];
expect(actions).toEqual([]);
});

View File

@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d
import { resolveMatrixAuth } from "./matrix/client.js";
import { probeMatrix } from "./matrix/probe.js";
import { sendMessageMatrix } from "./matrix/send.js";
import { matrixOutbound } from "./outbound.js";
import { resolveMatrixTargets } from "./resolve-targets.js";
export const matrixChannelRuntime = {
listMatrixDirectoryGroupsLive,
listMatrixDirectoryPeersLive,
matrixOutbound,
probeMatrix,
resolveMatrixAuth,
resolveMatrixTargets,

View File

@ -15,8 +15,8 @@ import {
createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
@ -47,7 +47,6 @@ import {
import { getMatrixRuntime } from "./runtime.js";
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
import { matrixSetupAdapter } from "./setup-core.js";
import { matrixSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: {
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
pairing: createTextPairingAdapter({
idLabel: "matrixUserId",
message: PAIRING_APPROVED_MESSAGE,

View File

@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => {
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
expect(process.exitCode).toBeUndefined();
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(
-1,
)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({

View File

@ -7,7 +7,7 @@ import {
resolveMatrixAccount,
} from "./accounts.js";
vi.mock("./credentials.js", () => ({
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: () => null,
credentialsMatchConfig: () => false,
}));

View File

@ -10,7 +10,7 @@ import {
import type { CoreConfig, MatrixConfig } from "../types.js";
import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js";
import { resolveMatrixConfigForAccount } from "./client.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js";
/** Merge account config with top-level defaults, preserving nested objects. */
function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig {

View File

@ -9,16 +9,20 @@ import {
resolveMatrixAuthContext,
validateMatrixHomeserverUrl,
} from "./client/config.js";
import * as credentialsModule from "./credentials.js";
import * as credentialsReadModule from "./credentials-read.js";
import * as sdkModule from "./sdk.js";
const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn());
const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn());
vi.mock("./credentials.js", () => ({
vi.mock("./credentials-read.js", () => ({
loadMatrixCredentials: vi.fn(() => null),
saveMatrixCredentials: saveMatrixCredentialsMock,
credentialsMatchConfig: vi.fn(() => false),
touchMatrixCredentials: vi.fn(),
}));
vi.mock("./credentials-write.runtime.js", () => ({
saveMatrixCredentials: saveMatrixCredentialsMock,
touchMatrixCredentials: touchMatrixCredentialsMock,
}));
describe("resolveMatrixConfig", () => {
@ -414,14 +418,14 @@ describe("resolveMatrixAuth", () => {
});
it("uses cached matching credentials when access token is not configured", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "cached-token",
deviceId: "CACHEDDEVICE",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
@ -464,13 +468,13 @@ describe("resolveMatrixAuth", () => {
});
it("falls back to config deviceId when cached credentials are missing it", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {
@ -533,8 +537,8 @@ describe("resolveMatrixAuth", () => {
});
it("uses named-account password auth instead of inheriting the base access token", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(false);
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false);
const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({
access_token: "ops-token",
user_id: "@ops:example.org",
@ -615,13 +619,13 @@ describe("resolveMatrixAuth", () => {
});
it("uses config deviceId with cached credentials when token is loaded from cache", async () => {
vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({
vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
createdAt: "2026-01-01T00:00:00.000Z",
});
vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true);
vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true);
const cfg = {
channels: {

View File

@ -19,6 +19,7 @@ import {
listNormalizedMatrixAccountIds,
} from "../account-config.js";
import { resolveMatrixConfigFieldPath } from "../config-update.js";
import { credentialsMatchConfig, loadMatrixCredentials } from "../credentials-read.js";
import { MatrixClient } from "../sdk.js";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
@ -338,13 +339,11 @@ export async function resolveMatrixAuth(params?: {
}): Promise<MatrixAuth> {
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
const homeserver = validateMatrixHomeserverUrl(resolved.homeserver);
const {
loadMatrixCredentials,
saveMatrixCredentials,
credentialsMatchConfig,
touchMatrixCredentials,
} = await import("../credentials.js");
let credentialsWriter: typeof import("../credentials-write.runtime.js") | undefined;
const loadCredentialsWriter = async () => {
credentialsWriter ??= await import("../credentials-write.runtime.js");
return credentialsWriter;
};
const cached = loadMatrixCredentials(env, accountId);
const cachedCredentials =
@ -391,6 +390,7 @@ export async function resolveMatrixAuth(params?: {
cachedCredentials.userId !== userId ||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
if (shouldRefreshCachedCredentials) {
const { saveMatrixCredentials } = await loadCredentialsWriter();
await saveMatrixCredentials(
{
homeserver,
@ -402,6 +402,7 @@ export async function resolveMatrixAuth(params?: {
accountId,
);
} else if (hasMatchingCachedToken) {
const { touchMatrixCredentials } = await loadCredentialsWriter();
await touchMatrixCredentials(env, accountId);
}
return {
@ -418,6 +419,7 @@ export async function resolveMatrixAuth(params?: {
}
if (cachedCredentials) {
const { touchMatrixCredentials } = await loadCredentialsWriter();
await touchMatrixCredentials(env, accountId);
return {
accountId,
@ -474,6 +476,7 @@ export async function resolveMatrixAuth(params?: {
encryption: resolved.encryption,
};
const { saveMatrixCredentials } = await loadCredentialsWriter();
await saveMatrixCredentials(
{
homeserver: auth.homeserver,

View File

@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
rooms: {
join: {
"!room:example.org": {
summary: {},
summary: { "m.heroes": [] },
state: { events: [] },
timeline: {
events: [
@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
unread_notifications: {},
},
},
invite: {},
leave: {},
knock: {},
},
account_data: {
events: [
@ -88,6 +91,50 @@ describe("FileBackedMatrixSyncStore", () => {
},
]);
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
});
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "bot-storage.json");
const firstStore = new FileBackedMatrixSyncStore(storagePath);
await firstStore.setSyncData(createSyncResponse("s123"));
await firstStore.flush();
const afterDirtyPersist = new FileBackedMatrixSyncStore(storagePath);
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
firstStore.markCleanShutdown();
await firstStore.flush();
const afterCleanShutdown = new FileBackedMatrixSyncStore(storagePath);
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
});
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
tempDirs.push(tempDir);
const storagePath = path.join(tempDir, "bot-storage.json");
const firstStore = new FileBackedMatrixSyncStore(storagePath);
await firstStore.setSyncData(createSyncResponse("s123"));
firstStore.markCleanShutdown();
await firstStore.flush();
const restartedStore = new FileBackedMatrixSyncStore(storagePath);
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
await restartedStore.setSyncData(createSyncResponse("s456"));
await restartedStore.flush();
const afterNewSync = new FileBackedMatrixSyncStore(storagePath);
expect(afterNewSync.hasSavedSync()).toBe(true);
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
});
it("coalesces background persistence until the debounce window elapses", async () => {

View File

@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = {
version: number;
savedSync: ISyncData | null;
clientOptions?: IStoredClientOpts;
cleanShutdown?: boolean;
};
function createAsyncLock() {
@ -52,7 +53,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
nextBatch: value.nextBatch,
accountData: value.accountData,
roomsData: value.roomsData,
} as ISyncData;
} as unknown as ISyncData;
}
// Older Matrix state files stored the raw /sync-shaped payload directly.
@ -64,7 +65,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
? value.account_data.events
: [],
roomsData: isRecord(value.rooms) ? value.rooms : {},
} as ISyncData;
} as unknown as ISyncData;
}
return null;
@ -76,6 +77,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
version?: unknown;
savedSync?: unknown;
clientOptions?: unknown;
cleanShutdown?: unknown;
};
const savedSync = toPersistedSyncData(parsed.savedSync);
if (parsed.version === STORE_VERSION) {
@ -85,6 +87,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
clientOptions: isRecord(parsed.clientOptions)
? (parsed.clientOptions as IStoredClientOpts)
: undefined,
cleanShutdown: parsed.cleanShutdown === true,
};
}
@ -93,6 +96,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
return {
version: STORE_VERSION,
savedSync: toPersistedSyncData(parsed),
cleanShutdown: false,
};
} catch {
return null;
@ -119,6 +123,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
private savedSync: ISyncData | null = null;
private savedClientOptions: IStoredClientOpts | undefined;
private readonly hadSavedSyncOnLoad: boolean;
private readonly hadCleanShutdownOnLoad: boolean;
private cleanShutdown = false;
private dirty = false;
private persistTimer: NodeJS.Timeout | null = null;
private persistPromise: Promise<void> | null = null;
@ -128,11 +134,13 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
let restoredSavedSync: ISyncData | null = null;
let restoredClientOptions: IStoredClientOpts | undefined;
let restoredCleanShutdown = false;
try {
const raw = readFileSync(this.storagePath, "utf8");
const persisted = readPersistedStore(raw);
restoredSavedSync = persisted?.savedSync ?? null;
restoredClientOptions = persisted?.clientOptions;
restoredCleanShutdown = persisted?.cleanShutdown === true;
} catch {
// Missing or unreadable sync cache should not block startup.
}
@ -140,6 +148,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
this.savedSync = restoredSavedSync;
this.savedClientOptions = restoredClientOptions;
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
this.cleanShutdown = this.hadCleanShutdownOnLoad;
if (this.savedSync) {
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
@ -154,6 +164,10 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
return this.hadSavedSyncOnLoad;
}
hasSavedSyncFromCleanShutdown(): boolean {
return this.hadCleanShutdownOnLoad;
}
override getSavedSync(): Promise<ISyncData | null> {
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
}
@ -205,9 +219,15 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
await super.deleteAllData();
this.savedSync = null;
this.savedClientOptions = undefined;
this.cleanShutdown = false;
await fs.rm(this.storagePath, { force: true }).catch(() => undefined);
}
markCleanShutdown(): void {
this.cleanShutdown = true;
this.dirty = true;
}
async flush(): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
@ -224,6 +244,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
}
private markDirtyAndSchedulePersist(): void {
this.cleanShutdown = false;
this.dirty = true;
if (this.persistTimer) {
return;
@ -242,6 +263,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
const payload: PersistedMatrixSyncStore = {
version: STORE_VERSION,
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
cleanShutdown: this.cleanShutdown === true,
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
};
try {

View File

@ -0,0 +1,150 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { getMatrixRuntime } from "../runtime.js";
import {
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
} from "../storage-paths.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
function resolveStateDir(env: NodeJS.ProcessEnv): string {
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
}
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
}
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
const normalizedAccountId = normalizeAccountId(accountId);
const cfg = getMatrixRuntime().config.loadConfig();
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
}
if (requiresExplicitMatrixDefaultAccount(cfg)) {
return false;
}
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
}
function resolveLegacyMigrationSourcePath(
env: NodeJS.ProcessEnv,
accountId?: string | null,
): string | null {
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
return null;
}
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
}
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return parsed as MatrixStoredCredentials;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? resolveStateDir(env);
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
}
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const resolvedStateDir = resolveStateDir(env);
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
return parseMatrixCredentialsFile(credPath);
}
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
if (!legacyPath || !fs.existsSync(legacyPath)) {
return null;
}
const parsed = parseMatrixCredentialsFile(legacyPath);
if (!parsed) {
return null;
}
try {
fs.mkdirSync(path.dirname(credPath), { recursive: true });
fs.renameSync(legacyPath, credPath);
} catch {
// Keep returning the legacy credentials even if migration fails.
}
return parsed;
} catch {
return null;
}
}
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const paths = [
resolveMatrixCredentialsPath(env, accountId),
resolveLegacyMigrationSourcePath(env, accountId),
];
for (const filePath of paths) {
if (!filePath) {
continue;
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
// ignore
}
}
}
export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string; accessToken?: string },
): boolean {
if (!config.userId) {
if (!config.accessToken) {
return false;
}
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@ -0,0 +1,18 @@
import type {
saveMatrixCredentials as saveMatrixCredentialsType,
touchMatrixCredentials as touchMatrixCredentialsType,
} from "./credentials.js";
export async function saveMatrixCredentials(
...args: Parameters<typeof saveMatrixCredentialsType>
): ReturnType<typeof saveMatrixCredentialsType> {
const runtime = await import("./credentials.js");
return runtime.saveMatrixCredentials(...args);
}
export async function touchMatrixCredentials(
...args: Parameters<typeof touchMatrixCredentialsType>
): ReturnType<typeof touchMatrixCredentialsType> {
const runtime = await import("./credentials.js");
return runtime.touchMatrixCredentials(...args);
}

View File

@ -1,119 +1,15 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
requiresExplicitMatrixDefaultAccount,
resolveMatrixDefaultOrOnlyAccountId,
} from "../account-selection.js";
import { writeJsonFileAtomically } from "../runtime-api.js";
import { getMatrixRuntime } from "../runtime.js";
import {
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
} from "../storage-paths.js";
import { loadMatrixCredentials, resolveMatrixCredentialsPath } from "./credentials-read.js";
import type { MatrixStoredCredentials } from "./credentials-read.js";
export type MatrixStoredCredentials = {
homeserver: string;
userId: string;
accessToken: string;
deviceId?: string;
createdAt: string;
lastUsedAt?: string;
};
function resolveStateDir(env: NodeJS.ProcessEnv): string {
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
}
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
}
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
const normalizedAccountId = normalizeAccountId(accountId);
const cfg = getMatrixRuntime().config.loadConfig();
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
}
if (requiresExplicitMatrixDefaultAccount(cfg)) {
return false;
}
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
}
function resolveLegacyMigrationSourcePath(
env: NodeJS.ProcessEnv,
accountId?: string | null,
): string | null {
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
return null;
}
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
}
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
if (
typeof parsed.homeserver !== "string" ||
typeof parsed.userId !== "string" ||
typeof parsed.accessToken !== "string"
) {
return null;
}
return parsed as MatrixStoredCredentials;
}
export function resolveMatrixCredentialsDir(
env: NodeJS.ProcessEnv = process.env,
stateDir?: string,
): string {
const resolvedStateDir = stateDir ?? resolveStateDir(env);
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
}
export function resolveMatrixCredentialsPath(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): string {
const resolvedStateDir = resolveStateDir(env);
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
}
export function loadMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): MatrixStoredCredentials | null {
const credPath = resolveMatrixCredentialsPath(env, accountId);
try {
if (fs.existsSync(credPath)) {
return parseMatrixCredentialsFile(credPath);
}
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
if (!legacyPath || !fs.existsSync(legacyPath)) {
return null;
}
const parsed = parseMatrixCredentialsFile(legacyPath);
if (!parsed) {
return null;
}
try {
fs.mkdirSync(path.dirname(credPath), { recursive: true });
fs.renameSync(legacyPath, credPath);
} catch {
// Keep returning the legacy credentials even if migration fails.
}
return parsed;
} catch {
return null;
}
}
export {
clearMatrixCredentials,
credentialsMatchConfig,
loadMatrixCredentials,
resolveMatrixCredentialsDir,
resolveMatrixCredentialsPath,
} from "./credentials-read.js";
export type { MatrixStoredCredentials } from "./credentials-read.js";
export async function saveMatrixCredentials(
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
@ -147,38 +43,3 @@ export async function touchMatrixCredentials(
const credPath = resolveMatrixCredentialsPath(env, accountId);
await writeJsonFileAtomically(credPath, existing);
}
export function clearMatrixCredentials(
env: NodeJS.ProcessEnv = process.env,
accountId?: string | null,
): void {
const paths = [
resolveMatrixCredentialsPath(env, accountId),
resolveLegacyMigrationSourcePath(env, accountId),
];
for (const filePath of paths) {
if (!filePath) {
continue;
}
try {
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
}
} catch {
// ignore
}
}
}
export function credentialsMatchConfig(
stored: MatrixStoredCredentials,
config: { homeserver: string; userId: string; accessToken?: string },
): boolean {
if (!config.userId) {
if (!config.accessToken) {
return false;
}
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
}
return stored.homeserver === config.homeserver && stored.userId === config.userId;
}

View File

@ -0,0 +1 @@
export { monitorMatrixProvider } from "./monitor/index.js";

View File

@ -62,7 +62,7 @@ function createHarness(params?: {
const ensureVerificationDmTracked = vi.fn(
params?.ensureVerificationDmTracked ?? (async () => null),
);
const sendMessage = vi.fn(async () => "$notice");
const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice");
const invalidateRoom = vi.fn();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const formatNativeDependencyHint = vi.fn(() => "install hint");

View File

@ -100,6 +100,7 @@ function createHandlerHarness() {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => {
mediaMaxBytes: 10_000_000,
startupMs: 0,
startupGraceMs: 0,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: async () => false,
},
getRoomInfo: async () => ({ altAliases: [] }),
getMemberDisplayName: async () => "sender",
needsRoomAliasesForConfig: false,
});
await handler(

View File

@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
mediaMaxBytes: 5 * 1024 * 1024,
startupMs: Date.now() - 120_000,
startupGraceMs: 60_000,
dropPreStartupMessages: false,
directTracker: {
isDirectMessage: vi.fn().mockResolvedValue(true),
},

View File

@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => {
hasPersistedSyncState: vi.fn(() => false),
};
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
let startClientError: Error | null = null;
const resolveTextChunkLimit = vi.fn<
(cfg: unknown, channel: unknown, accountId?: unknown) => number
>(() => 4000);
@ -18,17 +17,17 @@ const hoisted = vi.hoisted(() => {
debug: vi.fn(),
};
const stopThreadBindingManager = vi.fn();
const stopSharedClientInstance = vi.fn();
const releaseSharedClientInstance = vi.fn(async () => true);
const setActiveMatrixClient = vi.fn();
return {
callOrder,
client,
createMatrixRoomMessageHandler,
logger,
releaseSharedClientInstance,
resolveTextChunkLimit,
setActiveMatrixClient,
startClientError,
stopSharedClientInstance,
startClientError: null as Error | null,
stopThreadBindingManager,
};
});
@ -128,7 +127,10 @@ vi.mock("../client.js", () => ({
hoisted.callOrder.push("start-client");
return hoisted.client;
}),
stopSharedClientInstance: hoisted.stopSharedClientInstance,
}));
vi.mock("../client/shared.js", () => ({
releaseSharedClientInstance: hoisted.releaseSharedClientInstance,
}));
vi.mock("../config-update.js", () => ({
@ -207,8 +209,8 @@ describe("monitorMatrixProvider", () => {
hoisted.callOrder.length = 0;
hoisted.startClientError = null;
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
hoisted.setActiveMatrixClient.mockReset();
hoisted.stopSharedClientInstance.mockReset();
hoisted.stopThreadBindingManager.mockReset();
hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
@ -252,12 +254,13 @@ describe("monitorMatrixProvider", () => {
await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1);
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledTimes(1);
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
});
it("disables cold-start backlog dropping when sync state already exists", async () => {
it("disables cold-start backlog dropping only when sync state is cleanly persisted", async () => {
hoisted.client.hasPersistedSyncState.mockReturnValue(true);
const { monitorMatrixProvider } = await import("./index.js");
const abortController = new AbortController();

View File

@ -17,8 +17,8 @@ import {
resolveMatrixAuth,
resolveMatrixAuthContext,
resolveSharedMatrixClient,
stopSharedClientInstance,
} from "../client.js";
import { releaseSharedClientInstance } from "../client/shared.js";
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
import { registerMatrixAutoJoin } from "./auto-join.js";
import { resolveMatrixMonitorConfig } from "./config.js";
@ -131,7 +131,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
setActiveMatrixClient(client, auth.accountId);
let cleanedUp = false;
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
const cleanup = () => {
const cleanup = async () => {
if (cleanedUp) {
return;
}
@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
try {
threadBindingManager?.stop();
} finally {
stopSharedClientInstance(client);
await releaseSharedClientInstance(client, "persist");
setActiveMatrixClient(null, auth.accountId);
}
};
@ -273,19 +273,32 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
await new Promise<void>((resolve) => {
const onAbort = () => {
logVerboseMessage("matrix: stopping client");
cleanup();
resolve();
const stopAndResolve = async () => {
try {
logVerboseMessage("matrix: stopping client");
await cleanup();
} catch (err) {
logger.warn("matrix: failed during monitor shutdown cleanup", {
error: String(err),
});
} finally {
resolve();
}
};
if (opts.abortSignal?.aborted) {
onAbort();
void stopAndResolve();
return;
}
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
opts.abortSignal?.addEventListener(
"abort",
() => {
void stopAndResolve();
},
{ once: true },
);
});
} catch (err) {
cleanup();
await cleanup();
throw err;
}
}

View File

@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../../src/config/config.js";
import {
__testing as sessionBindingTesting,
createTestRegistry,
type OpenClawConfig,
resolveAgentRoute,
registerSessionBindingAdapter,
} from "../../../../../src/infra/outbound/session-binding-service.js";
import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js";
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js";
sessionBindingTesting,
setActivePluginRegistry,
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
import { matrixPlugin } from "../../channel.js";
import { resolveMatrixInboundRoute } from "./route.js";

View File

@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => {
it("prefers authenticated client media downloads", async () => {
const payload = Buffer.from([1, 2, 3, 4]);
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
const fetchMock = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
new Response(payload, { status: 200 }),
);
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");

View File

@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
import {
ClientEvent,
MatrixEventEvent,
Preset,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
@ -349,7 +350,9 @@ export class MatrixClient {
}
hasPersistedSyncState(): boolean {
return this.syncStore?.hasSavedSync() === true;
// Only trust restart replay when the previous process completed a final
// sync-store persist. A stale cursor can make Matrix re-surface old events.
return this.syncStore?.hasSavedSyncFromCleanShutdown() === true;
}
private async ensureStartedForCryptoControlPlane(): Promise<void> {
@ -366,6 +369,7 @@ export class MatrixClient {
}
this.decryptBridge.stop();
// Final persist on shutdown
this.syncStore?.markCleanShutdown();
this.stopPersistPromise = Promise.all([
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
@ -547,7 +551,7 @@ export class MatrixClient {
const result = await this.client.createRoom({
invite: [remoteUserId],
is_direct: true,
preset: "trusted_private_chat",
preset: Preset.TrustedPrivateChat,
initial_state: initialState,
});
return result.room_id;

View File

@ -173,6 +173,7 @@ function resolveBindingsPath(params: {
auth: MatrixAuth;
accountId: string;
env?: NodeJS.ProcessEnv;
stateDir?: string;
}): string {
const storagePaths = resolveMatrixStoragePaths({
homeserver: params.auth.homeserver,
@ -181,6 +182,7 @@ function resolveBindingsPath(params: {
accountId: params.accountId,
deviceId: params.auth.deviceId,
env: params.env,
stateDir: params.stateDir,
});
return path.join(storagePaths.rootDir, "thread-bindings.json");
}
@ -341,6 +343,7 @@ export async function createMatrixThreadBindingManager(params: {
auth: MatrixAuth;
client: MatrixClient;
env?: NodeJS.ProcessEnv;
stateDir?: string;
idleTimeoutMs: number;
maxAgeMs: number;
enableSweeper?: boolean;
@ -360,6 +363,7 @@ export async function createMatrixThreadBindingManager(params: {
auth: params.auth,
accountId: params.accountId,
env: params.env,
stateDir: params.stateDir,
});
const loaded = await loadBindingsFromDisk(filePath, params.accountId);
for (const record of loaded) {
@ -621,14 +625,6 @@ export async function createMatrixThreadBindingManager(params: {
});
return record ? toSessionBindingRecord(record, defaults) : null;
},
setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) =>
manager
.setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs })
.map((record) => toSessionBindingRecord(record, defaults)),
setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) =>
manager
.setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs })
.map((record) => toSessionBindingRecord(record, defaults)),
touch: (bindingId, at) => {
manager.touchBinding(bindingId, at);
},

View File

@ -1,8 +1,5 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import {
type ChannelSetupDmPolicy,
type ChannelSetupWizardAdapter,
} from "openclaw/plugin-sdk/setup";
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
import {
@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js";
const channel = "matrix" as const;
type MatrixOnboardingStatus = {
channel: typeof channel;
configured: boolean;
statusLines: string[];
selectionHint?: string;
quickstartScore?: number;
};
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
type MatrixOnboardingConfigureContext = {
cfg: CoreConfig;
runtime: RuntimeEnv;
prompter: WizardPrompter;
options?: unknown;
forceAllowFrom: boolean;
accountOverrides: MatrixAccountOverrides;
shouldPromptAccountIds: boolean;
};
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
configured: boolean;
label?: string;
};
type MatrixOnboardingAdapter = {
channel: typeof channel;
getStatus: (ctx: {
cfg: CoreConfig;
options?: unknown;
accountOverrides: MatrixAccountOverrides;
}) => Promise<MatrixOnboardingStatus>;
configure: (
ctx: MatrixOnboardingConfigureContext,
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
configureInteractive?: (
ctx: MatrixOnboardingInteractiveContext,
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
afterConfigWritten?: (ctx: {
previousCfg: CoreConfig;
cfg: CoreConfig;
accountId: string;
runtime: RuntimeEnv;
}) => Promise<void> | void;
dmPolicy?: ChannelSetupDmPolicy;
disable?: (cfg: CoreConfig) => CoreConfig;
};
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
return normalizeAccountId(
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
@ -473,7 +518,7 @@ async function runMatrixConfigure(params: {
return { cfg: next, accountId };
}
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
channel,
getStatus: async ({ cfg, accountOverrides }) => {
const resolvedCfg = cfg as CoreConfig;

View File

@ -119,6 +119,25 @@ describe("handleMatrixAction pollVote", () => {
).rejects.toThrow("pollId required");
});
it("accepts messageId as a pollId alias for poll votes", async () => {
const cfg = {} as CoreConfig;
await handleMatrixAction(
{
action: "pollVote",
roomId: "!room:example",
messageId: "$poll",
pollOptionIndex: 1,
},
cfg,
);
expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", {
cfg,
optionIds: [],
optionIndexes: [1],
});
});
it("passes account-scoped opts to add reactions", async () => {
const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig;
await handleMatrixAction(

View File

@ -97,6 +97,27 @@ function readRawParam(params: Record<string, unknown>, key: string): unknown {
return undefined;
}
function readStringAliasParam(
params: Record<string, unknown>,
keys: string[],
options: { required?: boolean } = {},
): string | undefined {
for (const key of keys) {
const raw = readRawParam(params, key);
if (typeof raw !== "string") {
continue;
}
const trimmed = raw.trim();
if (trimmed) {
return trimmed;
}
}
if (options.required) {
throw new Error(`${keys[0]} required`);
}
return undefined;
}
function readNumericArrayParam(
params: Record<string, unknown>,
key: string,
@ -169,7 +190,10 @@ export async function handleMatrixAction(
if (pollActions.has(action)) {
const roomId = readRoomId(params);
const pollId = readStringParam(params, "pollId", { required: true });
const pollId = readStringAliasParam(params, ["pollId", "messageId"], { required: true });
if (!pollId) {
throw new Error("pollId required");
}
const optionId = readStringParam(params, "pollOptionId");
const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true });
const optionIds = [

View File

@ -1 +1 @@
export * from "../../src/plugin-sdk/nextcloud-talk.js";
export * from "openclaw/plugin-sdk/nextcloud-talk";

View File

@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../src/config/config.js";
import { peekSystemEvents } from "../../../src/infra/system-events.js";
import { resolveAgentRoute } from "../../../src/routing/resolve-route.js";
import { normalizeE164 } from "../../../src/utils.js";
import type { SignalDaemonExitEvent } from "./daemon.js";
@ -16,7 +15,11 @@ import {
installSignalToolResultTestHooks();
// Import after the harness registers `vi.mock(...)` for Signal internals.
const { monitorSignalProvider } = await import("./monitor.js");
vi.resetModules();
const [{ peekSystemEvents }, { monitorSignalProvider }] = await Promise.all([
import("openclaw/plugin-sdk/infra-runtime"),
import("./monitor.js"),
]);
const {
replyMock,
@ -76,6 +79,7 @@ function createAutoAbortController() {
async function runMonitorWithMocks(opts: MonitorSignalProviderOptions) {
return monitorSignalProvider({
config: config as OpenClawConfig,
waitForTransportReady: waitForTransportReadyMock as any,
...opts,
});
}

View File

@ -171,7 +171,7 @@ export function installSignalToolResultTestHooks() {
replyMock.mockReset();
updateLastRouteMock.mockReset();
streamMock.mockReset();
signalCheckMock.mockReset().mockResolvedValue({});
signalCheckMock.mockReset().mockResolvedValue({ ok: true });
signalRpcRequestMock.mockReset().mockResolvedValue({});
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
readAllowFromStoreMock.mockReset().mockResolvedValue([]);

View File

@ -1,11 +1,11 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
import {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
@ -13,13 +13,13 @@ import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
chunkTextWithMode,
resolveChunkMode,
resolveTextChunkLimit,
} from "openclaw/plugin-sdk/reply-runtime";
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
@ -56,6 +56,7 @@ export type MonitorSignalOpts = {
groupAllowFrom?: Array<string | number>;
mediaMaxMb?: number;
reconnectPolicy?: Partial<BackoffPolicy>;
waitForTransportReady?: typeof waitForTransportReady;
};
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
@ -217,8 +218,10 @@ async function waitForSignalDaemonReady(params: {
logAfterMs: number;
logIntervalMs?: number;
runtime: RuntimeEnv;
waitForTransportReadyFn?: typeof waitForTransportReady;
}): Promise<void> {
await waitForTransportReady({
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
await waitForTransportReadyFn({
label: "signal daemon",
timeoutMs: params.timeoutMs,
logAfterMs: params.logAfterMs,
@ -374,6 +377,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
const startupTimeoutMs = Math.min(
@ -416,6 +420,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
logAfterMs: 10_000,
logIntervalMs: 10_000,
runtime,
waitForTransportReadyFn,
});
const daemonExitError = daemonLifecycle.getExitError();
if (daemonExitError) {

View File

@ -13,10 +13,10 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
import type { TelegramInlineButtons } from "../button-types.js";
import { splitTelegramCaption } from "../caption.js";
@ -238,6 +238,7 @@ async function deliverMediaReply(params: {
tableMode?: MarkdownTableMode;
mediaLocalRoots?: readonly string[];
chunkText: ChunkTextFn;
mediaLoader: typeof loadWebMedia;
onVoiceRecording?: () => Promise<void> | void;
linkPreview?: boolean;
silent?: boolean;
@ -252,7 +253,7 @@ async function deliverMediaReply(params: {
let pendingFollowUpText: string | undefined;
for (const mediaUrl of params.mediaList) {
const isFirstMedia = first;
const media = await loadWebMedia(
const media = await params.mediaLoader(
mediaUrl,
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
);
@ -569,12 +570,15 @@ export async function deliverReplies(params: {
silent?: boolean;
/** Optional quote text for Telegram reply_parameters. */
replyQuoteText?: string;
/** Override media loader (tests). */
mediaLoader?: typeof loadWebMedia;
}): Promise<{ delivered: boolean }> {
const progress: DeliveryProgress = {
hasReplied: false,
hasDelivered: false,
deliveredCount: 0,
};
const mediaLoader = params.mediaLoader ?? loadWebMedia;
const hookRunner = getGlobalHookRunner();
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
@ -663,6 +667,7 @@ export async function deliverReplies(params: {
tableMode: params.tableMode,
mediaLocalRoots: params.mediaLocalRoots,
chunkText,
mediaLoader,
onVoiceRecording: params.onVoiceRecording,
linkPreview: params.linkPreview,
silent: params.silent,

View File

@ -1,9 +1,10 @@
import type { Bot } from "grammy";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../../../src/runtime.js";
import { deliverReplies } from "./delivery.js";
const loadWebMedia = vi.fn();
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
const messageHookRunner = vi.hoisted(() => ({
hasHooks: vi.fn<(name: string) => boolean>(() => false),
@ -21,12 +22,15 @@ type DeliverWithParams = Omit<
DeliverRepliesParams,
"chatId" | "token" | "replyToMode" | "textLimit"
> &
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit" | "mediaLoader">>;
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
}));
vi.mock("openclaw/plugin-sdk/web-media.js", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
}));
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: () => messageHookRunner,
@ -42,6 +46,9 @@ vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
};
});
vi.resetModules();
const { deliverReplies } = await import("./delivery.js");
vi.mock("grammy", () => ({
InputFile: class {
constructor(
@ -70,6 +77,7 @@ async function deliverWith(params: DeliverWithParams) {
await deliverReplies({
...baseDeliveryParams,
...params,
mediaLoader: params.mediaLoader ?? loadWebMedia,
});
}

View File

@ -0,0 +1,36 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type ActiveListenerModule = typeof import("./active-listener.js");
const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href;
async function importActiveListenerModule(cacheBust: string): Promise<ActiveListenerModule> {
return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule;
}
afterEach(async () => {
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
mod.setActiveWebListener(null);
mod.setActiveWebListener("work", null);
});
describe("active WhatsApp listener singleton", () => {
it("shares listeners across duplicate module instances", async () => {
const first = await importActiveListenerModule(`first-${Date.now()}`);
const second = await importActiveListenerModule(`second-${Date.now()}`);
const listener = {
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
sendReaction: vi.fn(async () => {}),
sendComposingTo: vi.fn(async () => {}),
};
first.setActiveWebListener("work", listener);
expect(second.getActiveWebListener("work")).toBe(listener);
expect(second.requireActiveWebListener("work")).toEqual({
accountId: "work",
listener,
});
});
});

View File

@ -28,27 +28,22 @@ export type ActiveWebListener = {
close?: () => Promise<void>;
};
// Use a process-level singleton to survive bundler code-splitting.
// Rolldown duplicates this module across multiple output chunks, each with its
// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's
// Map via setActiveWebListener(), but the outbound send path reads from a
// different chunk's Map via requireActiveWebListener() — so the listener is
// never found. Pinning the Map to globalThis ensures all chunks share one
// instance. See: https://github.com/openclaw/openclaw/issues/14406
const GLOBAL_KEY = "__openclaw_wa_listeners" as const;
const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const;
// Use process-global symbol keys to survive bundler code-splitting and loader
// cache splits without depending on fragile string property names.
const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners");
const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener");
type GlobalWithListeners = typeof globalThis & {
[GLOBAL_KEY]?: Map<string, ActiveWebListener>;
[GLOBAL_LISTENERS_KEY]?: Map<string, ActiveWebListener>;
[GLOBAL_CURRENT_KEY]?: ActiveWebListener | null;
};
const _global = globalThis as GlobalWithListeners;
_global[GLOBAL_KEY] ??= new Map<string, ActiveWebListener>();
_global[GLOBAL_LISTENERS_KEY] ??= new Map<string, ActiveWebListener>();
_global[GLOBAL_CURRENT_KEY] ??= null;
const listeners = _global[GLOBAL_KEY];
const listeners = _global[GLOBAL_LISTENERS_KEY];
function getCurrentListener(): ActiveWebListener | null {
return _global[GLOBAL_CURRENT_KEY] ?? null;

View File

@ -0,0 +1,340 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"type": "string",
"defaultValue": "westus2",
"metadata": {
"description": "Azure region for all resources. Any valid Azure region is allowed (no allowedValues restriction)."
}
},
"vmName": {
"type": "string",
"defaultValue": "vm-openclaw",
"metadata": {
"description": "OpenClaw VM name."
}
},
"vmSize": {
"type": "string",
"defaultValue": "Standard_B2as_v2",
"metadata": {
"description": "Azure VM size for OpenClaw host."
}
},
"adminUsername": {
"type": "string",
"defaultValue": "openclaw",
"minLength": 1,
"maxLength": 32,
"metadata": {
"description": "Linux admin username."
}
},
"sshPublicKey": {
"type": "string",
"metadata": {
"description": "SSH public key content (for example ssh-ed25519 ...)."
}
},
"vnetName": {
"type": "string",
"defaultValue": "vnet-openclaw",
"metadata": {
"description": "Virtual network name."
}
},
"vnetAddressPrefix": {
"type": "string",
"defaultValue": "10.40.0.0/16",
"metadata": {
"description": "Address space for the virtual network."
}
},
"vmSubnetName": {
"type": "string",
"defaultValue": "snet-openclaw-vm",
"metadata": {
"description": "Subnet name for OpenClaw VM."
}
},
"vmSubnetPrefix": {
"type": "string",
"defaultValue": "10.40.2.0/24",
"metadata": {
"description": "Address prefix for VM subnet."
}
},
"bastionSubnetPrefix": {
"type": "string",
"defaultValue": "10.40.1.0/26",
"metadata": {
"description": "Address prefix for AzureBastionSubnet (must be /26 or larger)."
}
},
"nsgName": {
"type": "string",
"defaultValue": "nsg-openclaw-vm",
"metadata": {
"description": "Network security group for VM subnet."
}
},
"nicName": {
"type": "string",
"defaultValue": "nic-openclaw-vm",
"metadata": {
"description": "NIC for OpenClaw VM."
}
},
"bastionName": {
"type": "string",
"defaultValue": "bas-openclaw",
"metadata": {
"description": "Azure Bastion host name."
}
},
"bastionPublicIpName": {
"type": "string",
"defaultValue": "pip-openclaw-bastion",
"metadata": {
"description": "Public IP used by Bastion."
}
},
"osDiskSizeGb": {
"type": "int",
"defaultValue": 64,
"minValue": 30,
"maxValue": 1024,
"metadata": {
"description": "OS disk size in GiB."
}
}
},
"variables": {
"bastionSubnetName": "AzureBastionSubnet"
},
"resources": [
{
"type": "Microsoft.Network/networkSecurityGroups",
"apiVersion": "2023-11-01",
"name": "[parameters('nsgName')]",
"location": "[parameters('location')]",
"properties": {
"securityRules": [
{
"name": "AllowSshFromAzureBastionSubnet",
"properties": {
"priority": 100,
"access": "Allow",
"direction": "Inbound",
"protocol": "Tcp",
"sourcePortRange": "*",
"destinationPortRange": "22",
"sourceAddressPrefix": "[parameters('bastionSubnetPrefix')]",
"destinationAddressPrefix": "*"
}
},
{
"name": "DenyInternetSsh",
"properties": {
"priority": 110,
"access": "Deny",
"direction": "Inbound",
"protocol": "Tcp",
"sourcePortRange": "*",
"destinationPortRange": "22",
"sourceAddressPrefix": "Internet",
"destinationAddressPrefix": "*"
}
},
{
"name": "DenyVnetSsh",
"properties": {
"priority": 120,
"access": "Deny",
"direction": "Inbound",
"protocol": "Tcp",
"sourcePortRange": "*",
"destinationPortRange": "22",
"sourceAddressPrefix": "VirtualNetwork",
"destinationAddressPrefix": "*"
}
}
]
}
},
{
"type": "Microsoft.Network/virtualNetworks",
"apiVersion": "2023-11-01",
"name": "[parameters('vnetName')]",
"location": "[parameters('location')]",
"properties": {
"addressSpace": {
"addressPrefixes": ["[parameters('vnetAddressPrefix')]"]
},
"subnets": [
{
"name": "[variables('bastionSubnetName')]",
"properties": {
"addressPrefix": "[parameters('bastionSubnetPrefix')]"
}
},
{
"name": "[parameters('vmSubnetName')]",
"properties": {
"addressPrefix": "[parameters('vmSubnetPrefix')]",
"networkSecurityGroup": {
"id": "[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
}
}
}
]
},
"dependsOn": [
"[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
]
},
{
"type": "Microsoft.Network/publicIPAddresses",
"apiVersion": "2023-11-01",
"name": "[parameters('bastionPublicIpName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard"
},
"properties": {
"publicIPAllocationMethod": "Static"
}
},
{
"type": "Microsoft.Network/bastionHosts",
"apiVersion": "2023-11-01",
"name": "[parameters('bastionName')]",
"location": "[parameters('location')]",
"sku": {
"name": "Standard"
},
"dependsOn": [
"[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]",
"[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
],
"properties": {
"enableTunneling": true,
"ipConfigurations": [
{
"name": "bastionIpConfig",
"properties": {
"subnet": {
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), variables('bastionSubnetName'))]"
},
"publicIPAddress": {
"id": "[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
}
}
}
]
}
},
{
"type": "Microsoft.Network/networkInterfaces",
"apiVersion": "2023-11-01",
"name": "[parameters('nicName')]",
"location": "[parameters('location')]",
"dependsOn": ["[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]"],
"properties": {
"ipConfigurations": [
{
"name": "ipconfig1",
"properties": {
"privateIPAllocationMethod": "Dynamic",
"subnet": {
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('vmSubnetName'))]"
}
}
}
]
}
},
{
"type": "Microsoft.Compute/virtualMachines",
"apiVersion": "2023-09-01",
"name": "[parameters('vmName')]",
"location": "[parameters('location')]",
"dependsOn": ["[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"],
"properties": {
"hardwareProfile": {
"vmSize": "[parameters('vmSize')]"
},
"osProfile": {
"computerName": "[parameters('vmName')]",
"adminUsername": "[parameters('adminUsername')]",
"linuxConfiguration": {
"disablePasswordAuthentication": true,
"ssh": {
"publicKeys": [
{
"path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]",
"keyData": "[parameters('sshPublicKey')]"
}
]
}
}
},
"storageProfile": {
"imageReference": {
"publisher": "Canonical",
"offer": "ubuntu-24_04-lts",
"sku": "server",
"version": "latest"
},
"osDisk": {
"createOption": "FromImage",
"diskSizeGB": "[parameters('osDiskSizeGb')]",
"managedDisk": {
"storageAccountType": "StandardSSD_LRS"
}
}
},
"networkProfile": {
"networkInterfaces": [
{
"id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"
}
]
},
"diagnosticsProfile": {
"bootDiagnostics": {
"enabled": true
}
}
}
}
],
"outputs": {
"vmName": {
"type": "string",
"value": "[parameters('vmName')]"
},
"vmPrivateIp": {
"type": "string",
"value": "[reference(resourceId('Microsoft.Network/networkInterfaces', parameters('nicName')), '2023-11-01').ipConfigurations[0].properties.privateIPAddress]"
},
"vnetName": {
"type": "string",
"value": "[parameters('vnetName')]"
},
"vmSubnetName": {
"type": "string",
"value": "[parameters('vmSubnetName')]"
},
"bastionName": {
"type": "string",
"value": "[parameters('bastionName')]"
},
"bastionResourceId": {
"type": "string",
"value": "[resourceId('Microsoft.Network/bastionHosts', parameters('bastionName'))]"
}
}
}

View File

@ -0,0 +1,48 @@
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"location": {
"value": "westus2"
},
"vmName": {
"value": "vm-openclaw"
},
"vmSize": {
"value": "Standard_B2as_v2"
},
"adminUsername": {
"value": "openclaw"
},
"vnetName": {
"value": "vnet-openclaw"
},
"vnetAddressPrefix": {
"value": "10.40.0.0/16"
},
"vmSubnetName": {
"value": "snet-openclaw-vm"
},
"vmSubnetPrefix": {
"value": "10.40.2.0/24"
},
"bastionSubnetPrefix": {
"value": "10.40.1.0/26"
},
"nsgName": {
"value": "nsg-openclaw-vm"
},
"nicName": {
"value": "nic-openclaw-vm"
},
"bastionName": {
"value": "bas-openclaw"
},
"bastionPublicIpName": {
"value": "pip-openclaw-bastion"
},
"osDiskSizeGb": {
"value": 64
}
}
}

View File

@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js";
import {
__testing as sessionBindingServiceTesting,
registerSessionBindingAdapter,
type SessionBindingPlacement,
type SessionBindingRecord,
} from "../infra/outbound/session-binding-service.js";
import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js";
@ -104,7 +105,7 @@ function createSessionBindingCapabilities() {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] as const,
placements: ["current", "child"] satisfies SessionBindingPlacement[],
};
}
@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => {
metaCleared: false,
});
getAcpSessionManagerSpy.mockReset().mockReturnValue({
initializeSession: async (params) => await hoisted.initializeSessionMock(params),
closeSession: async (params) => await hoisted.closeSessionMock(params),
initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params),
closeSession: async (params: unknown) => await hoisted.closeSessionMock(params),
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as {
@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => {
...hoisted.state.cfg.channels,
telegram: {
threadBindings: {
spawnAcpSessions: true,
enabled: true,
},
},
},

View File

@ -11,20 +11,12 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num
});
}
function drainChunks(chunker: EmbeddedBlockChunker) {
function drainChunks(chunker: EmbeddedBlockChunker, force = false) {
const chunks: string[] = [];
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
chunker.drain({ force, emit: (chunk) => chunks.push(chunk) });
return chunks;
}
function expectFlushAtFirstParagraphBreak(text: string) {
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
chunker.append(text);
const chunks = drainChunks(chunker);
expect(chunks).toEqual(["First paragraph."]);
expect(chunker.bufferedText).toBe("Second paragraph.");
}
describe("EmbeddedBlockChunker", () => {
it("breaks at paragraph boundary right after fence close", () => {
const chunker = new EmbeddedBlockChunker({
@ -54,12 +46,25 @@ describe("EmbeddedBlockChunker", () => {
expect(chunker.bufferedText).toMatch(/^After/);
});
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
it("waits until minChars before flushing paragraph boundaries when flushOnParagraph is set", () => {
const chunker = createFlushOnParagraphChunker({ minChars: 30, maxChars: 200 });
chunker.append("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.");
const chunks = drainChunks(chunker);
expect(chunks).toEqual(["First paragraph.\n\nSecond paragraph."]);
expect(chunker.bufferedText).toBe("Third paragraph.");
});
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
it("still force flushes buffered paragraphs below minChars at the end", () => {
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
chunker.append("First paragraph.\n \nSecond paragraph.");
expect(drainChunks(chunker)).toEqual([]);
expect(drainChunks(chunker, true)).toEqual(["First paragraph.\n \nSecond paragraph."]);
expect(chunker.bufferedText).toBe("");
});
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
@ -97,7 +102,7 @@ describe("EmbeddedBlockChunker", () => {
it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => {
const chunker = new EmbeddedBlockChunker({
minChars: 100,
minChars: 10,
maxChars: 200,
breakPreference: "paragraph",
flushOnParagraph: true,

View File

@ -5,7 +5,7 @@ export type BlockReplyChunking = {
minChars: number;
maxChars: number;
breakPreference?: "paragraph" | "newline" | "sentence";
/** When true, flush eagerly on \n\n paragraph boundaries regardless of minChars. */
/** When true, prefer \n\n paragraph boundaries once minChars has been satisfied. */
flushOnParagraph?: boolean;
};
@ -129,7 +129,7 @@ export class EmbeddedBlockChunker {
const minChars = Math.max(1, Math.floor(this.#chunking.minChars));
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) {
if (this.#buffer.length < minChars && !force) {
return;
}
@ -150,12 +150,12 @@ export class EmbeddedBlockChunker {
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
const remainingLength = reopenPrefix.length + (source.length - start);
if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) {
if (!force && remainingLength < minChars) {
break;
}
if (this.#chunking.flushOnParagraph && !force) {
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start);
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars);
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
@ -175,12 +175,7 @@ export class EmbeddedBlockChunker {
const breakResult =
force && remainingLength <= maxChars
? this.#pickSoftBreakIndex(view, fenceSpans, 1, start)
: this.#pickBreakIndex(
view,
fenceSpans,
force || this.#chunking.flushOnParagraph ? 1 : undefined,
start,
);
: this.#pickBreakIndex(view, fenceSpans, force ? 1 : undefined, start);
if (breakResult.index <= 0) {
if (force) {
emit(`${reopenPrefix}${source.slice(start)}`);
@ -205,7 +200,7 @@ export class EmbeddedBlockChunker {
const nextLength =
(reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start);
if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) {
if (nextLength < minChars && !force) {
break;
}
if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) {
@ -401,6 +396,7 @@ function findNextParagraphBreak(
buffer: string,
fenceSpans: FenceSpan[],
startIndex = 0,
minCharsFromStart = 1,
): ParagraphBreak | null {
if (startIndex < 0) {
return null;
@ -413,6 +409,9 @@ function findNextParagraphBreak(
if (index < 0) {
continue;
}
if (index - startIndex < minCharsFromStart) {
continue;
}
if (!isSafeFenceBreak(fenceSpans, index)) {
continue;
}

View File

@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn(
const embeddedRunMock = {
isEmbeddedPiRunActive: vi.fn(() => false),
isEmbeddedPiRunStreaming: vi.fn(() => false),
queueEmbeddedPiMessage: vi.fn(() => false),
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false),
waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true),
};
const { subagentRegistryMock } = vi.hoisted(() => ({
subagentRegistryMock: {
@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void {
setRuntimeConfigSnapshot(configOverride);
}
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
return new Proxy(sessionStore, {
function loadSessionStoreFixture(): ReturnType<typeof configSessions.loadSessionStore> {
return new Proxy(sessionStore as ReturnType<typeof configSessions.loadSessionStore>, {
get(target, key: string | symbol) {
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
return {
sessionId: key,
updatedAt: Date.now(),
inputTokens: 1,
outputTokens: 1,
totalTokens: 2,
};
}
return target[key as keyof typeof target];
},
@ -207,7 +213,11 @@ describe("subagent announce formatting", () => {
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock);
getGlobalHookRunnerSpy
.mockReset()
.mockImplementation(
() => hookRunnerMock as unknown as ReturnType<typeof hookRunnerGlobal.getGlobalHookRunner>,
);
readLatestAssistantReplySpy
.mockReset()
.mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey));

View File

@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
}
function formatTimestamp(
export function formatEnvelopeTimestamp(
ts: number | Date | undefined,
options?: EnvelopeFormatOptions,
): string | undefined {
@ -179,7 +179,7 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
if (params.ip?.trim()) {
parts.push(sanitizeEnvelopeHeaderPart(params.ip.trim()));
}
const ts = formatTimestamp(params.timestamp, resolved);
const ts = formatEnvelopeTimestamp(params.timestamp, resolved);
if (ts) {
parts.push(ts);
}

View File

@ -89,8 +89,8 @@ export function createBlockReplyCoalescer(params: {
return;
}
// When flushOnEnqueue is set (chunkMode="newline"), each enqueued payload is treated
// as a separate paragraph and flushed immediately so delivery matches streaming boundaries.
// When flushOnEnqueue is set, treat each enqueued payload as its own outbound block
// and flush immediately instead of waiting for coalescing thresholds.
if (flushOnEnqueue) {
if (bufferText) {
void flush({ force: true });

View File

@ -44,6 +44,34 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
expect(resolved.coalescing.idleMs).toBe(0);
});
it("honors newline chunkMode for plugin channels even before the plugin registry is loaded", () => {
const cfg = {
channels: {
bluebubbles: {
chunkMode: "newline",
},
},
agents: {
defaults: {
blockStreamingChunk: {
minChars: 1,
maxChars: 4000,
breakPreference: "paragraph",
},
},
},
} as OpenClawConfig;
const resolved = resolveEffectiveBlockStreamingConfig({
cfg,
provider: "bluebubbles",
});
expect(resolved.chunking.flushOnParagraph).toBe(true);
expect(resolved.coalescing.flushOnEnqueue).toBeUndefined();
expect(resolved.coalescing.joiner).toBe("\n\n");
});
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
const cfg = {
channels: {

View File

@ -3,26 +3,22 @@ import type { OpenClawConfig } from "../../config/config.js";
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
import { resolveAccountEntry } from "../../routing/account-lookup.js";
import { normalizeAccountId } from "../../routing/session-key.js";
import {
INTERNAL_MESSAGE_CHANNEL,
listDeliverableMessageChannels,
} from "../../utils/message-channel.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const getBlockChunkProviders = () =>
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
if (!provider) {
return undefined;
}
const cleaned = provider.trim().toLowerCase();
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider)
: undefined;
const normalized = normalizeMessageChannel(provider);
if (!normalized) {
return undefined;
}
return normalized as TextChunkProvider;
}
function resolveProviderChunkContext(
@ -70,7 +66,7 @@ export type BlockStreamingCoalescing = {
maxChars: number;
idleMs: number;
joiner: string;
/** When true, the coalescer flushes the buffer on each enqueue (paragraph-boundary flush). */
/** Internal escape hatch for transports that truly need per-enqueue flushing. */
flushOnEnqueue?: boolean;
};
@ -151,7 +147,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
: chunking.breakPreference === "newline"
? "\n"
: "\n\n"),
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
...(coalescingDefaults?.flushOnEnqueue === true ? { flushOnEnqueue: true } : {}),
};
return { chunking, coalescing };
@ -165,9 +161,9 @@ export function resolveBlockStreamingChunking(
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// When chunkMode="newline", the outbound delivery splits on paragraph boundaries.
// The block chunker should flush eagerly on \n\n boundaries during streaming,
// regardless of minChars, so each paragraph is sent as its own message.
// When chunkMode="newline", outbound delivery prefers paragraph boundaries.
// Keep the chunker paragraph-aware during streaming, but still let minChars
// control when a buffered paragraph is ready to flush.
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
@ -196,7 +192,6 @@ export function resolveBlockStreamingCoalescing(
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
},
opts?: { chunkMode?: "length" | "newline" },
): BlockStreamingCoalescing | undefined {
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
cfg,
@ -204,9 +199,6 @@ export function resolveBlockStreamingCoalescing(
accountId,
);
// Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries
// when chunkMode="newline", matching the delivery-time splitting behavior.
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
const providerDefaults = providerId
? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults
: undefined;
@ -241,6 +233,5 @@ export function resolveBlockStreamingCoalescing(
maxChars,
idleMs,
joiner,
flushOnEnqueue: chunkMode === "newline",
};
}

View File

@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "telegram";
}
export function isMatrixSurface(params: DiscordSurfaceParams): boolean {
return resolveCommandSurfaceChannel(params) === "matrix";
}
export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string {
const channel =
params.ctx.OriginatingChannel ??

View File

@ -120,7 +120,7 @@ type FakeBinding = {
targetSessionKey: string;
targetKind: "subagent" | "session";
conversation: {
channel: "discord" | "telegram" | "feishu";
channel: "discord" | "matrix" | "telegram" | "feishu";
accountId: string;
conversationId: string;
parentConversationId?: string;
@ -245,9 +245,10 @@ function createSessionBindingCapabilities() {
type AcpBindInput = {
targetSessionKey: string;
conversation: {
channel?: "discord" | "telegram" | "feishu";
channel?: "discord" | "matrix" | "telegram" | "feishu";
accountId: string;
conversationId: string;
parentConversationId?: string;
};
placement: "current" | "child";
metadata?: Record<string, unknown>;
@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
conversationId: nextConversationId,
parentConversationId: "parent-1",
}
: channel === "feishu"
: channel === "matrix"
? {
channel: "feishu" as const,
channel: "matrix" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
parentConversationId:
input.placement === "child"
? input.conversation.conversationId
: input.conversation.parentConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
: channel === "feishu"
? {
channel: "feishu" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
}
: {
channel: "telegram" as const,
accountId: input.conversation.accountId,
conversationId: nextConversationId,
};
return createSessionBinding({
targetSessionKey: input.targetSessionKey,
conversation,
@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
}
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = createMatrixRoomParams(commandBody, cfg);
params.ctx.MessageThreadId = "$thread-root";
return params;
}
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
}
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
}
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "feishu",
@ -598,6 +635,63 @@ describe("/acp command", () => {
);
});
it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "!room:example.org",
}),
}),
);
});
it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
expect(result?.reply?.text).toContain("Bound this thread to");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "current",
conversation: expect.objectContaining({
channel: "matrix",
accountId: "default",
conversationId: "$thread-root",
parentConversationId: "!room:example.org",
}),
}),
);
});
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
@ -654,6 +748,24 @@ describe("/acp command", () => {
);
});
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("forbids /acp spawn from sandboxed requester sessions", async () => {
const cfg = {
...baseCfg,

View File

@ -141,6 +141,27 @@ describe("commands-acp context", () => {
expect(resolveAcpCommandConversationId(params)).toBe("123456789");
});
it("resolves Matrix thread context from the current room and thread root", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "work",
MessageThreadId: "$thread-root",
});
expect(resolveAcpCommandBindingContext(params)).toEqual({
channel: "matrix",
accountId: "work",
threadId: "$thread-root",
conversationId: "$thread-root",
parentConversationId: "!room:example.org",
});
expect(resolveAcpCommandConversationId(params)).toBe("$thread-root");
expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org");
});
it("builds Feishu topic conversation ids from chat target + root message id", () => {
const params = buildCommandTestParams("/acp status", baseCfg, {
Provider: "feishu",

View File

@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin
import { parseAgentSessionKey } from "../../../routing/session-key.js";
import type { HandleCommandsParams } from "../commands-types.js";
import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import { resolveTelegramConversationId } from "../telegram-context.js";
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string
export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
const telegramConversationId = resolveTelegramConversationId({
ctx: {
@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId(
params: HandleCommandsParams,
): string | undefined {
const channel = resolveAcpCommandChannel(params);
if (channel === "matrix") {
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
if (channel === "telegram") {
return (
parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ??

View File

@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const senderId = commandParams.command.senderId?.trim() || "";
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
const conversationRef = {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: currentConversationId,
...(parentConversationId && parentConversationId !== currentConversationId
? { parentConversationId }
: {}),
};
if (placement === "current") {
const existingBinding = bindingService.resolveByConversation({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId: currentConversationId,
});
const existingBinding = bindingService.resolveByConversation(conversationRef);
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
? existingBinding.metadata.boundBy.trim()
@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: {
}
const label = params.label || params.agentId;
const conversationId = currentConversationId;
try {
const binding = await bindingService.bind({
targetSessionKey: params.sessionKey,
targetKind: "session",
conversation: {
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
conversationId,
},
conversation: conversationRef,
placement,
metadata: {
threadName: resolveThreadBindingThreadName({

View File

@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto";
import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js";
import type { AcpRuntimeError } from "../../../acp/runtime/errors.js";
import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js";
import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js";
import {
DISCORD_THREAD_BINDING_CHANNEL,
MATRIX_THREAD_BINDING_CHANNEL,
} from "../../../channels/thread-bindings-policy.js";
import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js";
import { normalizeAgentId } from "../../../routing/session-key.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string {
}
function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode {
if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) {
const channel = resolveAcpCommandChannel(params);
if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) {
return "off";
}
const currentThreadId = resolveAcpCommandThreadId(params);

View File

@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => {
const getThreadBindingManagerMock = vi.fn();
const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn();
const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn();
const sessionBindingResolveByConversationMock = vi.fn();
@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => {
getThreadBindingManagerMock,
setThreadBindingIdleTimeoutBySessionKeyMock,
setThreadBindingMaxAgeBySessionKeyMock,
setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMatrixThreadBindingMaxAgeBySessionKeyMock,
setTelegramThreadBindingIdleTimeoutBySessionKeyMock,
setTelegramThreadBindingMaxAgeBySessionKeyMock,
sessionBindingResolveByConversationMock,
@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => {
setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock,
},
},
matrix: {
threadBindings: {
setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock,
},
},
},
}),
};
@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record<str
});
}
function createMatrixThreadCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
MessageThreadId: "$thread-1",
...overrides,
});
}
function createMatrixRoomCommandParams(commandBody: string, overrides?: Record<string, unknown>) {
return buildCommandTestParams(commandBody, baseCfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
...overrides,
});
}
function createFakeBinding(overrides: Partial<FakeBinding> = {}): FakeBinding {
const now = Date.now();
return {
@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial<SessionBindingRecord>): Sessi
};
}
function createMatrixBinding(overrides?: Partial<SessionBindingRecord>): SessionBindingRecord {
return {
bindingId: "default:$thread-1",
targetSessionKey: "agent:main:subagent:child",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
},
status: "active",
boundAt: Date.now(),
metadata: {
boundBy: "user-1",
lastActivityAt: Date.now(),
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
},
...overrides,
};
}
function expectIdleTimeoutSetReply(
mock: ReturnType<typeof vi.fn>,
text: string,
@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => {
hoisted.getThreadBindingManagerMock.mockReset();
hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset();
hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset();
hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null);
@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => {
);
});
it("sets idle timeout for focused Matrix threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding());
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt: Date.now(),
lastActivityAt: Date.now(),
idleTimeoutMs: 2 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createMatrixThreadCommandParams("/session idle 2h"),
true,
);
const text = result?.reply?.text ?? "";
expectIdleTimeoutSetReply(
hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock,
text,
2 * 60 * 60 * 1000,
"2h",
);
});
it("sets max age for focused Matrix threads", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
const boundAt = Date.parse("2026-02-19T22:00:00.000Z");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createMatrixBinding({ boundAt }),
);
hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([
{
targetSessionKey: "agent:main:subagent:child",
boundAt,
lastActivityAt: Date.now(),
maxAgeMs: 3 * 60 * 60 * 1000,
},
]);
const result = await handleSessionCommand(
createMatrixThreadCommandParams("/session max-age 3h"),
true,
);
const text = result?.reply?.text ?? "";
expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({
targetSessionKey: "agent:main:subagent:child",
accountId: "default",
maxAgeMs: 3 * 60 * 60 * 1000,
});
expect(text).toContain("Max age set to 3h");
expect(text).toContain("2026-02-20T01:00:00.000Z");
});
it("reports Telegram max-age expiry from the original bind time", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z"));
@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => {
const params = buildCommandTestParams("/session idle 2h", baseCfg);
const result = await handleSessionCommand(params, true);
expect(result?.reply?.text).toContain(
"currently available for Discord and Telegram bound sessions",
"currently available for Discord, Matrix, and Telegram bound sessions",
);
});
it("requires a focused Matrix thread for lifecycle updates", async () => {
const result = await handleSessionCommand(
createMatrixRoomCommandParams("/session idle 2h"),
true,
);
expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread");
expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled();
});
it("requires binding owner for lifecycle updates", async () => {
const binding = createFakeBinding({ boundBy: "owner-1" });
hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding));

View File

@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js";
import { parseActivationCommand } from "../group-activation.js";
import { parseSendPolicyCommand } from "../send-policy.js";
import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js";
import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js";
import {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
} from "./channel-context.js";
import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js";
import { persistSessionEntry } from "./commands-session-store.js";
import type { CommandHandler } from "./commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "./matrix-context.js";
import { resolveTelegramConversationId } from "./telegram-context.js";
const SESSION_COMMAND_PREFIX = "/session";
@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) {
return new Date(expiresAt).toISOString();
}
function resolveTelegramBindingDurationMs(
function resolveSessionBindingDurationMs(
binding: SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
fallbackMs: number,
@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs(
return Math.max(0, Math.floor(raw));
}
function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number {
function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number {
const raw = binding.metadata?.lastActivityAt;
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return binding.boundAt;
@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu
return Math.max(Math.floor(raw), binding.boundAt);
}
function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string {
function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string {
const raw = binding.metadata?.boundBy;
return typeof raw === "string" ? raw.trim() : "";
}
@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = {
maxAgeMs?: number;
};
function isSessionBindingRecord(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
): binding is SessionBindingRecord {
return "bindingId" in binding;
}
function resolveUpdatedLifecycleDurationMs(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
key: "idleTimeoutMs" | "maxAgeMs",
): number | undefined {
if (!isSessionBindingRecord(binding)) {
const raw = binding[key];
if (typeof raw === "number" && Number.isFinite(raw)) {
return Math.max(0, Math.floor(raw));
}
}
if (!isSessionBindingRecord(binding)) {
return undefined;
}
const raw = binding.metadata?.[key];
if (typeof raw !== "number" || !Number.isFinite(raw)) {
return undefined;
}
return Math.max(0, Math.floor(raw));
}
function toUpdatedLifecycleBinding(
binding: UpdatedLifecycleBinding | SessionBindingRecord,
): UpdatedLifecycleBinding {
const lastActivityAt = isSessionBindingRecord(binding)
? resolveSessionBindingLastActivityAt(binding)
: Math.max(Math.floor(binding.lastActivityAt), binding.boundAt);
return {
boundAt: binding.boundAt,
lastActivityAt,
idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"),
maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"),
};
}
function resolveUpdatedBindingExpiry(params: {
action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE;
bindings: UpdatedLifecycleBinding[];
@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
}
const onDiscord = isDiscordSurface(params);
const onMatrix = isMatrixSurface(params);
const onTelegram = isTelegramSurface(params);
if (!onDiscord && !onTelegram) {
if (!onDiscord && !onMatrix && !onTelegram) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.",
text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.",
},
};
}
@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const sessionBindingService = getSessionBindingService();
const threadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
const matrixConversationId = onMatrix
? resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
const matrixParentConversationId = onMatrix
? resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
})
: undefined;
const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined;
const channelRuntime = getChannelRuntime();
@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
conversationId: telegramConversationId,
})
: null;
const matrixBinding =
onMatrix && matrixConversationId
? sessionBindingService.resolveByConversation({
channel: "matrix",
accountId,
conversationId: matrixConversationId,
...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId
? { parentConversationId: matrixParentConversationId }
: {}),
})
: null;
if (onDiscord && !discordBinding) {
if (onDiscord && !threadId) {
return {
@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
reply: { text: " This thread is not currently focused." },
};
}
if (onMatrix && !matrixBinding) {
if (!threadId) {
return {
shouldContinue: false,
reply: {
text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.",
},
};
}
return {
shouldContinue: false,
reply: { text: " This thread is not currently focused." },
};
}
if (onTelegram && !telegramBinding) {
if (!telegramConversationId) {
return {
@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000);
: resolveSessionBindingDurationMs(
(onMatrix ? matrixBinding : telegramBinding)!,
"idleTimeoutMs",
24 * 60 * 60 * 1000,
);
const idleExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({
record: discordBinding!,
defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(),
})
: idleTimeoutMs > 0
? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs
? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) +
idleTimeoutMs
: undefined;
const maxAgeMs = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeMs({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0);
: resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0);
const maxAgeExpiresAt = onDiscord
? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({
record: discordBinding!,
defaultMaxAgeMs: discordManager!.getMaxAgeMs(),
})
: maxAgeMs > 0
? telegramBinding!.boundAt + maxAgeMs
? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs
: undefined;
const durationArgRaw = tokens.slice(1).join("");
@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const senderId = params.command.senderId?.trim() || "";
const boundBy = onDiscord
? discordBinding!.boundBy
: resolveTelegramBindingBoundBy(telegramBinding!);
: resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!);
if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) {
return {
shouldContinue: false,
reply: {
text: onDiscord
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
: onMatrix
? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.`
: `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`,
},
};
}
@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
maxAgeMs: durationMs,
});
}
if (onMatrix) {
return action === SESSION_ACTION_IDLE
? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: matrixBinding!.targetSessionKey,
accountId,
idleTimeoutMs: durationMs,
})
: channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({
targetSessionKey: matrixBinding!.targetSessionKey,
accountId,
maxAgeMs: durationMs,
});
}
return action === SESSION_ACTION_IDLE
? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({
targetSessionKey: telegramBinding!.targetSessionKey,
@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm
const nextExpiry = resolveUpdatedBindingExpiry({
action,
bindings: updatedBindings,
bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)),
});
const expiryLabel =
typeof nextExpiry === "number" && Number.isFinite(nextExpiry)

View File

@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) {
return params;
}
function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
MessageThreadId: "$thread-1",
});
params.command.senderId = "user-1";
return params;
}
function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
const params = buildCommandTestParams(commandBody, cfg, {
Provider: "matrix",
Surface: "matrix",
OriginatingChannel: "matrix",
OriginatingTo: "room:!room:example.org",
AccountId: "default",
});
params.command.senderId = "user-1";
return params;
}
function createSessionBindingRecord(
overrides?: Partial<SessionBindingRecord>,
): SessionBindingRecord {
@ -144,7 +169,13 @@ async function focusCodexAcp(
hoisted.sessionBindingBindMock.mockImplementation(
async (input: {
targetSessionKey: string;
conversation: { channel: string; accountId: string; conversationId: string };
placement: "current" | "child";
conversation: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
};
metadata?: Record<string, unknown>;
}) =>
createSessionBindingRecord({
@ -152,7 +183,11 @@ async function focusCodexAcp(
conversation: {
channel: input.conversation.channel,
accountId: input.conversation.accountId,
conversationId: input.conversation.conversationId,
conversationId:
input.placement === "child" ? "thread-created" : input.conversation.conversationId,
...(input.conversation.parentConversationId
? { parentConversationId: input.conversation.parentConversationId }
: {}),
},
metadata: {
boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1",
@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => {
);
});
it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
spawnSubagentSessions: true,
},
},
},
} satisfies OpenClawConfig;
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
expect(result?.reply?.text).toContain("created thread thread-created and bound it");
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
expect.objectContaining({
placement: "child",
conversation: expect.objectContaining({
channel: "matrix",
conversationId: "!room:example.org",
}),
}),
);
});
it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => {
const cfg = {
...baseCfg,
channels: {
matrix: {
threadBindings: {
enabled: true,
},
},
},
} satisfies OpenClawConfig;
const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg));
expect(result?.reply?.text).toContain("spawnSubagentSessions=true");
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
});
it("/focus includes ACP session identifiers in intro text when available", async () => {
hoisted.readAcpSessionEntryMock.mockReturnValue({
sessionKey: "agent:codex-acp:session-1",
@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => {
});
});
it("/unfocus removes an active Matrix thread binding for the binding owner", async () => {
const params = createMatrixThreadCommandParams("/unfocus");
hoisted.sessionBindingResolveByConversationMock.mockReturnValue(
createSessionBindingRecord({
bindingId: "default:matrix-thread-1",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
},
metadata: { boundBy: "user-1" },
}),
);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("Thread unfocused");
expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({
channel: "matrix",
accountId: "default",
conversationId: "$thread-1",
parentConversationId: "!room:example.org",
});
expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({
bindingId: "default:matrix-thread-1",
reason: "manual",
});
});
it("/focus rejects rebinding when the thread is focused by another user", async () => {
const result = await focusCodexAcp(undefined, {
existingBinding: createSessionBindingRecord({
@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => {
it("/focus rejects unsupported channels", async () => {
const params = buildCommandTestParams("/focus codex-acp", baseCfg);
const result = await handleSubagentsCommand(params, true);
expect(result?.reply?.text).toContain("only available on Discord and Telegram");
expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram");
});
});

View File

@ -8,14 +8,22 @@ import {
resolveThreadBindingThreadName,
} from "../../../channels/thread-bindings-messages.js";
import {
formatThreadBindingDisabledError,
formatThreadBindingSpawnDisabledError,
resolveThreadBindingIdleTimeoutMsForChannel,
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../../../channels/thread-bindings-policy.js";
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
@ -26,9 +34,10 @@ import {
} from "./shared.js";
type FocusBindingContext = {
channel: "discord" | "telegram";
channel: "discord" | "matrix" | "telegram";
accountId: string;
conversationId: string;
parentConversationId?: string;
placement: "current" | "child";
labelNoun: "thread" | "conversation";
};
@ -65,6 +74,41 @@ function resolveFocusBindingContext(
labelNoun: "conversation",
};
}
if (isMatrixSurface(params)) {
const conversationId = resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
if (!conversationId) {
return null;
}
const parentConversationId = resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
const currentThreadId =
params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : "";
return {
channel: "matrix",
accountId: resolveChannelAccountId(params),
conversationId,
...(parentConversationId ? { parentConversationId } : {}),
placement: currentThreadId ? "current" : "child",
labelNoun: "thread",
};
}
return null;
}
@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction(
): Promise<CommandHandlerResult> {
const { params, runs, restTokens } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /focus is only available on Discord and Telegram.");
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram.");
}
const token = restTokens.join(" ").trim();
@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction(
accountId,
});
if (!capabilities.adapterAvailable || !capabilities.bindSupported) {
const label = channel === "discord" ? "Discord thread" : "Telegram conversation";
const label =
channel === "discord"
? "Discord thread"
: channel === "matrix"
? "Matrix thread"
: "Telegram conversation";
return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`);
}
@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction(
"⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
}
if (channel === "matrix") {
return stopWithText("⚠️ Could not resolve a Matrix room for /focus.");
}
return stopWithText("⚠️ Could not resolve a Discord channel for /focus.");
}
if (channel === "matrix") {
const spawnPolicy = resolveThreadBindingSpawnPolicy({
cfg: params.cfg,
channel,
accountId: bindingContext.accountId,
kind: "subagent",
});
if (!spawnPolicy.enabled) {
return stopWithText(
`⚠️ ${formatThreadBindingDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "subagent",
})}`,
);
}
if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) {
return stopWithText(
`⚠️ ${formatThreadBindingSpawnDisabledError({
channel: spawnPolicy.channel,
accountId: spawnPolicy.accountId,
kind: "subagent",
})}`,
);
}
}
const senderId = params.command.senderId?.trim() || "";
const existingBinding = bindingService.resolveByConversation({
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId &&
bindingContext.parentConversationId !== bindingContext.conversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
});
const boundBy =
typeof existingBinding?.metadata?.boundBy === "string"
@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction(
channel: bindingContext.channel,
accountId: bindingContext.accountId,
conversationId: bindingContext.conversationId,
...(bindingContext.parentConversationId &&
bindingContext.parentConversationId !== bindingContext.conversationId
? { parentConversationId: bindingContext.parentConversationId }
: {}),
},
placement: bindingContext.placement,
metadata: {

View File

@ -1,8 +1,13 @@
import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js";
import type { CommandHandlerResult } from "../commands-types.js";
import {
resolveMatrixConversationId,
resolveMatrixParentConversationId,
} from "../matrix-context.js";
import {
type SubagentsCommandContext,
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveChannelAccountId,
resolveCommandSurfaceChannel,
@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction(
): Promise<CommandHandlerResult> {
const { params } = ctx;
const channel = resolveCommandSurfaceChannel(params);
if (channel !== "discord" && channel !== "telegram") {
return stopWithText("⚠️ /unfocus is only available on Discord and Telegram.");
if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") {
return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram.");
}
const accountId = resolveChannelAccountId(params);
@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction(
if (isTelegramSurface(params)) {
return resolveTelegramConversationId(params);
}
if (isMatrixSurface(params)) {
return resolveMatrixConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
}
return undefined;
})();
const parentConversationId = (() => {
if (!isMatrixSurface(params)) {
return undefined;
}
return resolveMatrixParentConversationId({
ctx: {
MessageThreadId: params.ctx.MessageThreadId,
OriginatingTo: params.ctx.OriginatingTo,
To: params.ctx.To,
},
command: {
to: params.command.to,
},
});
})();
if (!conversationId) {
if (channel === "discord") {
return stopWithText("⚠️ /unfocus must be run inside a Discord thread.");
}
if (channel === "matrix") {
return stopWithText("⚠️ /unfocus must be run inside a Matrix thread.");
}
return stopWithText(
"⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.",
);
@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction(
channel,
accountId,
conversationId,
...(parentConversationId && parentConversationId !== conversationId
? { parentConversationId }
: {}),
});
if (!binding) {
return stopWithText(
channel === "discord"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
: channel === "matrix"
? " This thread is not currently focused."
: " This conversation is not currently focused.",
);
}
@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction(
return stopWithText(
channel === "discord"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
: channel === "matrix"
? `⚠️ Only ${boundBy} can unfocus this thread.`
: `⚠️ Only ${boundBy} can unfocus this conversation.`,
);
}
@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction(
reason: "manual",
});
return stopWithText(
channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.",
channel === "discord" || channel === "matrix"
? "✅ Thread unfocused."
: "✅ Conversation unfocused.",
);
}

View File

@ -30,6 +30,7 @@ import {
} from "../../../shared/subagents-format.js";
import {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,
@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js";
export { extractAssistantText, stripToolMessages };
export {
isDiscordSurface,
isMatrixSurface,
isTelegramSurface,
resolveCommandSurfaceChannel,
resolveDiscordAccountId,

View File

@ -21,6 +21,7 @@ import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
import { normalizeMainKey } from "../../routing/session-key.js";
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
import { hasControlCommand } from "../command-detection.js";
import { resolveEnvelopeFormatOptions } from "../envelope.js";
import { buildInboundMediaNote } from "../media-note.js";
import type { MsgContext, TemplateContext } from "../templating.js";
import {
@ -292,6 +293,7 @@ export async function runPreparedReply(
isNewSession &&
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody;
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
const inboundUserContext = buildInboundUserContextPrefix(
isNewSession
? {
@ -301,6 +303,7 @@ export async function runPreparedReply(
: {}),
}
: { ...sessionCtx, ThreadStarterBody: undefined },
envelopeOptions,
);
const baseBodyForPrompt = isBareSessionReset
? baseBodyFinal

View File

@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import { withEnv } from "../../test-utils/env.js";
import type { TemplateContext } from "../templating.js";
import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js";
@ -217,6 +218,25 @@ describe("buildInboundUserContextPrefix", () => {
expect(conversationInfo["timestamp"]).toEqual(expect.any(String));
});
it("honors envelope user timezone for conversation timestamps", () => {
withEnv({ TZ: "America/Los_Angeles" }, () => {
const text = buildInboundUserContextPrefix(
{
ChatType: "group",
MessageSid: "msg-with-user-tz",
Timestamp: Date.UTC(2026, 2, 19, 0, 0),
} as TemplateContext,
{
timezone: "user",
userTimezone: "Asia/Tokyo",
},
);
const conversationInfo = parseConversationInfoPayload(text);
expect(conversationInfo["timestamp"]).toBe("Thu 2026-03-19 09:00 GMT+9");
});
});
it("omits invalid timestamps instead of throwing", () => {
expect(() =>
buildInboundUserContextPrefix({

View File

@ -1,6 +1,7 @@
import { normalizeChatType } from "../../channels/chat-type.js";
import { resolveSenderLabel } from "../../channels/sender-label.js";
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
import type { EnvelopeFormatOptions } from "../envelope.js";
import { formatEnvelopeTimestamp } from "../envelope.js";
import type { TemplateContext } from "../templating.js";
function safeTrim(value: unknown): string | undefined {
@ -11,24 +12,14 @@ function safeTrim(value: unknown): string | undefined {
return trimmed ? trimmed : undefined;
}
function formatConversationTimestamp(value: unknown): string | undefined {
function formatConversationTimestamp(
value: unknown,
envelope?: EnvelopeFormatOptions,
): string | undefined {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return undefined;
}
const formatted = formatZonedTimestamp(date);
if (!formatted) {
return undefined;
}
try {
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
return weekday ? `${weekday} ${formatted}` : formatted;
} catch {
return formatted;
}
return formatEnvelopeTimestamp(value, envelope);
}
function resolveInboundChannel(ctx: TemplateContext): string | undefined {
@ -81,7 +72,10 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
].join("\n");
}
export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
export function buildInboundUserContextPrefix(
ctx: TemplateContext,
envelope?: EnvelopeFormatOptions,
): string {
const blocks: string[] = [];
const chatType = normalizeChatType(ctx.ChatType);
const isDirect = !chatType || chatType === "direct";
@ -94,7 +88,7 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
const messageId = safeTrim(ctx.MessageSid);
const messageIdFull = safeTrim(ctx.MessageSidFull);
const resolvedMessageId = messageId ?? messageIdFull;
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
const timestampStr = formatConversationTimestamp(ctx.Timestamp, envelope);
const conversationInfo = {
message_id: shouldIncludeConversationInfo ? resolvedMessageId : undefined,

View File

@ -675,6 +675,39 @@ describe("block reply coalescer", () => {
coalescer.stop();
});
it("keeps buffering newline-style chunks until minChars is reached", async () => {
vi.useFakeTimers();
const { flushes, coalescer } = createBlockCoalescerHarness({
minChars: 25,
maxChars: 2000,
idleMs: 50,
joiner: "\n\n",
});
coalescer.enqueue({ text: "First paragraph" });
coalescer.enqueue({ text: "Second paragraph" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
coalescer.stop();
});
it("force flushes buffered newline-style chunks even below minChars", async () => {
const { flushes, coalescer } = createBlockCoalescerHarness({
minChars: 100,
maxChars: 2000,
idleMs: 50,
joiner: "\n\n",
});
coalescer.enqueue({ text: "First paragraph" });
coalescer.enqueue({ text: "Second paragraph" });
await coalescer.flush({ force: true });
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
coalescer.stop();
});
it("flushes immediately per enqueue when flushOnEnqueue is set", async () => {
const cases = [
{

View File

@ -1,9 +1,13 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { expect, vi } from "vitest";
import {
__testing as discordThreadBindingTesting,
createThreadBindingManager as createDiscordThreadBindingManager,
} from "../../../../extensions/discord/runtime-api.js";
import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js";
import { createMatrixThreadBindingManager } from "../../../../extensions/matrix/api.js";
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
@ -126,7 +130,7 @@ type DirectoryContractEntry = {
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
@ -136,6 +140,7 @@ function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
conversationId: string;
parentConversationId?: string;
targetSessionKey: string;
}) {
expect(
@ -143,6 +148,7 @@ function expectResolvedSessionBinding(params: {
channel: params.channel,
accountId: params.accountId,
conversationId: params.conversationId,
parentConversationId: params.parentConversationId,
}),
)?.toMatchObject({
targetSessionKey: params.targetSessionKey,
@ -589,6 +595,24 @@ const baseSessionBindingCfg = {
session: { mainKey: "main", scope: "per-sender" },
} satisfies OpenClawConfig;
async function createContractMatrixThreadBindingManager() {
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-"));
return await createMatrixThreadBindingManager({
accountId: "ops",
auth: {
accountId: "ops",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
},
client: {} as never,
stateDir,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
{
id: "discord",
@ -708,6 +732,61 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "matrix",
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await createContractMatrixThreadBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: "ops",
});
},
bindAndResolve: async () => {
await createContractMatrixThreadBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:subagent:child-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "ops",
conversationId: "!room:example",
},
placement: "child",
metadata: {
label: "codex-matrix",
introText: "intro root",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: "ops",
conversationId: "$root",
parentConversationId: "!room:example",
targetSessionKey: "agent:matrix:subagent:child-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await createContractMatrixThreadBindingManager();
manager.stop();
expect(
getSessionBindingService().resolveByConversation({
channel: "matrix",
accountId: "ops",
conversationId: "$root",
parentConversationId: "!room:example",
}),
).toBeNull();
},
},
{
id: "telegram",
expectedCapabilities: {

View File

@ -1,15 +1,32 @@
import { beforeEach, describe } from "vitest";
import { beforeEach, describe, vi } from "vitest";
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/api.js";
import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js";
import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js";
import { sessionBindingContractRegistry } from "./registry.js";
import { installSessionBindingContractSuite } from "./suites.js";
vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => {
const actual = await vi.importActual<
typeof import("../../../../extensions/matrix/src/matrix/send.js")
>("../../../../extensions/matrix/src/matrix/send.js");
return {
...actual,
sendMessageMatrix: vi.fn(
async (_to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$reply" : "$root",
roomId: "!room:example",
}),
),
};
});
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
discordThreadBindingTesting.resetThreadBindingsForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
resetMatrixThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
});

View File

@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: {
}
export function installSessionBindingContractSuite(params: {
getCapabilities: () => SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
expectedCapabilities: SessionBindingCapabilities;
}) {
it("registers the expected session binding capabilities", () => {
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
it("registers the expected session binding capabilities", async () => {
expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
});
it("binds and resolves a session binding through the shared service", async () => {

View File

@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
"timeout",
"kick",
"ban",
"set-profile",
"set-presence",
"download-file",
] as const;

View File

@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js";
import { normalizeAccountId } from "../routing/session-key.js";
export const DISCORD_THREAD_BINDING_CHANNEL = "discord";
export const MATRIX_THREAD_BINDING_CHANNEL = "matrix";
const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24;
const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0;
@ -127,8 +128,9 @@ export function resolveThreadBindingSpawnPolicy(params: {
const spawnFlagKey = resolveSpawnFlagKey(params.kind);
const spawnEnabledRaw =
normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]);
// Non-Discord channels currently have no dedicated spawn gate config keys.
const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL;
const spawnEnabled =
spawnEnabledRaw ??
(channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL);
return {
channel,
accountId,
@ -183,6 +185,9 @@ export function formatThreadBindingDisabledError(params: {
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) {
return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) {
return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally).";
}
return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`;
}
@ -197,5 +202,11 @@ export function formatThreadBindingSpawnDisabledError(params: {
if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") {
return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable).";
}
if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") {
return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable).";
}
return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`;
}

View File

@ -174,7 +174,7 @@ describe("registerAgentCommands", () => {
"--agent",
"ops",
"--bind",
"matrix-js:ops",
"matrix:ops",
"--bind",
"telegram",
"--json",
@ -182,7 +182,7 @@ describe("registerAgentCommands", () => {
expect(agentsBindCommandMock).toHaveBeenCalledWith(
{
agent: "ops",
bind: ["matrix-js:ops", "telegram"],
bind: ["matrix:ops", "telegram"],
json: true,
},
runtime,

View File

@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
return {
...actual,
getChannelPlugin: (channel: string) => {
if (channel === "matrix-js") {
if (channel === "matrix") {
return {
id: "matrix-js",
id: "matrix",
setup: {
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
},
@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
return actual.getChannelPlugin(channel);
},
normalizeChannelId: (channel: string) => {
if (channel.trim().toLowerCase() === "matrix-js") {
return "matrix-js";
if (channel.trim().toLowerCase() === "matrix") {
return "matrix";
}
return actual.normalizeChannelId(channel);
},
@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => {
...baseConfigSnapshot,
config: {
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "main", match: { channel: "matrix" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => {
await agentsBindingsCommand({}, runtime);
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix"));
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("ops <- telegram accountId=work"),
);
@ -76,23 +76,29 @@ describe("agents bind/unbind commands", () => {
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
});
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
it("defaults matrix accountId to the target agent id when omitted", async () => {
readConfigFileSnapshotMock.mockResolvedValue({
...baseConfigSnapshot,
config: {},
});
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
bindings: [
{
type: "route",
agentId: "main",
match: { channel: "matrix", accountId: "main" },
},
],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();
@ -123,7 +129,7 @@ describe("agents bind/unbind commands", () => {
config: {
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
bindings: [
{ agentId: "main", match: { channel: "matrix-js" } },
{ agentId: "main", match: { channel: "matrix" } },
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
],
},
@ -133,7 +139,7 @@ describe("agents bind/unbind commands", () => {
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
bindings: [{ agentId: "main", match: { channel: "matrix" } }],
}),
);
expect(runtime.exit).not.toHaveBeenCalled();

View File

@ -350,14 +350,15 @@ export async function channelsAddCommand(
await writeConfigFile(nextConfig);
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
if (plugin.setup.afterAccountConfigWritten) {
const setup = plugin.setup;
if (setup?.afterAccountConfigWritten) {
await runCollectedChannelOnboardingPostWriteHooks({
hooks: [
{
channel,
accountId,
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
await plugin.setup.afterAccountConfigWritten?.({
await setup.afterAccountConfigWritten?.({
previousCfg: cfg,
cfg: writtenCfg,
accountId,

View File

@ -106,10 +106,16 @@ export async function channelsRemoveCommand(
if (resolvedPluginState?.configChanged) {
cfg = resolvedPluginState.cfg;
}
channel = resolvedPluginState?.channelId ?? channel;
const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
const resolvedChannel = resolvedPluginState?.channelId ?? channel;
if (!resolvedChannel) {
runtime.error(`Unknown channel: ${rawChannel}`);
runtime.exit(1);
return;
}
channel = resolvedChannel;
const plugin = resolvedPluginState?.plugin ?? getChannelPlugin(resolvedChannel);
if (!plugin) {
runtime.error(`Unknown channel: ${channel}`);
runtime.error(`Unknown channel: ${resolvedChannel}`);
runtime.exit(1);
return;
}

View File

@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
import { withTempHome } from "../../test/helpers/temp-home.js";
import * as noteModule from "../terminal/note.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
@ -203,6 +204,250 @@ describe("doctor config flow", () => {
).toBe("existing-session");
});
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"next_batch":"s1"}',
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
);
expect(warning?.[0]).toContain("Legacy sync store:");
expect(warning?.[0]).toContain(
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
);
} finally {
noteSpy.mockRestore();
}
});
it("previews Matrix encrypted-state migration in read-only mode", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
stateDir,
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
});
await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(accountRoot, "crypto", "bot-sdk.json"),
JSON.stringify({ deviceId: "DEVICE123" }),
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true },
confirm: async () => false,
});
});
const warning = noteSpy.mock.calls.find(
(call) =>
call[1] === "Doctor warnings" &&
String(call[0]).includes("Matrix encrypted-state migration is pending"),
);
expect(warning?.[0]).toContain("Legacy crypto store:");
expect(warning?.[0]).toContain("New recovery key file:");
} finally {
noteSpy.mockRestore();
}
});
it("migrates Matrix legacy state on doctor repair", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(
path.join(stateDir, "matrix", "bot-storage.json"),
'{"next_batch":"s1"}',
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const migratedRoot = path.join(
stateDir,
"matrix",
"accounts",
"default",
"matrix.example.org__bot_example.org",
);
const migratedChildren = await fs.readdir(migratedRoot);
expect(migratedChildren.length).toBe(1);
expect(
await fs
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(true);
expect(
await fs
.access(path.join(stateDir, "matrix", "bot-storage.json"))
.then(() => true)
.catch(() => false),
).toBe(false);
});
expect(
noteSpy.mock.calls.some(
(call) =>
call[1] === "Doctor changes" &&
String(call[0]).includes("Matrix plugin upgraded in place."),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => {
await withTempHome(async (home) => {
const stateDir = path.join(home, ".openclaw");
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
await fs.writeFile(
path.join(stateDir, "openclaw.json"),
JSON.stringify({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
},
},
}),
);
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const snapshotDir = path.join(home, "Backups", "openclaw-migrations");
const snapshotEntries = await fs.readdir(snapshotDir);
expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true);
const marker = JSON.parse(
await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"),
) as {
archivePath: string;
};
expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations"));
});
});
it("warns when Matrix is installed from a stale custom path", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: "/tmp/openclaw-matrix-missing",
installPath: "/tmp/openclaw-matrix-missing",
},
},
},
});
expect(
doctorWarnings.some(
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
),
).toBe(true);
});
it("warns when Matrix is installed from an existing custom path", async () => {
await withTempHome(async (home) => {
const pluginPath = path.join(home, "matrix-plugin");
await fs.mkdir(pluginPath, { recursive: true });
const doctorWarnings = await collectDoctorWarnings({
channels: {
matrix: {
homeserver: "https://matrix.example.org",
accessToken: "tok-123",
},
},
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: pluginPath,
installPath: pluginPath,
},
},
},
});
expect(
doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")),
).toBe(true);
expect(
doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")),
).toBe(true);
});
});
it("notes legacy browser extension migration changes", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {

View File

@ -26,6 +26,23 @@ import {
isTrustedSafeBinPath,
normalizeTrustedSafeBinDirs,
} from "../infra/exec-safe-bin-trust.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../infra/matrix-legacy-crypto.js";
import {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../infra/matrix-legacy-state.js";
import {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../infra/matrix-migration-snapshot.js";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
import {
@ -312,6 +329,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
return hits;
}
function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
}).map((entry) => `- ${entry}`);
}
async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
config: OpenClawConfig;
changes: string[];
@ -1699,6 +1766,110 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
}
}
const matrixLegacyState = detectLegacyMatrixState({
cfg: candidate,
env: process.env,
});
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: candidate,
env: process.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
cfg: candidate,
env: process.env,
});
if (shouldRepair) {
let matrixSnapshotReady = true;
if (actionableMatrixMigration) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: process.env,
});
note(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
"Doctor changes",
);
} catch (err) {
matrixSnapshotReady = false;
note(
`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`,
"Doctor warnings",
);
note(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
"Doctor warnings",
);
}
} else if (pendingMatrixMigration) {
note(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
"Doctor warnings",
);
}
if (matrixSnapshotReady) {
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: candidate,
env: process.env,
});
if (matrixStateRepair.changes.length > 0) {
note(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
"Doctor changes",
);
}
if (matrixStateRepair.warnings.length > 0) {
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (matrixCryptoRepair.changes.length > 0) {
note(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
"Doctor changes",
);
}
if (matrixCryptoRepair.warnings.length > 0) {
note(
matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"),
"Doctor warnings",
);
}
}
} else if (matrixLegacyState) {
if ("warning" in matrixLegacyState) {
note(`- ${matrixLegacyState.warning}`, "Doctor warnings");
} else {
note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings");
}
}
if (
!shouldRepair &&
(matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0)
) {
for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) {
note(preview, "Doctor warnings");
}
}
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate);
if (matrixInstallWarnings.length > 0) {
note(matrixInstallWarnings.join("\n"), "Doctor warnings");
}
const missingDefaultAccountBindingWarnings =
collectMissingDefaultAccountBindingWarnings(candidate);
if (missingDefaultAccountBindingWarnings.length > 0) {

View File

@ -110,6 +110,7 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({
changes: [],
warnings: [],
}) as unknown as MockFn;
export const runStartupMatrixMigration = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
function createLegacyStateMigrationDetectionResult(params?: {
hasLegacySessions?: boolean;
@ -299,6 +300,10 @@ vi.mock("./doctor-state-migrations.js", () => ({
runLegacyStateMigrations,
}));
vi.mock("../gateway/server-startup-matrix-migration.js", () => ({
runStartupMatrixMigration,
}));
export function mockDoctorConfigSnapshot(
params: {
config?: Record<string, unknown>;
@ -393,6 +398,7 @@ beforeEach(() => {
serviceRestart.mockReset().mockResolvedValue(undefined);
serviceUninstall.mockReset().mockResolvedValue(undefined);
callGateway.mockReset().mockRejectedValue(new Error("gateway closed"));
runStartupMatrixMigration.mockReset().mockResolvedValue(undefined);
originalIsTTY = process.stdin.isTTY;
setStdinTty(true);

Some files were not shown because too many files have changed in this diff Show More