From c4a4050ce48b5abd62bed82263a5472639dd8b25 Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Thu, 19 Mar 2026 13:51:17 +0200 Subject: [PATCH 001/137] fix(macos): align exec command parity (#50386) * fix(macos): align exec command parity * fix(macos): address exec review follow-ups --- .../OpenClaw/ExecApprovalEvaluation.swift | 11 +- .../OpenClaw/ExecApprovalsSocket.swift | 10 +- .../OpenClaw/ExecCommandResolution.swift | 126 ++++++++++++++++++ .../OpenClaw/ExecEnvInvocationUnwrapper.swift | 26 +++- .../OpenClaw/ExecHostRequestEvaluator.swift | 6 +- .../ExecSystemRunCommandValidator.swift | 50 ++++++- .../OpenClaw/NodeMode/MacNodeRuntime.swift | 11 +- .../CommandResolverTests.swift | 2 +- .../OpenClawIPCTests/ExecAllowlistTests.swift | 37 ++++- .../ExecApprovalsStoreRefactorTests.swift | 17 ++- .../ExecHostRequestEvaluatorTests.swift | 1 + .../ExecSystemRunCommandValidatorTests.swift | 14 ++ 12 files changed, 268 insertions(+), 43 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index a36e58db1d8..e39db84534f 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -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, diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 19336f4f7b1..1187d3d09a4 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -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() - 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) } diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index f89293a81aa..131868bb23e 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -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() + 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) + { + 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 } diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift index 19161858571..35423182b6e 100644 --- a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -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 diff --git a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift index 4e0ff4173de..5a95bd7949d 100644 --- a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift +++ b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -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( diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index f8ff84155e1..d73724db5bd 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -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") { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 6782913bd23..c24f5d0f1b8 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -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() - 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) } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 969a8ea1a51..5e8e68f52e6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -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) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index fa92cc81ef5..dc2ab9c42d7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -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`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 480b4cd9194..cd270d00fd2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -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 } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift index c9772a5d512..ee2177e1440 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests { env: [:], resolution: nil, allowlistResolutions: [], + allowAlwaysPatterns: [], allowlistMatches: [], allowlistSatisfied: allowlistSatisfied, allowlistMatch: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index 64dbb335807..2b07d928ccf 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -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) From f69450b170ebf73d7a8bac793a54423eb37bcbc9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 07:59:01 -0400 Subject: [PATCH 002/137] Matrix: fix typecheck and boundary drift --- extensions/matrix/src/actions.test.ts | 98 ++++++++++--------- extensions/matrix/src/channel.runtime.ts | 2 + extensions/matrix/src/channel.ts | 4 +- extensions/matrix/src/cli.test.ts | 4 +- .../src/matrix/client/file-sync-store.test.ts | 5 +- .../src/matrix/client/file-sync-store.ts | 4 +- extensions/matrix/src/matrix/index.ts | 1 + .../matrix/src/matrix/monitor/events.test.ts | 2 +- .../monitor/handler.media-failure.test.ts | 1 + .../matrix/src/matrix/monitor/handler.test.ts | 2 + .../monitor/handler.thread-root-media.test.ts | 1 + .../matrix/src/matrix/monitor/index.test.ts | 3 +- .../matrix/src/matrix/monitor/route.test.ts | 12 +-- extensions/matrix/src/matrix/sdk.test.ts | 5 +- extensions/matrix/src/matrix/sdk.ts | 3 +- .../matrix/src/matrix/thread-bindings.ts | 8 -- extensions/matrix/src/onboarding.ts | 55 ++++++++++- src/agents/acp-spawn.test.ts | 9 +- .../subagent-announce.format.e2e.test.ts | 22 +++-- src/channels/plugins/message-action-names.ts | 1 + src/commands/channels/add.ts | 5 +- src/infra/matrix-plugin-helper.test.ts | 9 +- src/infra/outbound/message-action-spec.ts | 1 + test/helpers/extensions/matrix-route-test.ts | 8 ++ 24 files changed, 170 insertions(+), 95 deletions(-) create mode 100644 extensions/matrix/src/matrix/index.ts create mode 100644 test/helpers/extensions/matrix-route-test.ts diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index f9da97881ac..df34411b806 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -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 } | 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([]); }); diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts index e75d06f1875..e3d8c9d05c5 100644 --- a/extensions/matrix/src/channel.runtime.ts +++ b/extensions/matrix/src/channel.runtime.ts @@ -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, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index cf251450fd2..cfc4ccdddf1 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -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 = { id: "matrix", meta, - setupWizard: matrixSetupWizard, pairing: createTextPairingAdapter({ idLabel: "matrixUserId", message: PAIRING_APPROVED_MESSAGE, diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index a97c083ebce..008fd46795d 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -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({ diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 85d61580a17..5bda781b5b2 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -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: [ diff --git a/extensions/matrix/src/matrix/client/file-sync-store.ts b/extensions/matrix/src/matrix/client/file-sync-store.ts index 9f1d0599569..411f4e0decd 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.ts @@ -52,7 +52,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 +64,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; diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts new file mode 100644 index 00000000000..9795b10c1a6 --- /dev/null +++ b/extensions/matrix/src/matrix/index.ts @@ -0,0 +1 @@ +export { monitorMatrixProvider } from "./monitor/index.js"; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 0f8480424b5..5d4642bdb5e 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -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"); diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index e1fc7e969ca..25f17cb0254 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -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), }, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 2a627c0fc0e..e28afdff33d 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -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( diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index 7dfbcebe401..c08452cd76b 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -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), }, diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 30d7a6d4890..6d6779de445 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -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); @@ -27,7 +26,7 @@ const hoisted = vi.hoisted(() => { logger, resolveTextChunkLimit, setActiveMatrixClient, - startClientError, + startClientError: null as Error | null, stopSharedClientInstance, stopThreadBindingManager, }; diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts index 3b64f3e4491..5846d45dd9c 100644 --- a/extensions/matrix/src/matrix/monitor/route.test.ts +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 3467f12711c..e25d215af05 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -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"); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 94ac1990096..b2084e5c210 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -4,6 +4,7 @@ import { EventEmitter } from "node:events"; import { ClientEvent, MatrixEventEvent, + Preset, createClient as createMatrixJsClient, type MatrixClient as MatrixJsClient, type MatrixEvent, @@ -547,7 +548,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; diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index d69e477a20a..eb9a7e4c1d9 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -621,14 +621,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); }, diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 62fe0613524..01e60ba53eb 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -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>; + +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; + 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; + 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; diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index d11b569602c..3b93bf0a826 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -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); 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, }, }, }, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 7e83742b5ce..280172dc073 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -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> { - return new Proxy(sessionStore, { +function loadSessionStoreFixture(): ReturnType { + return new Proxy(sessionStore as ReturnType, { 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, + ); readLatestAssistantReplySpy .mockReset() .mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey)); diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index aadff95c77d..3bf58083d14 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "timeout", "kick", "ban", + "set-profile", "set-presence", "download-file", ] as const; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 03aa841edd5..ddddae5ee71 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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, diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts index 650edc434ca..ae71aca0bc8 100644 --- a/src/infra/matrix-plugin-helper.test.ts +++ b/src/infra/matrix-plugin-helper.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; import { isMatrixLegacyCryptoInspectorAvailable, loadMatrixLegacyCryptoInspector, @@ -89,13 +90,13 @@ describe("matrix plugin helper resolution", () => { ].join("\n"), ); - const cfg = { + const cfg: OpenClawConfig = { plugins: { load: { paths: [customRoot], }, }, - } as const; + }; expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ @@ -160,13 +161,13 @@ describe("matrix plugin helper resolution", () => { return; } - const cfg = { + const cfg: OpenClawConfig = { plugins: { load: { paths: [customRoot], }, }, - } as const; + }; expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false); await expect( diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index a71bc35b6fb..f5149e715ef 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record Date: Thu, 19 Mar 2026 08:03:19 -0400 Subject: [PATCH 003/137] Matrix: wire startup migration into doctor and gateway --- extensions/matrix/index.test.ts | 16 +++++ extensions/matrix/runtime-api.ts | 13 +++- src/commands/doctor.e2e-harness.ts | 6 ++ src/commands/doctor.matrix-migration.test.ts | 70 +++++++++++++++++++ src/commands/doctor.ts | 14 ++++ .../server-startup-matrix-migration.ts | 13 +++- src/gateway/server.impl.ts | 6 ++ ...artup-matrix-migration.integration.test.ts | 54 ++++++++++++++ src/plugin-sdk/runtime-api-guardrails.test.ts | 5 ++ 9 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 src/commands/doctor.matrix-migration.test.ts create mode 100644 src/gateway/server.startup-matrix-migration.integration.test.ts diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index 647f841487b..ecdd6619595 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -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({ diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 9d427c4ac8c..52df80f9843 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -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"; diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 320e8e1258c..32615377773 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -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; @@ -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); diff --git a/src/commands/doctor.matrix-migration.test.ts b/src/commands/doctor.matrix-migration.test.ts new file mode 100644 index 00000000000..1e7a3572ab2 --- /dev/null +++ b/src/commands/doctor.matrix-migration.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + createDoctorRuntime, + mockDoctorConfigSnapshot, + runStartupMatrixMigration, +} from "./doctor.e2e-harness.js"; +import "./doctor.fast-path-mocks.js"; + +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders: vi.fn(() => []), +})); + +const DOCTOR_MIGRATION_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000; +let doctorCommand: typeof import("./doctor.js").doctorCommand; + +describe("doctor command", () => { + beforeAll(async () => { + ({ doctorCommand } = await import("./doctor.js")); + }); + + it( + "runs Matrix startup migration during repair flows", + { timeout: DOCTOR_MIGRATION_TIMEOUT_MS }, + async () => { + mockDoctorConfigSnapshot({ + config: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }, + parsed: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }, + }); + + await doctorCommand(createDoctorRuntime(), { nonInteractive: true, repair: true }); + + expect(runStartupMatrixMigration).toHaveBeenCalledTimes(1); + expect(runStartupMatrixMigration).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + trigger: "doctor-fix", + logPrefix: "doctor", + log: expect.objectContaining({ + info: expect.any(Function), + warn: expect.any(Function), + }), + }), + ); + }, + ); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3e4cbebe5d0..252b44efaca 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -17,6 +17,7 @@ import { resolveGatewayService } from "../daemon/service.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -236,6 +237,19 @@ export async function doctorCommand( await noteMacLaunchAgentOverrides(); await noteMacLaunchctlGatewayEnvOverrides(cfg); + if (prompter.shouldRepair) { + await runStartupMatrixMigration({ + cfg, + env: process.env, + log: { + info: (message) => runtime.log(message), + warn: (message) => runtime.error(message), + }, + trigger: "doctor-fix", + logPrefix: "doctor", + }); + } + await noteSecurityWarnings(cfg); await noteChromeMcpBrowserReadiness(cfg); await noteOpenAIOAuthTlsPrerequisites({ diff --git a/src/gateway/server-startup-matrix-migration.ts b/src/gateway/server-startup-matrix-migration.ts index 64a5f4e0721..0db6bc5be59 100644 --- a/src/gateway/server-startup-matrix-migration.ts +++ b/src/gateway/server-startup-matrix-migration.ts @@ -15,13 +15,14 @@ type MatrixMigrationLogger = { async function runBestEffortMatrixMigrationStep(params: { label: string; log: MatrixMigrationLogger; + logPrefix?: string; run: () => Promise; }): Promise { try { await params.run(); } catch (err) { params.log.warn?.( - `gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`, + `${params.logPrefix?.trim() || "gateway"}: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`, ); } } @@ -30,6 +31,8 @@ export async function runStartupMatrixMigration(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; log: MatrixMigrationLogger; + trigger?: string; + logPrefix?: string; deps?: { maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot; autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState; @@ -43,6 +46,8 @@ export async function runStartupMatrixMigration(params: { params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState; const prepareLegacyCrypto = params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto; + const trigger = params.trigger?.trim() || "gateway-startup"; + const logPrefix = params.logPrefix?.trim() || "gateway"; const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env }); const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env }); @@ -58,13 +63,13 @@ export async function runStartupMatrixMigration(params: { try { await createSnapshot({ - trigger: "gateway-startup", + trigger, env, log: params.log, }); } catch (err) { params.log.warn?.( - `gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`, + `${logPrefix}: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`, ); return; } @@ -72,6 +77,7 @@ export async function runStartupMatrixMigration(params: { await runBestEffortMatrixMigrationStep({ label: "legacy Matrix state migration", log: params.log, + logPrefix, run: () => migrateLegacyState({ cfg: params.cfg, @@ -82,6 +88,7 @@ export async function runStartupMatrixMigration(params: { await runBestEffortMatrixMigrationStep({ label: "legacy Matrix encrypted-state preparation", log: params.log, + logPrefix, run: () => prepareLegacyCrypto({ cfg: params.cfg, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index af8d1c18759..18ab617b1ce 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -105,6 +105,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { logGatewayStartup } from "./server-startup-log.js"; +import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js"; import { startGatewaySidecars } from "./server-startup.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; @@ -519,6 +520,11 @@ export async function startGatewayServer( writeConfig: writeConfigFile, log, }); + await runStartupMatrixMigration({ + cfg: cfgAtStart, + env: process.env, + log, + }); initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); diff --git a/src/gateway/server.startup-matrix-migration.integration.test.ts b/src/gateway/server.startup-matrix-migration.integration.test.ts new file mode 100644 index 00000000000..3757a311ff3 --- /dev/null +++ b/src/gateway/server.startup-matrix-migration.integration.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const runStartupMatrixMigrationMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("./server-startup-matrix-migration.js", () => ({ + runStartupMatrixMigration: runStartupMatrixMigrationMock, +})); + +import { + getFreePort, + installGatewayTestHooks, + startGatewayServer, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway startup Matrix migration wiring", () => { + let server: Awaited> | undefined; + + beforeAll(async () => { + testState.channelsConfig = { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }; + server = await startGatewayServer(await getFreePort()); + }); + + afterAll(async () => { + await server?.close(); + }); + + it("runs startup Matrix migration with the resolved startup config", () => { + expect(runStartupMatrixMigrationMock).toHaveBeenCalledTimes(1); + expect(runStartupMatrixMigrationMock).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: expect.objectContaining({ + matrix: expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }), + }), + }), + env: process.env, + log: expect.anything(), + }), + ); + }); +}); diff --git a/src/plugin-sdk/runtime-api-guardrails.test.ts b/src/plugin-sdk/runtime-api-guardrails.test.ts index fc96a09b39e..35de2096e88 100644 --- a/src/plugin-sdk/runtime-api-guardrails.test.ts +++ b/src/plugin-sdk/runtime-api-guardrails.test.ts @@ -35,6 +35,11 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { sendMessageIMessage } from "./src/send.js";', ], "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], + "extensions/matrix/runtime-api.ts": [ + 'export * from "openclaw/plugin-sdk/matrix";', + 'export * from "./src/auth-precedence.js";', + 'export { findMatrixAccountEntry, hashMatrixAccessToken, listMatrixEnvAccountIds, resolveConfiguredMatrixAccountIds, resolveMatrixChannelConfig, resolveMatrixCredentialsFilename, resolveMatrixEnvAccountToken, resolveMatrixHomeserverKey, resolveMatrixLegacyFlatStoreRoot, sanitizeMatrixPathSegment } from "./helper-api.js";', + ], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], From 34ee75b174afc8921c514d247087dffc339d728f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 08:09:52 -0400 Subject: [PATCH 004/137] Matrix: restore doctor migration previews --- src/commands/doctor-config-flow.test.ts | 245 ++++++++++++++++++++++++ src/commands/doctor-config-flow.ts | 171 +++++++++++++++++ src/gateway/server.impl.ts | 20 ++ 3 files changed, 436 insertions(+) diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 39e7b9d00fe..4a461c58267 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -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 { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ed82ea4473f..e0599eca1bb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -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, 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, +): 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 { + 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) { diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 18ab617b1ce..7a4c18b6593 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -36,6 +36,10 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; +import { + detectPluginInstallPathIssue, + formatPluginInstallPathIssue, +} from "../infra/plugin-install-path-warnings.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { primeRemoteSkillsCache, @@ -525,6 +529,22 @@ export async function startGatewayServer( env: process.env, log, }); + const matrixInstallPathIssue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: cfgAtStart.plugins?.installs?.matrix, + }); + if (matrixInstallPathIssue) { + const lines = formatPluginInstallPathIssue({ + issue: matrixInstallPathIssue, + pluginLabel: "Matrix", + defaultInstallCommand: "openclaw plugins install @openclaw/matrix", + repoInstallCommand: "openclaw plugins install ./extensions/matrix", + formatCommand: formatCliCommand, + }); + log.warn( + `gateway: matrix install path warning:\n${lines.map((entry) => `- ${entry}`).join("\n")}`, + ); + } initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); From f8eb23de1c4a8c5256be679c5cfd23ca1a031a06 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 08:29:57 -0400 Subject: [PATCH 005/137] CLI: fix check failures --- extensions/googlechat/runtime-api.ts | 2 +- extensions/nextcloud-talk/runtime-api.ts | 2 +- src/commands/channels/remove.ts | 12 +++++++++--- src/plugin-sdk/core.ts | 2 ++ 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 324abaf11c4..9eecea28139 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -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"; diff --git a/extensions/nextcloud-talk/runtime-api.ts b/extensions/nextcloud-talk/runtime-api.ts index ba31a546cdf..fc9283930bd 100644 --- a/extensions/nextcloud-talk/runtime-api.ts +++ b/extensions/nextcloud-talk/runtime-api.ts @@ -1 +1 @@ -export * from "../../src/plugin-sdk/nextcloud-talk.js"; +export * from "openclaw/plugin-sdk/nextcloud-talk"; diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index f48a85f8521..127dee5a3f9 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -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; } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index c80e681350b..e5605756e90 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -14,6 +14,7 @@ import type { OpenClawPluginConfigSchema, OpenClawPluginDefinition, PluginInteractiveTelegramHandlerContext, + PluginCommandContext, } from "../plugins/types.js"; export type { @@ -52,6 +53,7 @@ export type { ProviderAuthResult, OpenClawPluginCommandDefinition, OpenClawPluginDefinition, + PluginCommandContext, PluginLogger, PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; From 16129272dc94a23377c667cf60fdbf6cd58f2071 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 08:31:38 -0400 Subject: [PATCH 006/137] Tests: update Matrix agent bind fixtures --- src/cli/program/register.agent.test.ts | 4 +-- src/commands/agents.bind.commands.test.ts | 30 ++++++++++++++--------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 2d37e56a702..15fcc4d06dd 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -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, diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index 0fe03173be6..0b55adb2cdd 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -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(); From 75e6c8fe9c07ab98146179a2220c6001aa4b3154 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 08:31:44 -0400 Subject: [PATCH 007/137] Matrix: persist clean shutdown sync state --- .../src/matrix/client/file-sync-store.test.ts | 44 +++++++++++++++++++ .../src/matrix/client/file-sync-store.ts | 22 ++++++++++ .../matrix/src/matrix/monitor/index.test.ts | 16 ++++--- extensions/matrix/src/matrix/monitor/index.ts | 33 +++++++++----- extensions/matrix/src/matrix/sdk.ts | 5 ++- 5 files changed, 103 insertions(+), 17 deletions(-) diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 5bda781b5b2..632ec309210 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -91,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 () => { diff --git a/extensions/matrix/src/matrix/client/file-sync-store.ts b/extensions/matrix/src/matrix/client/file-sync-store.ts index 411f4e0decd..cbb71e09727 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.ts @@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = { version: number; savedSync: ISyncData | null; clientOptions?: IStoredClientOpts; + cleanShutdown?: boolean; }; function createAsyncLock() { @@ -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 | 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 { 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 { 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 { diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 6d6779de445..34538ed5b80 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -17,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: null as Error | null, - stopSharedClientInstance, stopThreadBindingManager, }; }); @@ -127,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", () => ({ @@ -206,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()); @@ -251,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(); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index cb0b22734be..957d629440c 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -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((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; } } diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index b2084e5c210..5b56e07d5d8 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -350,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 { @@ -367,6 +369,7 @@ export class MatrixClient { } this.decryptBridge.stop(); // Final persist on shutdown + this.syncStore?.markCleanShutdown(); this.stopPersistPromise = Promise.all([ persistIdbToDisk({ snapshotPath: this.idbSnapshotPath, From 47b02435c1c023d3ee95d903d0fb70df6314013c Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Thu, 19 Mar 2026 04:37:37 -0700 Subject: [PATCH 008/137] fix: honor BlueBubbles chunk mode and envelope timezone --- src/auto-reply/envelope.ts | 4 +-- src/auto-reply/reply/block-streaming.test.ts | 28 ++++++++++++++++++ src/auto-reply/reply/block-streaming.ts | 16 ++++------- src/auto-reply/reply/get-reply-run.ts | 3 ++ src/auto-reply/reply/inbound-meta.test.ts | 20 +++++++++++++ src/auto-reply/reply/inbound-meta.ts | 30 ++++++++------------ 6 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 3a2985419dd..5eedb19dd0c 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -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); } diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts index 29264ca99b3..9da4f73a619 100644 --- a/src/auto-reply/reply/block-streaming.test.ts +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -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).toBe(true); + expect(resolved.coalescing.joiner).toBe("\n\n"); + }); + it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => { const cfg = { channels: { diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 9149f7c8562..8db8170e060 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -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([...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( diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 760c42aed1a..c8451fd88f6 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -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 diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index b39fe5c9805..db964a9db26 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -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({ diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 519414fa109..8aa9973bae0 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -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, From 20728e1035111ed26a50d6c4432a9645529e6add Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Thu, 19 Mar 2026 05:39:38 -0700 Subject: [PATCH 009/137] fix: stop newline block streaming from sending per paragraph --- src/agents/pi-embedded-block-chunker.test.ts | 35 +++++++++++-------- src/agents/pi-embedded-block-chunker.ts | 21 ++++++----- src/auto-reply/reply/block-reply-coalescer.ts | 4 +-- src/auto-reply/reply/block-streaming.test.ts | 2 +- src/auto-reply/reply/block-streaming.ts | 15 +++----- src/auto-reply/reply/reply-utils.test.ts | 33 +++++++++++++++++ 6 files changed, 71 insertions(+), 39 deletions(-) diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index c8b1f5dda55..0766dce9233 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -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, diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index 11eddc2d190..6abe7b5a7da 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -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; } diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index c7a6f85c26b..ada535ad7cc 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -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 }); diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts index 9da4f73a619..1850f1521c8 100644 --- a/src/auto-reply/reply/block-streaming.test.ts +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -68,7 +68,7 @@ describe("resolveEffectiveBlockStreamingConfig", () => { }); expect(resolved.chunking.flushOnParagraph).toBe(true); - expect(resolved.coalescing.flushOnEnqueue).toBe(true); + expect(resolved.coalescing.flushOnEnqueue).toBeUndefined(); expect(resolved.coalescing.joiner).toBe("\n\n"); }); diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 8db8170e060..df1582846ff 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -66,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; }; @@ -147,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 }; @@ -161,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)); @@ -192,7 +192,6 @@ export function resolveBlockStreamingCoalescing( maxChars: number; breakPreference: "paragraph" | "newline" | "sentence"; }, - opts?: { chunkMode?: "length" | "newline" }, ): BlockStreamingCoalescing | undefined { const { providerKey, providerId, textLimit } = resolveProviderChunkContext( cfg, @@ -200,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; @@ -237,6 +233,5 @@ export function resolveBlockStreamingCoalescing( maxChars, idleMs, joiner, - flushOnEnqueue: chunkMode === "newline", }; } diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index fc499e93676..2055ce54583 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -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 = [ { From 7f86be1037aeb696303607660d979923d25aa484 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 08:50:38 -0400 Subject: [PATCH 010/137] Matrix: accept messageId alias for poll votes --- extensions/matrix/src/tool-actions.test.ts | 19 ++++++++++++++++ extensions/matrix/src/tool-actions.ts | 26 +++++++++++++++++++++- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index d917f33090f..341569d6beb 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -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( diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 4e2bd5aff4a..3798818c0d9 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -97,6 +97,27 @@ function readRawParam(params: Record, key: string): unknown { return undefined; } +function readStringAliasParam( + params: Record, + 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, 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 = [ From 550837466998d547851456270d535d3cba8f8a08 Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 09:10:24 -0400 Subject: [PATCH 011/137] fix(plugins): share split-load singleton state (openclaw#50418) thanks @huntharo Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> --- CHANGELOG.md | 1 + .../whatsapp/src/active-listener.test.ts | 36 ++++++++++++++++ extensions/whatsapp/src/active-listener.ts | 19 ++++----- src/plugins/commands.test.ts | 42 +++++++++++++++++++ src/plugins/commands.ts | 23 ++++++---- 5 files changed, 102 insertions(+), 19 deletions(-) create mode 100644 extensions/whatsapp/src/active-listener.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c5a376f35bc..3dab0842940 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -161,6 +161,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 diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts new file mode 100644 index 00000000000..a1d037f788a --- /dev/null +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -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 { + 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, + }); + }); +}); diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 3315a5775ec..8b62d15ff1f 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -28,27 +28,22 @@ export type ActiveWebListener = { close?: () => Promise; }; -// 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; + [GLOBAL_LISTENERS_KEY]?: Map; [GLOBAL_CURRENT_KEY]?: ActiveWebListener | null; }; const _global = globalThis as GlobalWithListeners; -_global[GLOBAL_KEY] ??= new Map(); +_global[GLOBAL_LISTENERS_KEY] ??= new Map(); _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; diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index c1c482e2bd2..9f10ae7fe81 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -12,6 +12,14 @@ import { } from "./commands.js"; import { setActivePluginRegistry } from "./runtime.js"; +type CommandsModule = typeof import("./commands.js"); + +const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href; + +async function importCommandsModule(cacheBust: string): Promise { + return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule; +} + beforeEach(() => { setActivePluginRegistry( createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), @@ -108,6 +116,40 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("shares plugin commands across duplicate module instances", async () => { + const first = await importCommandsModule(`first-${Date.now()}`); + const second = await importCommandsModule(`second-${Date.now()}`); + + first.clearPluginCommands(); + + expect( + first.registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + telegram: "voice", + }, + description: "Voice command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect(second.getPluginCommandSpecs("telegram")).toEqual([ + { + name: "voice", + description: "Voice command", + acceptsArgs: false, + }, + ]); + expect(second.matchPluginCommand("/voice")).toMatchObject({ + command: expect.objectContaining({ + name: "voice", + pluginId: "demo-plugin", + }), + }); + + second.clearPluginCommands(); + }); + it("matches provider-specific native aliases back to the canonical command", () => { const result = registerPluginCommand("demo-plugin", { name: "voice", diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index a44cbc26e7e..8137ebbed1b 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -8,6 +8,7 @@ import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, @@ -25,11 +26,19 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginRoot?: string; }; -// Registry of plugin commands -const pluginCommands: Map = new Map(); +type PluginCommandState = { + pluginCommands: Map; + registryLocked: boolean; +}; -// Lock to prevent modifications during command execution -let registryLocked = false; +const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); + +const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ + pluginCommands: new Map(), + registryLocked: false, +})); + +const pluginCommands = state.pluginCommands; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; @@ -172,7 +181,7 @@ export function registerPluginCommand( opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (registryLocked) { + if (state.registryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -451,7 +460,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - registryLocked = true; + state.registryLocked = true; try { const result = await command.handler(ctx); logVerbose( @@ -464,7 +473,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - registryLocked = false; + state.registryLocked = false; } } From 191e1947c1b1ec6f5c819c8ec20150697f14acbb Mon Sep 17 00:00:00 2001 From: Johnson Shi <13926417+johnsonshi@users.noreply.github.com> Date: Thu, 19 Mar 2026 06:15:06 -0700 Subject: [PATCH 012/137] docs: add Azure VM deployment guide with in-repo ARM templates and bootstrap script (#47898) * docs: add Azure Linux VM install guide * docs: move Azure guide into dedicated docs/install/azure layout * docs: polish Azure guide onboarding and reference links * docs: address Azure review feedback on bootstrap safety * docs: format azure ARM template * docs: flatten Azure install docs and move ARM assets --- docs/docs.json | 13 + docs/install/azure.md | 169 +++++++++ docs/platforms/index.md | 1 + docs/vps.md | 3 +- infra/azure/templates/azuredeploy.json | 340 ++++++++++++++++++ .../templates/azuredeploy.parameters.json | 48 +++ 6 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 docs/install/azure.md create mode 100644 infra/azure/templates/azuredeploy.json create mode 100644 infra/azure/templates/azuredeploy.parameters.json diff --git a/docs/docs.json b/docs/docs.json index 1e5cf45d4d5..e80697ac63d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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", diff --git a/docs/install/azure.md b/docs/install/azure.md new file mode 100644 index 00000000000..a257059f75d --- /dev/null +++ b/docs/install/azure.md @@ -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 you’ll 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 + +You’ll 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 don’t 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) diff --git a/docs/platforms/index.md b/docs/platforms/index.md index ec2663aefe4..37a0a47a6fb 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -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 diff --git a/docs/vps.md b/docs/vps.md index 66c2fdaf93f..9847f88e98d 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -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) diff --git a/infra/azure/templates/azuredeploy.json b/infra/azure/templates/azuredeploy.json new file mode 100644 index 00000000000..41157feec46 --- /dev/null +++ b/infra/azure/templates/azuredeploy.json @@ -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'))]" + } + } +} diff --git a/infra/azure/templates/azuredeploy.parameters.json b/infra/azure/templates/azuredeploy.parameters.json new file mode 100644 index 00000000000..dead2e5dd3f --- /dev/null +++ b/infra/azure/templates/azuredeploy.parameters.json @@ -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 + } + } +} From dd10f290e825d6fa2d04f805234c4508c763804b Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 09:24:24 -0400 Subject: [PATCH 013/137] Matrix: wire thread binding command support --- docs/channels/matrix.md | 5 +- docs/install/migrating-matrix.md | 6 +- src/auto-reply/reply/channel-context.ts | 4 + src/auto-reply/reply/commands-acp.test.ts | 130 ++++++++++++++-- .../reply/commands-acp/context.test.ts | 21 +++ src/auto-reply/reply/commands-acp/context.ts | 28 ++++ .../reply/commands-acp/lifecycle.ts | 22 +-- src/auto-reply/reply/commands-acp/shared.ts | 8 +- .../reply/commands-session-lifecycle.test.ts | 130 +++++++++++++++- src/auto-reply/reply/commands-session.ts | 145 ++++++++++++++++-- .../reply/commands-subagents-focus.test.ts | 116 +++++++++++++- .../reply/commands-subagents/action-focus.ts | 95 +++++++++++- .../commands-subagents/action-unfocus.ts | 54 ++++++- .../reply/commands-subagents/shared.ts | 2 + src/channels/thread-bindings-policy.ts | 15 +- src/plugin-sdk/matrix.ts | 4 + src/plugins/runtime/runtime-channel.ts | 6 +- src/plugins/runtime/runtime-matrix.ts | 14 ++ src/plugins/runtime/types-channel.ts | 6 + .../helpers/extensions/plugin-runtime-mock.ts | 1 + 20 files changed, 756 insertions(+), 56 deletions(-) create mode 100644 src/plugins/runtime/runtime-matrix.ts diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 4d9d0fa0e4f..d6ec40ff4db 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -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. diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index d1e85c5ecd1..bd8772e29f6 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -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 ""`. -`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 ""`. diff --git a/src/auto-reply/reply/channel-context.ts b/src/auto-reply/reply/channel-context.ts index d8ffb261eb8..afe77e32805 100644 --- a/src/auto-reply/reply/channel-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -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 ?? diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 5d732e4b4e6..ca8ece9b3cc 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -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; @@ -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, diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 5b1e60ad1fc..721ee325b48 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -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", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index de3a615eb4b..7a326f4d564 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -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) ?? diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 42ee1d2e184..89615c9e74e 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -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({ diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 2b0571b332f..438fe963c11 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -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); diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index bb56ef82bd9..8d31fbf8c0d 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -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) { + 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) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + ...overrides, + }); +} + function createFakeBinding(overrides: Partial = {}): FakeBinding { const now = Date.now(); return { @@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } +function createMatrixBinding(overrides?: Partial): 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, 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)); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 0359c77331b..29f85050a43 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -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) diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 651d8088486..de799e5208b 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -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 { @@ -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; }) => 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"); }); }); diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index df7a268b3b0..f55cbe95a39 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -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 { 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: { diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts index 78bb02b2427..0331772316e 100644 --- a/src/auto-reply/reply/commands-subagents/action-unfocus.ts +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -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 { 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.", ); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 9781683267e..3d2b9726da3 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -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, diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 15f3f5557fe..5fe30994da0 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -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}.`; } diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index b1cfd8c5195..a85e8997389 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -82,6 +82,10 @@ export { resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, } from "../channels/thread-bindings-policy.js"; +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "../../extensions/matrix/src/matrix/thread-bindings.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 80bb1aba736..0617cb7f8ff 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -78,6 +78,7 @@ import { import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; import { createRuntimeDiscord } from "./runtime-discord.js"; import { createRuntimeIMessage } from "./runtime-imessage.js"; +import { createRuntimeMatrix } from "./runtime-matrix.js"; import { createRuntimeSignal } from "./runtime-signal.js"; import { createRuntimeSlack } from "./runtime-slack.js"; import { createRuntimeTelegram } from "./runtime-telegram.js"; @@ -206,18 +207,19 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { }, } satisfies Omit< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > & Partial< Pick< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > >; defineCachedValue(channelRuntime, "discord", createRuntimeDiscord); defineCachedValue(channelRuntime, "slack", createRuntimeSlack); defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram); + defineCachedValue(channelRuntime, "matrix", createRuntimeMatrix); defineCachedValue(channelRuntime, "signal", createRuntimeSignal); defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage); defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp); diff --git a/src/plugins/runtime/runtime-matrix.ts b/src/plugins/runtime/runtime-matrix.ts new file mode 100644 index 00000000000..d97734397c0 --- /dev/null +++ b/src/plugins/runtime/runtime-matrix.ts @@ -0,0 +1,14 @@ +import { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "openclaw/plugin-sdk/matrix"; +import type { PluginRuntimeChannel } from "./types-channel.js"; + +export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] { + return { + threadBindings: { + setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKey, + setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKey, + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index a0fe9a1d9bc..0a7eab63727 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -193,6 +193,12 @@ export type PluginRuntimeChannel = { unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; }; }; + matrix: { + threadBindings: { + setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + }; + }; signal: { probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index d71eeb2d584..c0b73a6e15d 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -297,6 +297,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = line: {} as PluginRuntime["channel"]["line"], slack: {} as PluginRuntime["channel"]["slack"], telegram: {} as PluginRuntime["channel"]["telegram"], + matrix: {} as PluginRuntime["channel"]["matrix"], signal: {} as PluginRuntime["channel"]["signal"], imessage: {} as PluginRuntime["channel"]["imessage"], whatsapp: {} as PluginRuntime["channel"]["whatsapp"], From 1c1a3b6a7575dfe84eccdee325a693acf343b984 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 06:13:43 -0700 Subject: [PATCH 014/137] fix(discord): break plugin-sdk account helper cycle --- extensions/discord/src/account-inspect.ts | 14 ++++++-------- extensions/discord/src/accounts.ts | 14 ++++++++------ extensions/discord/src/runtime-api.ts | 5 +++-- src/config/types.discord.ts | 6 +++++- src/plugin-sdk/discord-core.ts | 3 ++- src/plugin-sdk/discord.ts | 3 +-- 6 files changed, 25 insertions(+), 20 deletions(-) diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 0b3bd3f8fc8..7166c3cf9fd 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,16 +1,14 @@ +import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "openclaw/plugin-sdk/config-runtime"; 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"; diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index ea28be7fb0d..714d2a2779f 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,12 +1,14 @@ +import type { + DiscordAccountConfig, + DiscordActionConfig, + OpenClawConfig, +} from "openclaw/plugin-sdk/discord-core"; 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 { resolveAccountEntry } from "openclaw/plugin-sdk/routing"; import { resolveDiscordToken } from "./token.js"; export type ResolvedDiscordAccount = { diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 2357a477e76..0d355ab506f 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -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, diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 2b115ec67b6..2177791bce1 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,3 @@ -import type { DiscordPluralKitConfig } from "openclaw/plugin-sdk/discord"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, @@ -19,6 +18,11 @@ import type { TtsConfig } from "./types.tts.js"; export type DiscordStreamMode = "off" | "partial" | "block" | "progress"; +export type DiscordPluralKitConfig = { + enabled?: boolean; + token?: string; +}; + export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; diff --git a/src/plugin-sdk/discord-core.ts b/src/plugin-sdk/discord-core.ts index 4de83bafb7d..23531f74248 100644 --- a/src/plugin-sdk/discord-core.ts +++ b/src/plugin-sdk/discord-core.ts @@ -1,7 +1,8 @@ export type { ChannelPlugin } from "./channel-plugin-common.js"; -export type { DiscordActionConfig } from "../config/types.js"; +export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; export { buildChannelConfigSchema, getChatChannelMeta } from "./channel-plugin-common.js"; export type { OpenClawConfig } from "../config/config.js"; +export type { DiscordConfig } from "../config/types.discord.js"; export { withNormalizedTimestamp } from "../agents/date-time.js"; export { assertMediaNotDataUrl } from "../agents/sandbox-paths.js"; export { diff --git a/src/plugin-sdk/discord.ts b/src/plugin-sdk/discord.ts index c3e9936d4a2..043e9cfa4b9 100644 --- a/src/plugin-sdk/discord.ts +++ b/src/plugin-sdk/discord.ts @@ -5,8 +5,7 @@ export type { } from "../channels/plugins/types.js"; export type { OpenClawConfig } from "../config/config.js"; export type { DiscordAccountConfig, DiscordActionConfig } from "../config/types.js"; -export type { DiscordConfig } from "../config/types.discord.js"; -export type { DiscordPluralKitConfig } from "../../extensions/discord/api.js"; +export type { DiscordConfig, DiscordPluralKitConfig } from "../config/types.discord.js"; export type { InspectedDiscordAccount } from "../../extensions/discord/api.js"; export type { ResolvedDiscordAccount } from "../../extensions/discord/api.js"; export type { DiscordSendComponents, DiscordSendEmbeds } from "../../extensions/discord/api.js"; From a0445b192e84ad97510729c5b23f36fa2a638ed4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 06:13:56 -0700 Subject: [PATCH 015/137] test(signal): mock daemon readiness in monitor suite --- ...ends-tool-summaries-responseprefix.test.ts | 10 +++++--- .../src/monitor.tool-result.test-harness.ts | 2 +- extensions/signal/src/monitor.ts | 23 +++++++++++-------- 3 files changed, 22 insertions(+), 13 deletions(-) diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index ccefd20b064..812895a15e6 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import { peekSystemEvents } from "../../../src/infra/system-events.js"; +import type { SignalDaemonExitEvent } from "./daemon.js"; import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; import { normalizeE164 } from "../../../src/utils.js"; -import type { SignalDaemonExitEvent } from "./daemon.js"; import { createMockSignalDaemonHandle, config, @@ -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, }); } diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 7445fc0ffb7..ad81a4d6da2 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -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([]); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 20f0c943823..bdc3da35baf 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -1,12 +1,13 @@ import type { OpenClawConfig } 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 type { ReplyPayload } from "openclaw/plugin-sdk/reply-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"; import { @@ -19,20 +20,19 @@ import { 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"; -import { resolveSignalAccount } from "./accounts.js"; -import { signalCheck, signalRpcRequest } from "./client.js"; -import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; -import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; -import { createSignalEventHandler } from "./monitor/event-handler.js"; import type { SignalAttachment, SignalReactionMessage, SignalReactionTarget, } from "./monitor/event-handler.types.js"; +import { resolveSignalAccount } from "./accounts.js"; +import { signalCheck, signalRpcRequest } from "./client.js"; +import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; +import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; +import { createSignalEventHandler } from "./monitor/event-handler.js"; import { sendMessageSignal } from "./send.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; @@ -56,6 +56,7 @@ export type MonitorSignalOpts = { groupAllowFrom?: Array; mediaMaxMb?: number; reconnectPolicy?: Partial; + 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 { - 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) { From 79d7fdce932b3f88cc07173effe80d3ceb61e3d5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 06:30:38 -0700 Subject: [PATCH 016/137] test(telegram): inject media loader in delivery replies --- extensions/telegram/src/bot/delivery.replies.ts | 13 +++++++++---- extensions/telegram/src/bot/delivery.test.ts | 14 +++++++++++--- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 41dec78c70d..f773b3d1195 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -1,6 +1,8 @@ -import { type Bot, GrammyError, InputFile } from "grammy"; import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { type Bot, GrammyError, InputFile } from "grammy"; import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime"; import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime"; import { @@ -14,9 +16,7 @@ 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 type { RuntimeEnv } 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; 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, diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 20642a225ea..d22c97802cd 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -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>; + Partial>; type RuntimeStub = Pick; 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, }); } From c7cbc8cc0bf687c65659809b5d79287793b9ee34 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 09:44:25 -0400 Subject: [PATCH 017/137] CI: validate plugin runtime deps in install smoke --- .github/workflows/install-smoke.yml | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index a8115f1644a..8baa84ca67b 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -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) { From 8c013479890650cc540d7d7a6edf7fd4ca0a4ff6 Mon Sep 17 00:00:00 2001 From: Liu Ricardo Date: Thu, 19 Mar 2026 22:26:37 +0800 Subject: [PATCH 018/137] test(contracts): cover matrix session binding adapters (#50369) Merged via squash. Prepared head SHA: 25412dbc2ca91876882de1854da1f0e9c0640543 Co-authored-by: ChroniCat <220139611+ChroniCat@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + extensions/matrix/api.ts | 5 ++ .../matrix/src/matrix/thread-bindings.ts | 4 + src/channels/plugins/contracts/registry.ts | 81 ++++++++++++++++++- .../session-binding.contract.test.ts | 19 ++++- src/channels/plugins/contracts/suites.ts | 6 +- 6 files changed, 111 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dab0842940..a26a8e80b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts index 620864b9a90..4a3e03f0a31 100644 --- a/extensions/matrix/api.ts +++ b/extensions/matrix/api.ts @@ -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"; diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index eb9a7e4c1d9..fe3116f3691 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -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) { diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 94892151c7b..3068f790053 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -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; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | 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: { diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts index b8201569cde..efc85cb74b4 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -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(); }); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 892d4b293f9..7c9803ee47f 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: { } export function installSessionBindingContractSuite(params: { - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | 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 () => { From f4f0b171d3bcdfd88f051e1d2f8b852ff1f0eafa Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 10:30:12 -0400 Subject: [PATCH 019/137] Matrix: isolate credential write runtime --- extensions/matrix/src/matrix/accounts.test.ts | 2 +- extensions/matrix/src/matrix/accounts.ts | 2 +- extensions/matrix/src/matrix/client.test.ts | 28 +-- extensions/matrix/src/matrix/client/config.ts | 17 +- .../matrix/src/matrix/credentials-read.ts | 150 +++++++++++++++++ .../src/matrix/credentials-write.runtime.ts | 18 ++ extensions/matrix/src/matrix/credentials.ts | 159 ++---------------- 7 files changed, 206 insertions(+), 170 deletions(-) create mode 100644 extensions/matrix/src/matrix/credentials-read.ts create mode 100644 extensions/matrix/src/matrix/credentials-write.runtime.ts diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 45db29362ce..8480ef0e94b 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -7,7 +7,7 @@ import { resolveMatrixAccount, } from "./accounts.js"; -vi.mock("./credentials.js", () => ({ +vi.mock("./credentials-read.js", () => ({ loadMatrixCredentials: () => null, credentialsMatchConfig: () => false, })); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index d0039664ac8..13e33a259a6 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -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 { diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index fc89a4944e7..663e5715daf 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -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: { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 6d137677657..e4be059ccc5 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -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 { 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, diff --git a/extensions/matrix/src/matrix/credentials-read.ts b/extensions/matrix/src/matrix/credentials-read.ts new file mode 100644 index 00000000000..e297072fea4 --- /dev/null +++ b/extensions/matrix/src/matrix/credentials-read.ts @@ -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; + 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; +} diff --git a/extensions/matrix/src/matrix/credentials-write.runtime.ts b/extensions/matrix/src/matrix/credentials-write.runtime.ts new file mode 100644 index 00000000000..5e773861e42 --- /dev/null +++ b/extensions/matrix/src/matrix/credentials-write.runtime.ts @@ -0,0 +1,18 @@ +import type { + saveMatrixCredentials as saveMatrixCredentialsType, + touchMatrixCredentials as touchMatrixCredentialsType, +} from "./credentials.js"; + +export async function saveMatrixCredentials( + ...args: Parameters +): ReturnType { + const runtime = await import("./credentials.js"); + return runtime.saveMatrixCredentials(...args); +} + +export async function touchMatrixCredentials( + ...args: Parameters +): ReturnType { + const runtime = await import("./credentials.js"); + return runtime.touchMatrixCredentials(...args); +} diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index eaccd0ed487..7fb71715ddf 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -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; - 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, @@ -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; -} From 0c4fdf12846f5d2328b59b6020729eab1a5fc3e8 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 10:32:50 -0400 Subject: [PATCH 020/137] Format: apply import ordering cleanup --- extensions/discord/src/account-inspect.ts | 2 +- extensions/discord/src/accounts.ts | 10 +++++----- ...ult.sends-tool-summaries-responseprefix.test.ts | 2 +- extensions/signal/src/monitor.ts | 14 +++++++------- extensions/telegram/src/bot/delivery.replies.ts | 6 +++--- 5 files changed, 17 insertions(+), 17 deletions(-) diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 7166c3cf9fd..9f13b612dab 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -1,9 +1,9 @@ -import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/discord-core"; 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, diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 714d2a2779f..ab014f4bc4a 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -1,13 +1,13 @@ -import type { - DiscordAccountConfig, - DiscordActionConfig, - OpenClawConfig, -} from "openclaw/plugin-sdk/discord-core"; import { createAccountActionGate, createAccountListHelpers, } 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"; diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index 812895a15e6..e8ee7403e38 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; -import type { SignalDaemonExitEvent } from "./daemon.js"; import { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; import { normalizeE164 } from "../../../src/utils.js"; +import type { SignalDaemonExitEvent } from "./daemon.js"; import { createMockSignalDaemonHandle, config, diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index bdc3da35baf..b0e601fc01e 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -1,19 +1,19 @@ import type { OpenClawConfig } 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 type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { loadConfig } from "openclaw/plugin-sdk/config-runtime"; import { resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce, } 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"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, } from "openclaw/plugin-sdk/reply-payload"; +import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { chunkTextWithMode, resolveChunkMode, @@ -23,16 +23,16 @@ import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin- 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"; -import type { - SignalAttachment, - SignalReactionMessage, - SignalReactionTarget, -} from "./monitor/event-handler.types.js"; import { resolveSignalAccount } from "./accounts.js"; import { signalCheck, signalRpcRequest } from "./client.js"; import { formatSignalDaemonExit, spawnSignalDaemon, type SignalDaemonHandle } from "./daemon.js"; import { isSignalSenderAllowed, type resolveSignalSender } from "./identity.js"; import { createSignalEventHandler } from "./monitor/event-handler.js"; +import type { + SignalAttachment, + SignalReactionMessage, + SignalReactionTarget, +} from "./monitor/event-handler.types.js"; import { sendMessageSignal } from "./send.js"; import { runSignalSseLoop } from "./sse-reconnect.js"; diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index f773b3d1195..e1f464c52a5 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -1,8 +1,6 @@ +import { type Bot, GrammyError, InputFile } from "grammy"; import type { ReplyToMode } from "openclaw/plugin-sdk/config-runtime"; import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-runtime"; -import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; -import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; -import { type Bot, GrammyError, InputFile } from "grammy"; import { fireAndForgetHook } from "openclaw/plugin-sdk/hook-runtime"; import { createInternalHookEvent, triggerInternalHook } from "openclaw/plugin-sdk/hook-runtime"; import { @@ -15,7 +13,9 @@ 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 type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; 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"; From 44cd4fb55fde2d1715aa163be2e296ad032e9924 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 07:50:02 -0700 Subject: [PATCH 021/137] fix(ci): repair main type and boundary regressions --- .../src/actions.account-propagation.test.ts | 6 +- extensions/matrix/src/actions.test.ts | 134 ++++++++++-------- extensions/matrix/src/cli.test.ts | 14 +- .../src/matrix/client/file-sync-store.test.ts | 4 +- .../src/matrix/client/file-sync-store.ts | 35 ++++- .../matrix/src/matrix/monitor/events.test.ts | 2 +- .../matrix/monitor/handler.test-helpers.ts | 3 +- .../matrix/src/matrix/monitor/handler.test.ts | 2 + .../matrix/src/matrix/monitor/index.test.ts | 13 +- .../matrix/src/matrix/monitor/route.test.ts | 8 +- extensions/matrix/src/matrix/sdk.test.ts | 27 ++-- extensions/matrix/src/setup-surface.ts | 5 +- src/agents/acp-spawn.test.ts | 17 ++- .../subagent-announce.format.e2e.test.ts | 89 ++++++++---- src/channels/plugins/message-action-names.ts | 1 + src/commands/channels/add.ts | 6 +- src/commands/channels/remove.ts | 9 +- src/plugin-sdk/core.ts | 2 +- src/plugin-sdk/setup.ts | 5 +- .../extensions/matrix-monitor-route.ts | 8 ++ 20 files changed, 246 insertions(+), 144 deletions(-) create mode 100644 test/helpers/extensions/matrix-monitor-route.ts diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 0675fb2e440..12dfea963f3 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -12,6 +12,8 @@ vi.mock("./tool-actions.js", () => ({ const { matrixMessageActions } = await import("./actions.js"); +const profileAction = "set-profile" as ChannelMessageActionContext["action"]; + function createContext( overrides: Partial, ): ChannelMessageActionContext { @@ -88,7 +90,7 @@ describe("matrixMessageActions account propagation", () => { it("forwards accountId for self-profile updates", async () => { await matrixMessageActions.handleAction?.( createContext({ - action: "set-profile", + action: profileAction, accountId: "ops", params: { displayName: "Ops Bot", @@ -112,7 +114,7 @@ describe("matrixMessageActions account propagation", () => { it("forwards local avatar paths for self-profile updates", async () => { await matrixMessageActions.handleAction?.( createContext({ - action: "set-profile", + action: profileAction, accountId: "ops", params: { path: "/tmp/avatar.jpg", diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index df34411b806..5e657bb4603 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -4,6 +4,8 @@ import { matrixMessageActions } from "./actions.js"; import { setMatrixRuntime } from "./runtime.js"; import type { CoreConfig } from "./types.js"; +const profileAction = "set-profile" as const; + const runtimeStub = { config: { loadConfig: () => ({}), @@ -52,101 +54,115 @@ describe("matrixMessageActions", () => { it("exposes poll create but only handles poll votes inside the plugin", () => { const describeMessageTool = matrixMessageActions.describeMessageTool; - const supportsAction = matrixMessageActions.supportsAction; + const supportsAction = matrixMessageActions.supportsAction ?? (() => false); expect(describeMessageTool).toBeTypeOf("function"); expect(supportsAction).toBeTypeOf("function"); const discovery = describeMessageTool!({ cfg: createConfiguredMatrixConfig(), - } as never) ?? { actions: [] }; + } as never); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } const actions = discovery.actions; - expect(actions).toContain("poll"); expect(actions).toContain("poll-vote"); - expect(supportsAction!({ action: "poll" } as never)).toBe(false); - expect(supportsAction!({ action: "poll-vote" } as never)).toBe(true); + expect(supportsAction({ action: "poll" } as never)).toBe(false); + expect(supportsAction({ action: "poll-vote" } as never)).toBe(true); }); it("exposes and describes self-profile updates", () => { const describeMessageTool = matrixMessageActions.describeMessageTool; - const supportsAction = matrixMessageActions.supportsAction; + const supportsAction = matrixMessageActions.supportsAction ?? (() => false); const discovery = describeMessageTool!({ cfg: createConfiguredMatrixConfig(), - } as never) ?? { actions: [], schema: null }; + } as never); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } const actions = discovery.actions; - const properties = - (discovery.schema as { properties?: Record } | null)?.properties ?? {}; + const schema = discovery.schema; + if (!schema) { + throw new Error("matrix schema missing"); + } + const properties = (schema as { properties?: Record }).properties ?? {}; - expect(actions).toContain("set-profile"); - expect(supportsAction!({ action: "set-profile" } as never)).toBe(true); + expect(actions).toContain(profileAction); + expect(supportsAction({ action: profileAction } as never)).toBe(true); expect(properties.displayName).toBeDefined(); expect(properties.avatarUrl).toBeDefined(); expect(properties.avatarPath).toBeDefined(); }); 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 discovery = 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); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } + const actions = discovery.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 discovery = 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); + if (!discovery) { + throw new Error("describeMessageTool returned null"); + } + const actions = discovery.actions; expect(actions).toEqual([]); }); diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index 008fd46795d..da10215f435 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -20,6 +20,8 @@ const setMatrixSdkConsoleLoggingMock = vi.fn(); const setMatrixSdkLogModeMock = vi.fn(); const updateMatrixOwnProfileMock = vi.fn(); const verifyMatrixRecoveryKeyMock = vi.fn(); +const consoleLogMock = vi.fn(); +const consoleErrorMock = vi.fn(); vi.mock("./matrix/actions/verification.js", () => ({ bootstrapMatrixVerification: (...args: unknown[]) => bootstrapMatrixVerificationMock(...args), @@ -86,8 +88,12 @@ describe("matrix CLI verification commands", () => { beforeEach(() => { vi.clearAllMocks(); process.exitCode = undefined; - vi.spyOn(console, "log").mockImplementation(() => {}); - vi.spyOn(console, "error").mockImplementation(() => {}); + vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => consoleLogMock(...args)); + vi.spyOn(console, "error").mockImplementation((...args: unknown[]) => + consoleErrorMock(...args), + ); + consoleLogMock.mockReset(); + consoleErrorMock.mockReset(); matrixSetupValidateInputMock.mockReturnValue(null); matrixSetupApplyAccountConfigMock.mockImplementation(({ cfg }: { cfg: unknown }) => cfg); matrixRuntimeLoadConfigMock.mockReturnValue({}); @@ -521,9 +527,7 @@ describe("matrix CLI verification commands", () => { expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); - const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at( - -1, - )?.[0]; + const jsonOutput = consoleLogMock.mock.calls.at(-1)?.[0]; expect(typeof jsonOutput).toBe("string"); expect(JSON.parse(String(jsonOutput))).toEqual( expect.objectContaining({ diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 632ec309210..56c88433d9c 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -12,7 +12,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse { rooms: { join: { "!room:example.org": { - summary: { "m.heroes": [] }, + summary: { + "m.heroes": [], + }, state: { events: [] }, timeline: { events: [ diff --git a/extensions/matrix/src/matrix/client/file-sync-store.ts b/extensions/matrix/src/matrix/client/file-sync-store.ts index cbb71e09727..453e6b1bd38 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.ts @@ -1,9 +1,11 @@ import { readFileSync } from "node:fs"; import fs from "node:fs/promises"; import { + Category, MemoryStore, SyncAccumulator, type ISyncData, + type IRooms, type ISyncResponse, type IStoredClientOpts, } from "matrix-js-sdk"; @@ -41,31 +43,54 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function normalizeRoomsData(value: unknown): IRooms | null { + if (!isRecord(value)) { + return null; + } + return { + [Category.Join]: isRecord(value[Category.Join]) ? (value[Category.Join] as IRooms["join"]) : {}, + [Category.Invite]: isRecord(value[Category.Invite]) + ? (value[Category.Invite] as IRooms["invite"]) + : {}, + [Category.Leave]: isRecord(value[Category.Leave]) + ? (value[Category.Leave] as IRooms["leave"]) + : {}, + [Category.Knock]: isRecord(value[Category.Knock]) + ? (value[Category.Knock] as IRooms["knock"]) + : {}, + }; +} + function toPersistedSyncData(value: unknown): ISyncData | null { if (!isRecord(value)) { return null; } if (typeof value.nextBatch === "string" && value.nextBatch.trim()) { - if (!Array.isArray(value.accountData) || !isRecord(value.roomsData)) { + const roomsData = normalizeRoomsData(value.roomsData); + if (!Array.isArray(value.accountData) || !roomsData) { return null; } return { nextBatch: value.nextBatch, accountData: value.accountData, - roomsData: value.roomsData, - } as unknown as ISyncData; + roomsData, + }; } // Older Matrix state files stored the raw /sync-shaped payload directly. if (typeof value.next_batch === "string" && value.next_batch.trim()) { + const roomsData = normalizeRoomsData(value.rooms); + if (!roomsData) { + return null; + } return { nextBatch: value.next_batch, accountData: isRecord(value.account_data) && Array.isArray(value.account_data.events) ? value.account_data.events : [], - roomsData: isRecord(value.rooms) ? value.rooms : {}, - } as unknown as ISyncData; + roomsData, + }; } return null; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 5d4642bdb5e..bd4caa97fa7 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -516,7 +516,7 @@ describe("registerMatrixMonitorEvents verification routing", () => { await vi.waitFor(() => { expect(sendMessage).toHaveBeenCalledTimes(1); }); - const roomId = (sendMessage.mock.calls[0]?.[0] ?? "") as string; + const roomId = ((sendMessage.mock.calls as unknown[][])[0]?.[0] ?? "") as string; const body = getSentNoticeBody(sendMessage, 0); expect(roomId).toBe("!dm-active:example.org"); expect(body).toContain("SAS decimal: 4321 8765 2109"); diff --git a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts index a39b9efec06..7a04948a191 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test-helpers.ts @@ -35,6 +35,7 @@ type MatrixHandlerTestHarnessOptions = { startupMs?: number; startupGraceMs?: number; dropPreStartupMessages?: boolean; + needsRoomAliasesForConfig?: boolean; isDirectMessage?: boolean; readAllowFromStore?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["readAllowFromStore"]; upsertPairingRequest?: MatrixMonitorHandlerParams["core"]["channel"]["pairing"]["upsertPairingRequest"]; @@ -179,7 +180,7 @@ export function createMatrixHandlerTestHarness( }, getRoomInfo: options.getRoomInfo ?? (async () => ({ altAliases: [] })), getMemberDisplayName: options.getMemberDisplayName ?? (async () => "sender"), - needsRoomAliasesForConfig: false, + needsRoomAliasesForConfig: options.needsRoomAliasesForConfig ?? false, }); return { diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index e28afdff33d..fc55012a6b5 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -177,6 +177,8 @@ describe("matrix monitor handler pairing account scope", () => { dmPolicy: "pairing", isDirectMessage: true, getMemberDisplayName: async () => "sender", + dropPreStartupMessages: true, + needsRoomAliasesForConfig: false, }); await handler( diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 34538ed5b80..7039968dd0b 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -2,6 +2,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const hoisted = vi.hoisted(() => { const callOrder: string[] = []; + const state = { + startClientError: null as Error | null, + }; const client = { id: "matrix-client", hasPersistedSyncState: vi.fn(() => false), @@ -27,7 +30,7 @@ const hoisted = vi.hoisted(() => { releaseSharedClientInstance, resolveTextChunkLimit, setActiveMatrixClient, - startClientError: null as Error | null, + state, stopThreadBindingManager, }; }); @@ -121,8 +124,8 @@ vi.mock("../client.js", () => ({ if (!hoisted.callOrder.includes("create-manager")) { throw new Error("Matrix client started before thread bindings were registered"); } - if (hoisted.startClientError) { - throw hoisted.startClientError; + if (hoisted.state.startClientError) { + throw hoisted.state.startClientError; } hoisted.callOrder.push("start-client"); return hoisted.client; @@ -207,7 +210,7 @@ describe("monitorMatrixProvider", () => { beforeEach(() => { vi.resetModules(); hoisted.callOrder.length = 0; - hoisted.startClientError = null; + hoisted.state.startClientError = null; hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000); hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true); hoisted.setActiveMatrixClient.mockReset(); @@ -249,7 +252,7 @@ describe("monitorMatrixProvider", () => { it("cleans up thread bindings and shared clients when startup fails", async () => { const { monitorMatrixProvider } = await import("./index.js"); - hoisted.startClientError = new Error("start failed"); + hoisted.state.startClientError = new Error("start failed"); await expect(monitorMatrixProvider()).rejects.toThrow("start failed"); diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts index 5846d45dd9c..f170db9080b 100644 --- a/extensions/matrix/src/matrix/monitor/route.test.ts +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { + __testing as sessionBindingTesting, createTestRegistry, - type OpenClawConfig, - resolveAgentRoute, registerSessionBindingAdapter, - sessionBindingTesting, + resolveAgentRoute, setActivePluginRegistry, -} from "../../../../../test/helpers/extensions/matrix-route-test.js"; + type OpenClawConfig, +} from "../../../../../test/helpers/extensions/matrix-monitor-route.js"; import { matrixPlugin } from "../../channel.js"; import { resolveMatrixInboundRoute } from "./route.js"; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index e25d215af05..8975af5bdff 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -222,9 +222,8 @@ describe("MatrixClient request hardening", () => { it("prefers authenticated client media downloads", async () => { const payload = Buffer.from([1, 2, 3, 4]); - const fetchMock = vi.fn( - async (_input: RequestInfo | URL, _init?: RequestInit) => - new Response(payload, { status: 200 }), + const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>( + async () => new Response(payload, { status: 200 }), ); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); @@ -232,7 +231,7 @@ describe("MatrixClient request hardening", () => { await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); expect(fetchMock).toHaveBeenCalledTimes(1); - const firstUrl = String(fetchMock.mock.calls[0]?.[0]); + const firstUrl = String((fetchMock.mock.calls as unknown[][])[0]?.[0] ?? ""); expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); }); @@ -260,8 +259,8 @@ describe("MatrixClient request hardening", () => { await expect(client.downloadContent("mxc://example.org/media")).resolves.toEqual(payload); expect(fetchMock).toHaveBeenCalledTimes(2); - const firstUrl = String(fetchMock.mock.calls[0]?.[0]); - const secondUrl = String(fetchMock.mock.calls[1]?.[0]); + const firstUrl = String((fetchMock.mock.calls as unknown[][])[0]?.[0] ?? ""); + const secondUrl = String((fetchMock.mock.calls as unknown[][])[1]?.[0] ?? ""); expect(firstUrl).toContain("/_matrix/client/v1/media/download/example.org/media"); expect(secondUrl).toContain("/_matrix/media/v3/download/example.org/media"); }); @@ -977,7 +976,7 @@ describe("MatrixClient crypto bootstrapping", () => { await client.start(); expect(bootstrapSpy).toHaveBeenCalledTimes(2); - expect(bootstrapSpy.mock.calls[1]?.[1]).toEqual({ + expect((bootstrapSpy.mock.calls as unknown[][])[1]?.[1] ?? {}).toEqual({ forceResetCrossSigning: true, strict: true, }); @@ -1025,7 +1024,7 @@ describe("MatrixClient crypto bootstrapping", () => { await client.start(); expect(bootstrapSpy).toHaveBeenCalledTimes(1); - expect(bootstrapSpy.mock.calls[0]?.[1]).toEqual({ + expect((bootstrapSpy.mock.calls as unknown[][])[0]?.[1] ?? {}).toEqual({ allowAutomaticCrossSigningReset: false, }); }); @@ -2061,12 +2060,12 @@ describe("MatrixClient crypto bootstrapping", () => { expect(result.success).toBe(true); expect(result.verification.backupVersion).toBe("9"); - const bootstrapSecretStorageCalls = bootstrapSecretStorage.mock.calls as Array< - [{ setupNewKeyBackup?: boolean }?] - >; - expect(bootstrapSecretStorageCalls.some((call) => Boolean(call[0]?.setupNewKeyBackup))).toBe( - false, - ); + const bootstrapSecretStorageCalls = bootstrapSecretStorage.mock.calls as Array; + expect( + bootstrapSecretStorageCalls.some((call) => + Boolean((call[0] as { setupNewKeyBackup?: boolean })?.setupNewKeyBackup), + ), + ).toBe(false); }); it("does not report bootstrap errors when final verification state is healthy", async () => { diff --git a/extensions/matrix/src/setup-surface.ts b/extensions/matrix/src/setup-surface.ts index ed601b90400..cd4ab580eb3 100644 --- a/extensions/matrix/src/setup-surface.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1 +1,4 @@ -export { matrixOnboardingAdapter } from "./onboarding.js"; +export { + matrixOnboardingAdapter, + matrixOnboardingAdapter as matrixSetupWizard, +} from "./onboarding.js"; diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index 3b93bf0a826..0ca4dd2c903 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -1,5 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as acpSessionManager from "../acp/control-plane/manager.js"; +import type { + AcpCloseSessionInput, + AcpInitializeSessionInput, +} from "../acp/control-plane/manager.types.js"; import { clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot, @@ -180,16 +184,12 @@ describe("spawnAcpDirect", () => { metaCleared: false, }); getAcpSessionManagerSpy.mockReset().mockReturnValue({ - initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params), - closeSession: async (params: unknown) => await hoisted.closeSessionMock(params), + initializeSession: async (params: AcpInitializeSessionInput) => + await hoisted.initializeSessionMock(params), + closeSession: async (params: AcpCloseSessionInput) => await hoisted.closeSessionMock(params), } as unknown as ReturnType); hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { - const args = argsUnknown as { - sessionKey: string; - agent: string; - mode: "persistent" | "oneshot"; - cwd?: string; - }; + const args = argsUnknown as AcpInitializeSessionInput; const runtimeSessionName = `${args.sessionKey}:runtime`; const cwd = typeof args.cwd === "string" ? args.cwd : undefined; return { @@ -386,7 +386,6 @@ describe("spawnAcpDirect", () => { matrix: { threadBindings: { enabled: true, - spawnAcpSessions: true, }, }, }, diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 280172dc073..265fda978e9 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -6,12 +6,14 @@ import { type OpenClawConfig, } from "../config/config.js"; import * as configSessions from "../config/sessions.js"; +import type { SessionEntry } from "../config/sessions/types.js"; import * as gatewayCall from "../gateway/call.js"; import { __testing as sessionBindingServiceTesting, registerSessionBindingAdapter, } from "../infra/outbound/session-binding-service.js"; import * as hookRunnerGlobal from "../plugins/hook-runner-global.js"; +import type { HookRunner } from "../plugins/hooks.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; import { createTestRegistry } from "../test-utils/channel-plugins.js"; import * as piEmbedded from "./pi-embedded.js"; @@ -65,11 +67,23 @@ const waitForEmbeddedPiRunEndSpy = vi.spyOn(piEmbedded, "waitForEmbeddedPiRunEnd const readLatestAssistantReplyMock = vi.fn( async (_sessionKey?: string): Promise => "raw subagent reply", ); +const embeddedPiRunActiveMock = vi.fn( + (_sessionId: string) => false, +); +const embeddedPiRunStreamingMock = vi.fn( + (_sessionId: string) => false, +); +const queueEmbeddedPiMessageMock = vi.fn( + (_sessionId: string, _text: string) => false, +); +const waitForEmbeddedPiRunEndMock = vi.fn( + async (_sessionId: string, _timeoutMs?: number) => true, +); const embeddedRunMock = { - isEmbeddedPiRunActive: vi.fn(() => false), - isEmbeddedPiRunStreaming: vi.fn(() => false), - queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false), - waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true), + isEmbeddedPiRunActive: embeddedPiRunActiveMock, + isEmbeddedPiRunStreaming: embeddedPiRunStreamingMock, + queueEmbeddedPiMessage: queueEmbeddedPiMessageMock, + waitForEmbeddedPiRunEnd: waitForEmbeddedPiRunEndMock, }; const { subagentRegistryMock } = vi.hoisted(() => ({ subagentRegistryMock: { @@ -92,18 +106,21 @@ const subagentDeliveryTargetHookMock = vi.fn( undefined, ); let hasSubagentDeliveryTargetHook = false; +const hookHasHooksMock = vi.fn( + (hookName) => hookName === "subagent_delivery_target" && hasSubagentDeliveryTargetHook, +); +const hookRunSubagentDeliveryTargetMock = vi.fn( + async (event, ctx) => await subagentDeliveryTargetHookMock(event, ctx), +); const hookRunnerMock = { - hasHooks: vi.fn( - (hookName: string) => hookName === "subagent_delivery_target" && hasSubagentDeliveryTargetHook, - ), - runSubagentDeliveryTarget: vi.fn((event: unknown, ctx: unknown) => - subagentDeliveryTargetHookMock(event, ctx), - ), -}; + hasHooks: hookHasHooksMock, + runSubagentDeliveryTarget: hookRunSubagentDeliveryTargetMock, +} as unknown as HookRunner; const chatHistoryMock = vi.fn(async (_sessionKey?: string) => ({ messages: [] as Array, })); -let sessionStore: Record> = {}; +type TestSessionStore = Record>; +let sessionStore: TestSessionStore = {}; let configOverride: OpenClawConfig = { session: { mainKey: "main", @@ -131,19 +148,34 @@ function setConfigOverride(next: OpenClawConfig): void { setRuntimeConfigSnapshot(configOverride); } -function loadSessionStoreFixture(): ReturnType { - return new Proxy(sessionStore as ReturnType, { - get(target, key: string | symbol) { - if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) { - return { - sessionId: key, - updatedAt: Date.now(), +function toSessionEntry( + sessionKey: string, + entry?: Partial, +): SessionEntry | undefined { + if (!entry) { + return undefined; + } + return { + sessionId: entry.sessionId ?? sessionKey, + updatedAt: entry.updatedAt ?? Date.now(), + ...entry, + }; +} + +function loadSessionStoreFixture(): Record { + return new Proxy({} as Record, { + get(_target, key: string | symbol) { + if (typeof key !== "string") { + return undefined; + } + if (!(key in sessionStore) && key.includes(":subagent:")) { + return toSessionEntry(key, { inputTokens: 1, outputTokens: 1, totalTokens: 2, - }; + }); } - return target[key as keyof typeof target]; + return toSessionEntry(key, sessionStore[key]); }, }); } @@ -223,17 +255,20 @@ describe("subagent announce formatting", () => { .mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey)); isEmbeddedPiRunActiveSpy .mockReset() - .mockImplementation(() => embeddedRunMock.isEmbeddedPiRunActive()); + .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedPiRunActive(sessionId)); isEmbeddedPiRunStreamingSpy .mockReset() - .mockImplementation(() => embeddedRunMock.isEmbeddedPiRunStreaming()); + .mockImplementation((sessionId) => embeddedRunMock.isEmbeddedPiRunStreaming(sessionId)); queueEmbeddedPiMessageSpy .mockReset() - .mockImplementation((...args) => embeddedRunMock.queueEmbeddedPiMessage(...args)); + .mockImplementation((sessionId, text) => + embeddedRunMock.queueEmbeddedPiMessage(sessionId, text), + ); waitForEmbeddedPiRunEndSpy .mockReset() .mockImplementation( - async (...args) => await embeddedRunMock.waitForEmbeddedPiRunEnd(...args), + async (sessionId, timeoutMs) => + await embeddedRunMock.waitForEmbeddedPiRunEnd(sessionId, timeoutMs), ); embeddedRunMock.isEmbeddedPiRunActive.mockClear().mockReturnValue(false); embeddedRunMock.isEmbeddedPiRunStreaming.mockClear().mockReturnValue(false); @@ -258,8 +293,8 @@ describe("subagent announce formatting", () => { subagentRegistryMock.replaceSubagentRunAfterSteer.mockClear().mockReturnValue(true); subagentRegistryMock.resolveRequesterForChildSession.mockClear().mockReturnValue(null); hasSubagentDeliveryTargetHook = false; - hookRunnerMock.hasHooks.mockClear(); - hookRunnerMock.runSubagentDeliveryTarget.mockClear(); + hookHasHooksMock.mockClear(); + hookRunSubagentDeliveryTargetMock.mockClear(); subagentDeliveryTargetHookMock.mockReset().mockResolvedValue(undefined); readLatestAssistantReplyMock.mockClear().mockResolvedValue("raw subagent reply"); chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index 3bf58083d14..4952ec03c2b 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -53,6 +53,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "ban", "set-profile", "set-presence", + "set-profile", "download-file", ] as const; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index ddddae5ee71..a96fd8eaa85 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -350,15 +350,15 @@ export async function channelsAddCommand( await writeConfigFile(nextConfig); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); - const setup = plugin.setup; - if (setup?.afterAccountConfigWritten) { + const afterAccountConfigWritten = plugin.setup?.afterAccountConfigWritten; + if (afterAccountConfigWritten) { await runCollectedChannelOnboardingPostWriteHooks({ hooks: [ { channel, accountId, run: async ({ cfg: writtenCfg, runtime: hookRuntime }) => - await setup.afterAccountConfigWritten?.({ + await afterAccountConfigWritten({ previousCfg: cfg, cfg: writtenCfg, accountId, diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 127dee5a3f9..d35cd285fc7 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -119,7 +119,6 @@ export async function channelsRemoveCommand( runtime.exit(1); return; } - const resolvedAccountId = normalizeAccountId(accountId) ?? resolveChannelDefaultAccountId({ plugin, cfg }); const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID; @@ -164,14 +163,14 @@ export async function channelsRemoveCommand( if (useWizard && prompter) { await prompter.outro( deleteConfig - ? `Deleted ${channelLabel(channel)} account "${accountKey}".` - : `Disabled ${channelLabel(channel)} account "${accountKey}".`, + ? `Deleted ${channelLabel(resolvedChannel)} account "${accountKey}".` + : `Disabled ${channelLabel(resolvedChannel)} account "${accountKey}".`, ); } else { runtime.log( deleteConfig - ? `Deleted ${channelLabel(channel)} account "${accountKey}".` - : `Disabled ${channelLabel(channel)} account "${accountKey}".`, + ? `Deleted ${channelLabel(resolvedChannel)} account "${accountKey}".` + : `Disabled ${channelLabel(resolvedChannel)} account "${accountKey}".`, ); } } diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index e5605756e90..3c588f5a06e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -13,8 +13,8 @@ import type { OpenClawPluginCommandDefinition, OpenClawPluginConfigSchema, OpenClawPluginDefinition, - PluginInteractiveTelegramHandlerContext, PluginCommandContext, + PluginInteractiveTelegramHandlerContext, } from "../plugins/types.js"; export type { diff --git a/src/plugin-sdk/setup.ts b/src/plugin-sdk/setup.ts index 3ebce5a8f47..6865c64e841 100644 --- a/src/plugin-sdk/setup.ts +++ b/src/plugin-sdk/setup.ts @@ -6,7 +6,10 @@ export type { SecretInput } from "../config/types.secrets.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export type { ChannelSetupInput } from "../channels/plugins/types.core.js"; -export type { ChannelSetupDmPolicy } from "../channels/plugins/setup-wizard-types.js"; +export type { + ChannelSetupDmPolicy, + ChannelSetupWizardAdapter, +} from "../channels/plugins/setup-wizard-types.js"; export type { ChannelSetupWizard, ChannelSetupWizardAllowFromEntry, diff --git a/test/helpers/extensions/matrix-monitor-route.ts b/test/helpers/extensions/matrix-monitor-route.ts new file mode 100644 index 00000000000..1668a7e441a --- /dev/null +++ b/test/helpers/extensions/matrix-monitor-route.ts @@ -0,0 +1,8 @@ +export type { OpenClawConfig } from "../../../src/config/config.js"; +export { + __testing, + registerSessionBindingAdapter, +} from "../../../src/infra/outbound/session-binding-service.js"; +export { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +export { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; From 8268c28053792c8fc96aa92c78e3dc097dd79a2d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 11:02:33 -0400 Subject: [PATCH 022/137] Matrix: isolate thread binding manager stateDir reuse --- .../matrix/src/matrix/thread-bindings.test.ts | 95 ++++++++++++++++++- .../matrix/src/matrix/thread-bindings.ts | 34 ++++--- 2 files changed, 116 insertions(+), 13 deletions(-) diff --git a/extensions/matrix/src/matrix/thread-bindings.test.ts b/extensions/matrix/src/matrix/thread-bindings.test.ts index c872f720832..2b447447c81 100644 --- a/extensions/matrix/src/matrix/thread-bindings.test.ts +++ b/extensions/matrix/src/matrix/thread-bindings.test.ts @@ -59,11 +59,12 @@ describe("matrix thread bindings", () => { accessToken: "token", } as const; - function resolveBindingsFilePath() { + function resolveBindingsFilePath(customStateDir?: string) { return path.join( resolveMatrixStoragePaths({ ...auth, env: process.env, + ...(customStateDir ? { stateDir: customStateDir } : {}), }).rootDir, "thread-bindings.json", ); @@ -432,6 +433,98 @@ describe("matrix thread bindings", () => { expect(rotatedBindingsPath).toBe(initialBindingsPath); }); + it("replaces reused account managers when the bindings stateDir changes", async () => { + const initialStateDir = stateDir; + const replacementStateDir = await fs.mkdtemp( + path.join(os.tmpdir(), "matrix-thread-bindings-replacement-"), + ); + + const initialManager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + stateDir: initialStateDir, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + const replacementManager = await createMatrixThreadBindingManager({ + accountId: "ops", + auth, + client: {} as never, + stateDir: replacementStateDir, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); + + expect(replacementManager).not.toBe(initialManager); + expect(replacementManager.listBindings()).toEqual([]); + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$thread", + parentConversationId: "!room:example", + }), + ).toBeNull(); + + await getSessionBindingService().bind({ + targetSessionKey: "agent:ops:subagent:replacement", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "$thread-2", + parentConversationId: "!room:example", + }, + placement: "current", + }); + + await vi.waitFor(async () => { + const replacementRaw = await fs.readFile( + resolveBindingsFilePath(replacementStateDir), + "utf-8", + ); + expect(JSON.parse(replacementRaw)).toMatchObject({ + version: 1, + bindings: [ + expect.objectContaining({ + conversationId: "$thread-2", + parentConversationId: "!room:example", + targetSessionKey: "agent:ops:subagent:replacement", + }), + ], + }); + }); + await vi.waitFor(async () => { + const initialRaw = await fs.readFile(resolveBindingsFilePath(initialStateDir), "utf-8"); + expect(JSON.parse(initialRaw)).toMatchObject({ + version: 1, + bindings: [ + expect.objectContaining({ + conversationId: "$thread", + parentConversationId: "!room:example", + targetSessionKey: "agent:ops:subagent:child", + }), + ], + }); + }); + }); + it("updates lifecycle windows by session key and refreshes activity", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-06T10:00:00.000Z")); diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index fe3116f3691..6cf8029f9e9 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -62,7 +62,12 @@ export type MatrixThreadBindingManager = { stop: () => void; }; -const MANAGERS_BY_ACCOUNT_ID = new Map(); +type MatrixThreadBindingManagerCacheEntry = { + filePath: string; + manager: MatrixThreadBindingManager; +}; + +const MANAGERS_BY_ACCOUNT_ID = new Map(); const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); function normalizeDurationMs(raw: unknown, fallback: number): number { @@ -354,17 +359,19 @@ export async function createMatrixThreadBindingManager(params: { `Matrix thread binding account mismatch: requested ${params.accountId}, auth resolved ${params.auth.accountId}`, ); } - const existing = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); - if (existing) { - return existing; - } - const filePath = resolveBindingsPath({ auth: params.auth, accountId: params.accountId, env: params.env, stateDir: params.stateDir, }); + const existingEntry = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + if (existingEntry) { + if (existingEntry.filePath === filePath) { + return existingEntry.manager; + } + existingEntry.manager.stop(); + } const loaded = await loadBindingsFromDisk(filePath, params.accountId); for (const record of loaded) { setBindingRecord(record); @@ -499,7 +506,7 @@ export async function createMatrixThreadBindingManager(params: { channel: "matrix", accountId: params.accountId, }); - if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId) === manager) { + if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager === manager) { MANAGERS_BY_ACCOUNT_ID.delete(params.accountId); } for (const record of listBindingsForAccount(params.accountId)) { @@ -698,14 +705,17 @@ export async function createMatrixThreadBindingManager(params: { sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(params.accountId, manager); + MANAGERS_BY_ACCOUNT_ID.set(params.accountId, { + filePath, + manager, + }); return manager; } export function getMatrixThreadBindingManager( accountId: string, ): MatrixThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null; + return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; } export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { @@ -713,7 +723,7 @@ export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { targetSessionKey: string; idleTimeoutMs: number; }): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; if (!manager) { return []; } @@ -730,7 +740,7 @@ export function setMatrixThreadBindingMaxAgeBySessionKey(params: { targetSessionKey: string; maxAgeMs: number; }): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; if (!manager) { return []; } @@ -743,7 +753,7 @@ export function setMatrixThreadBindingMaxAgeBySessionKey(params: { } export function resetMatrixThreadBindingsForTests(): void { - for (const manager of MANAGERS_BY_ACCOUNT_ID.values()) { + for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { manager.stop(); } MANAGERS_BY_ACCOUNT_ID.clear(); From 12ad809e79066ad56782ea67f8261812900efe23 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 11:08:12 -0400 Subject: [PATCH 023/137] Matrix: fix runtime encryption loading --- extensions/matrix/index.test.ts | 15 ++++++++++----- extensions/matrix/src/matrix/sdk/crypto-facade.ts | 12 +++++++++++- .../matrix/src/matrix/sdk/crypto-node.runtime.ts | 3 +++ extensions/matrix/src/matrix/sdk/logger.ts | 3 ++- src/plugin-sdk/plugin-runtime.ts | 1 + 5 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index ecdd6619595..5cc8cd5a8c2 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -1,6 +1,10 @@ import path from "node:path"; import { createJiti } from "jiti"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + buildPluginLoaderJitiOptions, + resolvePluginSdkScopedAliasMap, +} from "../../src/plugins/sdk-alias.ts"; const setMatrixRuntimeMock = vi.hoisted(() => vi.fn()); const registerChannelMock = vi.hoisted(() => vi.fn()); @@ -17,12 +21,13 @@ describe("matrix plugin registration", () => { }); 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"); + const jiti = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions( + resolvePluginSdkScopedAliasMap({ modulePath: runtimeApiPath }), + ), + tryNative: false, + }); expect(jiti(runtimeApiPath)).toMatchObject({ requiresExplicitMatrixDefaultAccount: expect.any(Function), diff --git a/extensions/matrix/src/matrix/sdk/crypto-facade.ts b/extensions/matrix/src/matrix/sdk/crypto-facade.ts index f5e85cca26c..5d85539b0a3 100644 --- a/extensions/matrix/src/matrix/sdk/crypto-facade.ts +++ b/extensions/matrix/src/matrix/sdk/crypto-facade.ts @@ -1,4 +1,3 @@ -import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; import type { MatrixRecoveryKeyStore } from "./recovery-key-store.js"; import type { EncryptedFile } from "./types.js"; import type { @@ -64,6 +63,15 @@ export type MatrixCryptoFacade = { ) => Promise<{ decimal?: [number, number, number]; emoji?: Array<[string, string]> }>; }; +type MatrixCryptoNodeRuntime = typeof import("./crypto-node.runtime.js"); +let matrixCryptoNodeRuntimePromise: Promise | null = null; + +async function loadMatrixCryptoNodeRuntime(): Promise { + // Keep the native crypto package out of the main CLI startup graph. + matrixCryptoNodeRuntimePromise ??= import("./crypto-node.runtime.js"); + return await matrixCryptoNodeRuntimePromise; +} + export function createMatrixCryptoFacade(deps: { client: MatrixCryptoFacadeClient; verificationManager: MatrixVerificationManager; @@ -110,6 +118,7 @@ export function createMatrixCryptoFacade(deps: { encryptMedia: async ( buffer: Buffer, ): Promise<{ buffer: Buffer; file: Omit }> => { + const { Attachment } = await loadMatrixCryptoNodeRuntime(); const encrypted = Attachment.encrypt(new Uint8Array(buffer)); const mediaInfoJson = encrypted.mediaEncryptionInfo; if (!mediaInfoJson) { @@ -130,6 +139,7 @@ export function createMatrixCryptoFacade(deps: { file: EncryptedFile, opts?: { maxBytes?: number; readIdleTimeoutMs?: number }, ): Promise => { + const { Attachment, EncryptedAttachment } = await loadMatrixCryptoNodeRuntime(); const encrypted = await deps.downloadContent(file.url, opts); const metadata: EncryptedFile = { url: file.url, diff --git a/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts new file mode 100644 index 00000000000..8b3485cc7d0 --- /dev/null +++ b/extensions/matrix/src/matrix/sdk/crypto-node.runtime.ts @@ -0,0 +1,3 @@ +import { Attachment, EncryptedAttachment } from "@matrix-org/matrix-sdk-crypto-nodejs"; + +export { Attachment, EncryptedAttachment }; diff --git a/extensions/matrix/src/matrix/sdk/logger.ts b/extensions/matrix/src/matrix/sdk/logger.ts index 61c8c1fcfdb..758b0c1e85e 100644 --- a/extensions/matrix/src/matrix/sdk/logger.ts +++ b/extensions/matrix/src/matrix/sdk/logger.ts @@ -1,5 +1,6 @@ import { format } from "node:util"; -import { redactSensitiveText, type RuntimeLogger } from "../../runtime-api.js"; +import { redactSensitiveText } from "openclaw/plugin-sdk/diagnostics-otel"; +import type { RuntimeLogger } from "openclaw/plugin-sdk/plugin-runtime"; import { getMatrixRuntime } from "../../runtime.js"; export type Logger = { diff --git a/src/plugin-sdk/plugin-runtime.ts b/src/plugin-sdk/plugin-runtime.ts index 7286beae159..8066d30212b 100644 --- a/src/plugin-sdk/plugin-runtime.ts +++ b/src/plugin-sdk/plugin-runtime.ts @@ -6,3 +6,4 @@ export * from "../plugins/http-path.js"; export * from "../plugins/http-registry.js"; export * from "../plugins/interactive.js"; export * from "../plugins/types.js"; +export type { RuntimeLogger } from "../plugins/runtime/types.js"; From bfe979dd5b49570074cd473ff7cb887b1a507d0e Mon Sep 17 00:00:00 2001 From: xubaolin Date: Thu, 19 Mar 2026 23:27:43 +0800 Subject: [PATCH 024/137] refactor: add Android LocationHandler test seam (#50027) (thanks @xu-baolin) --- .../ai/openclaw/app/node/LocationHandler.kt | 92 +++++++++++++++---- .../openclaw/app/node/LocationHandlerTest.kt | 88 ++++++++++++++++++ 2 files changed, 163 insertions(+), 17 deletions(-) create mode 100644 apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt diff --git a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt index 014eead6669..e9f520e9a35 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/node/LocationHandler.kt @@ -8,27 +8,85 @@ import androidx.core.content.ContextCompat import ai.openclaw.app.gateway.GatewaySession import kotlinx.coroutines.TimeoutCancellationException import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive -class LocationHandler( +internal interface LocationDataSource { + fun hasFinePermission(context: Context): Boolean + + fun hasCoarsePermission(context: Context): Boolean + + suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload +} + +private class DefaultLocationDataSource( + private val capture: LocationCaptureManager, +) : LocationDataSource { + override fun hasFinePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + override fun hasCoarsePermission(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload = + capture.getLocation( + desiredProviders = desiredProviders, + maxAgeMs = maxAgeMs, + timeoutMs = timeoutMs, + isPrecise = isPrecise, + ) +} + +class LocationHandler private constructor( private val appContext: Context, - private val location: LocationCaptureManager, + private val dataSource: LocationDataSource, private val json: Json, private val isForeground: () -> Boolean, private val locationPreciseEnabled: () -> Boolean, ) { - fun hasFineLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == - PackageManager.PERMISSION_GRANTED - ) - } + constructor( + appContext: Context, + location: LocationCaptureManager, + json: Json, + isForeground: () -> Boolean, + locationPreciseEnabled: () -> Boolean, + ) : this( + appContext = appContext, + dataSource = DefaultLocationDataSource(location), + json = json, + isForeground = isForeground, + locationPreciseEnabled = locationPreciseEnabled, + ) - fun hasCoarseLocationPermission(): Boolean { - return ( - ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_COARSE_LOCATION) == - PackageManager.PERMISSION_GRANTED + fun hasFineLocationPermission(): Boolean = dataSource.hasFinePermission(appContext) + + fun hasCoarseLocationPermission(): Boolean = dataSource.hasCoarsePermission(appContext) + + companion object { + internal fun forTesting( + appContext: Context, + dataSource: LocationDataSource, + json: Json = Json { ignoreUnknownKeys = true }, + isForeground: () -> Boolean = { true }, + locationPreciseEnabled: () -> Boolean = { true }, + ): LocationHandler = + LocationHandler( + appContext = appContext, + dataSource = dataSource, + json = json, + isForeground = isForeground, + locationPreciseEnabled = locationPreciseEnabled, ) } @@ -39,7 +97,7 @@ class LocationHandler( message = "LOCATION_BACKGROUND_UNAVAILABLE: location requires OpenClaw to stay open", ) } - if (!hasFineLocationPermission() && !hasCoarseLocationPermission()) { + if (!dataSource.hasFinePermission(appContext) && !dataSource.hasCoarsePermission(appContext)) { return GatewaySession.InvokeResult.error( code = "LOCATION_PERMISSION_REQUIRED", message = "LOCATION_PERMISSION_REQUIRED: grant Location permission", @@ -49,9 +107,9 @@ class LocationHandler( val preciseEnabled = locationPreciseEnabled() val accuracy = when (desiredAccuracy) { - "precise" -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + "precise" -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced" "coarse" -> "coarse" - else -> if (preciseEnabled && hasFineLocationPermission()) "precise" else "balanced" + else -> if (preciseEnabled && dataSource.hasFinePermission(appContext)) "precise" else "balanced" } val providers = when (accuracy) { @@ -61,7 +119,7 @@ class LocationHandler( } try { val payload = - location.getLocation( + dataSource.fetchLocation( desiredProviders = providers, maxAgeMs = maxAgeMs, timeoutMs = timeoutMs, diff --git a/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt new file mode 100644 index 00000000000..9605077fa8b --- /dev/null +++ b/apps/android/app/src/test/java/ai/openclaw/app/node/LocationHandlerTest.kt @@ -0,0 +1,88 @@ +package ai.openclaw.app.node + +import android.content.Context +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class LocationHandlerTest : NodeHandlerRobolectricTest() { + @Test + fun handleLocationGet_requiresLocationPermissionWhenNeitherFineNorCoarse() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = false, + coarseGranted = false, + ), + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_PERMISSION_REQUIRED", result.error?.code) + } + + @Test + fun handleLocationGet_requiresForegroundBeforeLocationPermission() = + runTest { + val handler = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = + FakeLocationDataSource( + fineGranted = true, + coarseGranted = true, + ), + isForeground = { false }, + ) + + val result = handler.handleLocationGet(null) + + assertFalse(result.ok) + assertEquals("LOCATION_BACKGROUND_UNAVAILABLE", result.error?.code) + } + + @Test + fun hasFineLocationPermission_reflectsDataSource() { + val denied = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = false, coarseGranted = true), + ) + assertFalse(denied.hasFineLocationPermission()) + assertTrue(denied.hasCoarseLocationPermission()) + + val granted = + LocationHandler.forTesting( + appContext = appContext(), + dataSource = FakeLocationDataSource(fineGranted = true, coarseGranted = false), + ) + assertTrue(granted.hasFineLocationPermission()) + assertFalse(granted.hasCoarseLocationPermission()) + } +} + +private class FakeLocationDataSource( + private val fineGranted: Boolean, + private val coarseGranted: Boolean, +) : LocationDataSource { + override fun hasFinePermission(context: Context): Boolean = fineGranted + + override fun hasCoarsePermission(context: Context): Boolean = coarseGranted + + override suspend fun fetchLocation( + desiredProviders: List, + maxAgeMs: Long?, + timeoutMs: Long, + isPrecise: Boolean, + ): LocationCaptureManager.Payload { + throw IllegalStateException( + "LocationHandlerTest: fetchLocation must not run in this scenario", + ) + } +} From fb1803401104c1631fd2b2012106c2e7dbc94601 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 07:47:07 -0500 Subject: [PATCH 025/137] test: add macmini test profile --- package.json | 2 +- scripts/test-parallel.mjs | 189 +++++++++++++++++++++++++++++--------- 2 files changed, 146 insertions(+), 45 deletions(-) diff --git a/package.json b/package.json index e70c7dc3061..72ab6fb3b9a 100644 --- a/package.json +++ b/package.json @@ -655,7 +655,7 @@ "test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh", "test:install:smoke": "bash scripts/test-install-sh-docker.sh", "test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts", - "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs", + "test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=macmini node scripts/test-parallel.mjs", "test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh", "test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh", "test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh", diff --git a/scripts/test-parallel.mjs b/scripts/test-parallel.mjs index 8c63e61aeb4..1a128cf70dd 100644 --- a/scripts/test-parallel.mjs +++ b/scripts/test-parallel.mjs @@ -55,11 +55,13 @@ const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS === const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase(); const testProfile = rawTestProfile === "low" || + rawTestProfile === "macmini" || rawTestProfile === "max" || rawTestProfile === "normal" || rawTestProfile === "serial" ? rawTestProfile : "normal"; +const isMacMiniProfile = testProfile === "macmini"; // Even on low-memory hosts, keep the isolated lane split so files like // git-commit.test.ts still get the worker/process isolation they require. const shouldSplitUnitRuns = testProfile !== "serial"; @@ -162,6 +164,17 @@ const parsePassthroughArgs = (args) => { }; const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } = parsePassthroughArgs(passthroughArgs); +const passthroughMetadataFlags = new Set(["-h", "--help", "--listTags", "--clearCache"]); +const passthroughMetadataOnly = + passthroughArgs.length > 0 && + passthroughFileFilters.length === 0 && + passthroughOptionArgs.every((arg) => { + if (!arg.startsWith("-")) { + return false; + } + const [flag] = arg.split("=", 1); + return passthroughMetadataFlags.has(flag); + }); const countExplicitEntryFilters = (entryArgs) => { const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2)); return fileFilters.length > 0 ? fileFilters.length : null; @@ -242,9 +255,25 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => { return isUnitConfigTestFile(file); }); const defaultHeavyUnitFileLimit = - testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60; + testProfile === "serial" + ? 0 + : isMacMiniProfile + ? 90 + : testProfile === "low" + ? 20 + : highMemLocalHost + ? 80 + : 60; const defaultHeavyUnitLaneCount = - testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4; + testProfile === "serial" + ? 0 + : isMacMiniProfile + ? 6 + : testProfile === "low" + ? 2 + : highMemLocalHost + ? 5 + : 4; const heavyUnitFileLimit = parseEnvNumber( "OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT", defaultHeavyUnitFileLimit, @@ -538,12 +567,16 @@ const targetedEntries = (() => { // Node 25 local runs still show cross-process worker shutdown contention even // after moving the known heavy files into singleton lanes. const topLevelParallelEnabled = - testProfile !== "low" && testProfile !== "serial" && !(!isCI && nodeMajor >= 25); + testProfile !== "low" && + testProfile !== "serial" && + !(!isCI && nodeMajor >= 25) && + !isMacMiniProfile; const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10); const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null; const parallelGatewayEnabled = - process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost); + !isMacMiniProfile && + (process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost)); // Keep gateway serial by default except when explicitly requested or on high-memory local hosts. const keepGatewaySerial = isWindowsCi || @@ -570,45 +603,52 @@ const defaultWorkerBudget = extensions: 4, gateway: 1, } - : testProfile === "serial" + : isMacMiniProfile ? { - unit: 1, + unit: 3, unitIsolated: 1, extensions: 1, gateway: 1, } - : testProfile === "max" + : testProfile === "serial" ? { - unit: localWorkers, - unitIsolated: Math.min(4, localWorkers), - extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), - gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), + unit: 1, + unitIsolated: 1, + extensions: 1, + gateway: 1, } - : highMemLocalHost + : testProfile === "max" ? { - // After peeling measured hotspots into dedicated lanes, the shared - // unit-fast lane shuts down more reliably with a slightly smaller - // worker fan-out than the old "max it out" local default. - unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), - unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), - extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + unit: localWorkers, + unitIsolated: Math.min(4, localWorkers), + extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))), + gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))), } - : lowMemLocalHost + : highMemLocalHost ? { - // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. - unit: 2, - unitIsolated: 1, - extensions: 4, - gateway: 1, - } - : { - // 64-95 GiB local hosts: conservative split with some parallel headroom. - unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), - unitIsolated: 1, + // After peeling measured hotspots into dedicated lanes, the shared + // unit-fast lane shuts down more reliably with a slightly smaller + // worker fan-out than the old "max it out" local default. + unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))), + unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)), extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), - gateway: 1, - }; + gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))), + } + : lowMemLocalHost + ? { + // Sub-64 GiB local hosts are prone to OOM with large vmFork runs. + unit: 2, + unitIsolated: 1, + extensions: 4, + gateway: 1, + } + : { + // 64-95 GiB local hosts: conservative split with some parallel headroom. + unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))), + unitIsolated: 1, + extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))), + gateway: 1, + }; // Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM. // In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts. @@ -766,21 +806,52 @@ const run = async (entry, extraArgs = []) => { return 0; }; +const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => { + if (entries.length === 0) { + return undefined; + } + + const normalizedConcurrency = Math.max(1, Math.floor(concurrency)); + if (normalizedConcurrency <= 1) { + for (const entry of entries) { + // eslint-disable-next-line no-await-in-loop + const code = await run(entry, extraArgs); + if (code !== 0) { + return code; + } + } + + return undefined; + } + + let nextIndex = 0; + let firstFailure; + const worker = async () => { + while (firstFailure === undefined) { + const entryIndex = nextIndex; + nextIndex += 1; + if (entryIndex >= entries.length) { + return; + } + const code = await run(entries[entryIndex], extraArgs); + if (code !== 0 && firstFailure === undefined) { + firstFailure = code; + } + } + }; + + const workerCount = Math.min(normalizedConcurrency, entries.length); + await Promise.all(Array.from({ length: workerCount }, () => worker())); + return firstFailure; +}; + const runEntries = async (entries, extraArgs = []) => { if (topLevelParallelEnabled) { const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs))); return codes.find((code) => code !== 0); } - for (const entry of entries) { - // eslint-disable-next-line no-await-in-loop - const code = await run(entry, extraArgs); - if (code !== 0) { - return code; - } - } - - return undefined; + return runEntriesWithLimit(entries, extraArgs); }; const shutdown = (signal) => { @@ -800,6 +871,17 @@ if (process.env.OPENCLAW_TEST_LIST_LANES === "1") { process.exit(0); } +if (passthroughMetadataOnly) { + const exitCode = await runOnce( + { + name: "vitest-meta", + args: ["vitest", "run"], + }, + passthroughOptionArgs, + ); + process.exit(exitCode); +} + if (targetedEntries.length > 0) { if (passthroughRequiresSingleRun && targetedEntries.length > 1) { console.error( @@ -834,9 +916,28 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) { process.exit(2); } -const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs); -if (failedParallel !== undefined) { - process.exit(failedParallel); +if (isMacMiniProfile && targetedEntries.length === 0) { + const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast"); + if (unitFastEntry) { + const unitFastCode = await run(unitFastEntry, passthroughOptionArgs); + if (unitFastCode !== 0) { + process.exit(unitFastCode); + } + } + const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast"); + const failedMacMiniParallel = await runEntriesWithLimit( + deferredEntries, + passthroughOptionArgs, + 3, + ); + if (failedMacMiniParallel !== undefined) { + process.exit(failedMacMiniParallel); + } +} else { + const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs); + if (failedParallel !== undefined) { + process.exit(failedParallel); + } } for (const entry of serialRuns) { From e1b5ffadca14766254c8e32a7140a19c8441d1e2 Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:28:56 -0500 Subject: [PATCH 026/137] docs: clarify scoped-test validation policy --- AGENTS.md | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 488bc0678fd..8b659b985b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,10 +70,33 @@ - Format check: `pnpm format` (oxfmt --check) - Format fix: `pnpm format:fix` (oxfmt --write) - Tests: `pnpm test` (vitest); coverage: `pnpm test:coverage` -- Hard gate: before any commit, `pnpm check` MUST be run and MUST pass for the change being committed. -- Hard gate: before any push to `main`, `pnpm check` MUST be run and MUST pass, and `pnpm test` MUST be run and MUST pass. +- Default landing bar: before any commit, run `pnpm check` and prefer a passing result for the change being committed. +- For narrowly scoped changes, run narrowly scoped tests that directly validate the touched behavior; this is required proof for the change before commit and push decisions. If no meaningful scoped test exists, say so explicitly and use the next most direct validation available. +- Default landing bar: before any push to `main`, run `pnpm check` and `pnpm test` and prefer a green result. +- Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default. - Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`. -- Hard gate: do not commit or push with failing format, lint, type, build, or required test checks. +- Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. + +## Judgment / Exception Handling + +- Use judgment for narrowly scoped changes when unrelated failures already exist on latest `origin/main`. +- Before using that judgment, explicitly separate: + - failures caused by the change + - failures reproducible on current `origin/main` + - failures that are clearly unrelated to the touched surface +- Scoped exceptions are allowed only when all of the following are true: + - the diff is narrowly scoped and low blast radius + - the failing checks touch unrelated surfaces + - the failures are reproducible on current `origin/main` or are otherwise clearly pre-existing + - you explicitly explain that conclusion to Tak +- Even when using a scoped exception, narrowly scoped tests are still required as direct proof of the change unless no meaningful scoped test exists. +- Do not claim full gate compliance when using a scoped exception. State which checks are failing and why they appear unrelated. +- When using judgment because full-suite failures are unrelated or already failing on latest `origin/main`, report both: + - which scoped tests you ran as direct proof of the change + - which full-suite failures you are setting aside and why they appear unrelated +- If the branch contains only the intended scoped change and the remaining failures are demonstrably unrelated or already failing on latest `origin/main`, report that clearly and ask for a push/waiver decision instead of silently broadening scope into unrelated fixes. +- If Tak explicitly authorizes landing despite unrelated failing gates, treat that as an informed override. Do not keep repairing unrelated areas unless Tak explicitly asks for that broader work. +- Do not use judgment as a blanket bypass. If the change could plausibly affect the failing area, treat the failure as in-scope until proven otherwise. Do not use “scoped tests passed” as permission to ignore plausibly related failures. ## Coding Style & Naming Conventions From 5a41229a6d51e745023e99288596a4e546d6f5cf Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Thu, 19 Mar 2026 10:34:04 -0500 Subject: [PATCH 027/137] docs: simplify AGENTS validation policy --- AGENTS.md | 23 ++--------------------- 1 file changed, 2 insertions(+), 21 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8b659b985b0..538670892f4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,27 +76,8 @@ - Scoped tests prove the change itself. `pnpm test` remains the default `main` landing bar; scoped tests do not replace full-suite gates by default. - Hard gate: if the change can affect build output, packaging, lazy-loading/module boundaries, or published surfaces, `pnpm build` MUST be run and MUST pass before pushing `main`. - Default rule: do not commit or push with failing format, lint, type, build, or required test checks when those failures are caused by the change or plausibly related to the touched surface. - -## Judgment / Exception Handling - -- Use judgment for narrowly scoped changes when unrelated failures already exist on latest `origin/main`. -- Before using that judgment, explicitly separate: - - failures caused by the change - - failures reproducible on current `origin/main` - - failures that are clearly unrelated to the touched surface -- Scoped exceptions are allowed only when all of the following are true: - - the diff is narrowly scoped and low blast radius - - the failing checks touch unrelated surfaces - - the failures are reproducible on current `origin/main` or are otherwise clearly pre-existing - - you explicitly explain that conclusion to Tak -- Even when using a scoped exception, narrowly scoped tests are still required as direct proof of the change unless no meaningful scoped test exists. -- Do not claim full gate compliance when using a scoped exception. State which checks are failing and why they appear unrelated. -- When using judgment because full-suite failures are unrelated or already failing on latest `origin/main`, report both: - - which scoped tests you ran as direct proof of the change - - which full-suite failures you are setting aside and why they appear unrelated -- If the branch contains only the intended scoped change and the remaining failures are demonstrably unrelated or already failing on latest `origin/main`, report that clearly and ask for a push/waiver decision instead of silently broadening scope into unrelated fixes. -- If Tak explicitly authorizes landing despite unrelated failing gates, treat that as an informed override. Do not keep repairing unrelated areas unless Tak explicitly asks for that broader work. -- Do not use judgment as a blanket bypass. If the change could plausibly affect the failing area, treat the failure as in-scope until proven otherwise. Do not use “scoped tests passed” as permission to ignore plausibly related failures. +- For narrowly scoped changes, if unrelated failures already exist on latest `origin/main`, state that clearly, report the scoped tests you ran, and ask before broadening scope into unrelated fixes or landing despite those failures. +- Do not use scoped tests as permission to ignore plausibly related failures. ## Coding Style & Naming Conventions From ff6541f69d2e6cd88424953b13a43a20fa7aefb9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Thu, 19 Mar 2026 11:39:59 -0400 Subject: [PATCH 028/137] Matrix: fix Jiti runtime API boundary --- extensions/matrix/runtime-api.ts | 16 +- extensions/matrix/src/channel.ts | 16 +- .../src/matrix/thread-bindings-shared.ts | 225 +++++++++++++++++ .../matrix/src/matrix/thread-bindings.ts | 238 +++--------------- extensions/matrix/src/runtime-api.ts | 1 + extensions/matrix/thread-bindings-runtime.ts | 4 + src/plugin-sdk/matrix.ts | 2 +- src/plugins/runtime/types-channel.ts | 4 +- 8 files changed, 273 insertions(+), 233 deletions(-) create mode 100644 extensions/matrix/src/matrix/thread-bindings-shared.ts create mode 100644 extensions/matrix/thread-bindings-runtime.ts diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 52df80f9843..bc8163c9969 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1,14 +1,4 @@ -export * from "openclaw/plugin-sdk/matrix"; +// Keep the external runtime API light so Jiti callers can resolve Matrix config +// helpers without traversing the full plugin-sdk/runtime graph. export * from "./src/auth-precedence.js"; -export { - findMatrixAccountEntry, - hashMatrixAccessToken, - listMatrixEnvAccountIds, - resolveConfiguredMatrixAccountIds, - resolveMatrixChannelConfig, - resolveMatrixCredentialsFilename, - resolveMatrixEnvAccountToken, - resolveMatrixHomeserverKey, - resolveMatrixLegacyFlatStoreRoot, - sanitizeMatrixPathSegment, -} from "./helper-api.js"; +export * from "./helper-api.js"; diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index cfc4ccdddf1..34b6b9610e3 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -17,14 +17,6 @@ import { } from "openclaw/plugin-sdk/channel-runtime"; import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { - buildChannelConfigSchema, - buildProbeChannelStatusSummary, - collectStatusIssuesFromLastError, - DEFAULT_ACCOUNT_ID, - PAIRING_APPROVED_MESSAGE, - type ChannelPlugin, -} from "../runtime-api.js"; import { matrixMessageActions } from "./actions.js"; import { MatrixConfigSchema } from "./config-schema.js"; import { @@ -44,6 +36,14 @@ import { resolveMatrixDirectUserId, resolveMatrixTargetIdentity, } from "./matrix/target-ids.js"; +import { + buildChannelConfigSchema, + buildProbeChannelStatusSummary, + collectStatusIssuesFromLastError, + DEFAULT_ACCOUNT_ID, + PAIRING_APPROVED_MESSAGE, + type ChannelPlugin, +} from "./runtime-api.js"; import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; diff --git a/extensions/matrix/src/matrix/thread-bindings-shared.ts b/extensions/matrix/src/matrix/thread-bindings-shared.ts new file mode 100644 index 00000000000..f8c9c2b9e3f --- /dev/null +++ b/extensions/matrix/src/matrix/thread-bindings-shared.ts @@ -0,0 +1,225 @@ +import type { + BindingTargetKind, + SessionBindingRecord, +} from "openclaw/plugin-sdk/conversation-runtime"; + +export type MatrixThreadBindingTargetKind = "subagent" | "acp"; + +export type MatrixThreadBindingRecord = { + accountId: string; + conversationId: string; + parentConversationId?: string; + targetKind: MatrixThreadBindingTargetKind; + targetSessionKey: string; + agentId?: string; + label?: string; + boundBy?: string; + boundAt: number; + lastActivityAt: number; + idleTimeoutMs?: number; + maxAgeMs?: number; +}; + +export type MatrixThreadBindingManager = { + accountId: string; + getIdleTimeoutMs: () => number; + getMaxAgeMs: () => number; + getByConversation: (params: { + conversationId: string; + parentConversationId?: string; + }) => MatrixThreadBindingRecord | undefined; + listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; + listBindings: () => MatrixThreadBindingRecord[]; + touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; + setIdleTimeoutBySessionKey: (params: { + targetSessionKey: string; + idleTimeoutMs: number; + }) => MatrixThreadBindingRecord[]; + setMaxAgeBySessionKey: (params: { + targetSessionKey: string; + maxAgeMs: number; + }) => MatrixThreadBindingRecord[]; + stop: () => void; +}; + +export type MatrixThreadBindingManagerCacheEntry = { + filePath: string; + manager: MatrixThreadBindingManager; +}; + +const MANAGERS_BY_ACCOUNT_ID = new Map(); +const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); + +export function resolveBindingKey(params: { + accountId: string; + conversationId: string; + parentConversationId?: string; +}): string { + return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; +} + +function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { + return raw === "subagent" ? "subagent" : "session"; +} + +export function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { + return raw === "subagent" ? "subagent" : "acp"; +} + +export function resolveEffectiveBindingExpiry(params: { + record: MatrixThreadBindingRecord; + defaultIdleTimeoutMs: number; + defaultMaxAgeMs: number; +}): { + expiresAt?: number; + reason?: "idle-expired" | "max-age-expired"; +} { + const idleTimeoutMs = + typeof params.record.idleTimeoutMs === "number" + ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) + : params.defaultIdleTimeoutMs; + const maxAgeMs = + typeof params.record.maxAgeMs === "number" + ? Math.max(0, Math.floor(params.record.maxAgeMs)) + : params.defaultMaxAgeMs; + const inactivityExpiresAt = + idleTimeoutMs > 0 + ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs + : undefined; + const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; + + if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { + return inactivityExpiresAt <= maxAgeExpiresAt + ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } + : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + if (inactivityExpiresAt != null) { + return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; + } + if (maxAgeExpiresAt != null) { + return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; + } + return {}; +} + +export function toSessionBindingRecord( + record: MatrixThreadBindingRecord, + defaults: { idleTimeoutMs: number; maxAgeMs: number }, +): SessionBindingRecord { + const lifecycle = resolveEffectiveBindingExpiry({ + record, + defaultIdleTimeoutMs: defaults.idleTimeoutMs, + defaultMaxAgeMs: defaults.maxAgeMs, + }); + const idleTimeoutMs = + typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; + const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; + return { + bindingId: resolveBindingKey(record), + targetSessionKey: record.targetSessionKey, + targetKind: toSessionBindingTargetKind(record.targetKind), + conversation: { + channel: "matrix", + accountId: record.accountId, + conversationId: record.conversationId, + parentConversationId: record.parentConversationId, + }, + status: "active", + boundAt: record.boundAt, + expiresAt: lifecycle.expiresAt, + metadata: { + agentId: record.agentId, + label: record.label, + boundBy: record.boundBy, + lastActivityAt: record.lastActivityAt, + idleTimeoutMs, + maxAgeMs, + }, + }; +} + +export function setBindingRecord(record: MatrixThreadBindingRecord): void { + BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); +} + +export function removeBindingRecord( + record: MatrixThreadBindingRecord, +): MatrixThreadBindingRecord | null { + const key = resolveBindingKey(record); + const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; + if (removed) { + BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); + } + return removed; +} + +export function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { + return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( + (entry) => entry.accountId === accountId, + ); +} + +export function getMatrixThreadBindingManagerEntry( + accountId: string, +): MatrixThreadBindingManagerCacheEntry | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId) ?? null; +} + +export function setMatrixThreadBindingManagerEntry( + accountId: string, + entry: MatrixThreadBindingManagerCacheEntry, +): void { + MANAGERS_BY_ACCOUNT_ID.set(accountId, entry); +} + +export function deleteMatrixThreadBindingManagerEntry(accountId: string): void { + MANAGERS_BY_ACCOUNT_ID.delete(accountId); +} + +export function getMatrixThreadBindingManager( + accountId: string, +): MatrixThreadBindingManager | null { + return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; +} + +export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { + accountId: string; + targetSessionKey: string; + idleTimeoutMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setIdleTimeoutBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function setMatrixThreadBindingMaxAgeBySessionKey(params: { + accountId: string; + targetSessionKey: string; + maxAgeMs: number; +}): SessionBindingRecord[] { + const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; + if (!manager) { + return []; + } + return manager.setMaxAgeBySessionKey(params).map((record) => + toSessionBindingRecord(record, { + idleTimeoutMs: manager.getIdleTimeoutMs(), + maxAgeMs: manager.getMaxAgeMs(), + }), + ); +} + +export function resetMatrixThreadBindingsForTests(): void { + for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { + manager.stop(); + } + MANAGERS_BY_ACCOUNT_ID.clear(); + BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); +} diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index 6cf8029f9e9..edbbde5d000 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -6,70 +6,39 @@ import { resolveThreadBindingFarewellText, unregisterSessionBindingAdapter, writeJsonFileAtomically, - type BindingTargetKind, - type SessionBindingRecord, } from "../runtime-api.js"; import { resolveMatrixStoragePaths } from "./client/storage.js"; import type { MatrixAuth } from "./client/types.js"; import type { MatrixClient } from "./sdk.js"; import { sendMessageMatrix } from "./send.js"; +import { + deleteMatrixThreadBindingManagerEntry, + getMatrixThreadBindingManager, + getMatrixThreadBindingManagerEntry, + listBindingsForAccount, + removeBindingRecord, + resetMatrixThreadBindingsForTests, + resolveBindingKey, + resolveEffectiveBindingExpiry, + setBindingRecord, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingManagerEntry, + setMatrixThreadBindingMaxAgeBySessionKey, + toMatrixBindingTargetKind, + toSessionBindingRecord, + type MatrixThreadBindingManager, + type MatrixThreadBindingRecord, +} from "./thread-bindings-shared.js"; const STORE_VERSION = 1; const THREAD_BINDINGS_SWEEP_INTERVAL_MS = 60_000; const TOUCH_PERSIST_DELAY_MS = 30_000; -type MatrixThreadBindingTargetKind = "subagent" | "acp"; - -type MatrixThreadBindingRecord = { - accountId: string; - conversationId: string; - parentConversationId?: string; - targetKind: MatrixThreadBindingTargetKind; - targetSessionKey: string; - agentId?: string; - label?: string; - boundBy?: string; - boundAt: number; - lastActivityAt: number; - idleTimeoutMs?: number; - maxAgeMs?: number; -}; - type StoredMatrixThreadBindingState = { version: number; bindings: MatrixThreadBindingRecord[]; }; -export type MatrixThreadBindingManager = { - accountId: string; - getIdleTimeoutMs: () => number; - getMaxAgeMs: () => number; - getByConversation: (params: { - conversationId: string; - parentConversationId?: string; - }) => MatrixThreadBindingRecord | undefined; - listBySessionKey: (targetSessionKey: string) => MatrixThreadBindingRecord[]; - listBindings: () => MatrixThreadBindingRecord[]; - touchBinding: (bindingId: string, at?: number) => MatrixThreadBindingRecord | null; - setIdleTimeoutBySessionKey: (params: { - targetSessionKey: string; - idleTimeoutMs: number; - }) => MatrixThreadBindingRecord[]; - setMaxAgeBySessionKey: (params: { - targetSessionKey: string; - maxAgeMs: number; - }) => MatrixThreadBindingRecord[]; - stop: () => void; -}; - -type MatrixThreadBindingManagerCacheEntry = { - filePath: string; - manager: MatrixThreadBindingManager; -}; - -const MANAGERS_BY_ACCOUNT_ID = new Map(); -const BINDINGS_BY_ACCOUNT_CONVERSATION = new Map(); - function normalizeDurationMs(raw: unknown, fallback: number): number { if (typeof raw !== "number" || !Number.isFinite(raw)) { return fallback; @@ -86,94 +55,6 @@ function normalizeConversationId(raw: unknown): string | undefined { return trimmed || undefined; } -function resolveBindingKey(params: { - accountId: string; - conversationId: string; - parentConversationId?: string; -}): string { - return `${params.accountId}:${params.parentConversationId?.trim() || "-"}:${params.conversationId}`; -} - -function toSessionBindingTargetKind(raw: MatrixThreadBindingTargetKind): BindingTargetKind { - return raw === "subagent" ? "subagent" : "session"; -} - -function toMatrixBindingTargetKind(raw: BindingTargetKind): MatrixThreadBindingTargetKind { - return raw === "subagent" ? "subagent" : "acp"; -} - -function resolveEffectiveBindingExpiry(params: { - record: MatrixThreadBindingRecord; - defaultIdleTimeoutMs: number; - defaultMaxAgeMs: number; -}): { - expiresAt?: number; - reason?: "idle-expired" | "max-age-expired"; -} { - const idleTimeoutMs = - typeof params.record.idleTimeoutMs === "number" - ? Math.max(0, Math.floor(params.record.idleTimeoutMs)) - : params.defaultIdleTimeoutMs; - const maxAgeMs = - typeof params.record.maxAgeMs === "number" - ? Math.max(0, Math.floor(params.record.maxAgeMs)) - : params.defaultMaxAgeMs; - const inactivityExpiresAt = - idleTimeoutMs > 0 - ? Math.max(params.record.lastActivityAt, params.record.boundAt) + idleTimeoutMs - : undefined; - const maxAgeExpiresAt = maxAgeMs > 0 ? params.record.boundAt + maxAgeMs : undefined; - - if (inactivityExpiresAt != null && maxAgeExpiresAt != null) { - return inactivityExpiresAt <= maxAgeExpiresAt - ? { expiresAt: inactivityExpiresAt, reason: "idle-expired" } - : { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - if (inactivityExpiresAt != null) { - return { expiresAt: inactivityExpiresAt, reason: "idle-expired" }; - } - if (maxAgeExpiresAt != null) { - return { expiresAt: maxAgeExpiresAt, reason: "max-age-expired" }; - } - return {}; -} - -function toSessionBindingRecord( - record: MatrixThreadBindingRecord, - defaults: { idleTimeoutMs: number; maxAgeMs: number }, -): SessionBindingRecord { - const lifecycle = resolveEffectiveBindingExpiry({ - record, - defaultIdleTimeoutMs: defaults.idleTimeoutMs, - defaultMaxAgeMs: defaults.maxAgeMs, - }); - const idleTimeoutMs = - typeof record.idleTimeoutMs === "number" ? record.idleTimeoutMs : defaults.idleTimeoutMs; - const maxAgeMs = typeof record.maxAgeMs === "number" ? record.maxAgeMs : defaults.maxAgeMs; - return { - bindingId: resolveBindingKey(record), - targetSessionKey: record.targetSessionKey, - targetKind: toSessionBindingTargetKind(record.targetKind), - conversation: { - channel: "matrix", - accountId: record.accountId, - conversationId: record.conversationId, - parentConversationId: record.parentConversationId, - }, - status: "active", - boundAt: record.boundAt, - expiresAt: lifecycle.expiresAt, - metadata: { - agentId: record.agentId, - label: record.label, - boundBy: record.boundBy, - lastActivityAt: record.lastActivityAt, - idleTimeoutMs, - maxAgeMs, - }, - }; -} - function resolveBindingsPath(params: { auth: MatrixAuth; accountId: string; @@ -256,25 +137,6 @@ async function persistBindingsSnapshot( await writeJsonFileAtomically(filePath, toStoredBindingsState(bindings)); } -function setBindingRecord(record: MatrixThreadBindingRecord): void { - BINDINGS_BY_ACCOUNT_CONVERSATION.set(resolveBindingKey(record), record); -} - -function removeBindingRecord(record: MatrixThreadBindingRecord): MatrixThreadBindingRecord | null { - const key = resolveBindingKey(record); - const removed = BINDINGS_BY_ACCOUNT_CONVERSATION.get(key) ?? null; - if (removed) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(key); - } - return removed; -} - -function listBindingsForAccount(accountId: string): MatrixThreadBindingRecord[] { - return [...BINDINGS_BY_ACCOUNT_CONVERSATION.values()].filter( - (entry) => entry.accountId === accountId, - ); -} - function buildMatrixBindingIntroText(params: { metadata?: Record; targetSessionKey: string; @@ -365,7 +227,7 @@ export async function createMatrixThreadBindingManager(params: { env: params.env, stateDir: params.stateDir, }); - const existingEntry = MANAGERS_BY_ACCOUNT_ID.get(params.accountId); + const existingEntry = getMatrixThreadBindingManagerEntry(params.accountId); if (existingEntry) { if (existingEntry.filePath === filePath) { return existingEntry.manager; @@ -506,11 +368,11 @@ export async function createMatrixThreadBindingManager(params: { channel: "matrix", accountId: params.accountId, }); - if (MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager === manager) { - MANAGERS_BY_ACCOUNT_ID.delete(params.accountId); + if (getMatrixThreadBindingManagerEntry(params.accountId)?.manager === manager) { + deleteMatrixThreadBindingManagerEntry(params.accountId); } for (const record of listBindingsForAccount(params.accountId)) { - BINDINGS_BY_ACCOUNT_CONVERSATION.delete(resolveBindingKey(record)); + removeBindingRecord(record); } }, }; @@ -705,57 +567,15 @@ export async function createMatrixThreadBindingManager(params: { sweepTimer.unref?.(); } - MANAGERS_BY_ACCOUNT_ID.set(params.accountId, { + setMatrixThreadBindingManagerEntry(params.accountId, { filePath, manager, }); return manager; } - -export function getMatrixThreadBindingManager( - accountId: string, -): MatrixThreadBindingManager | null { - return MANAGERS_BY_ACCOUNT_ID.get(accountId)?.manager ?? null; -} - -export function setMatrixThreadBindingIdleTimeoutBySessionKey(params: { - accountId: string; - targetSessionKey: string; - idleTimeoutMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setIdleTimeoutBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function setMatrixThreadBindingMaxAgeBySessionKey(params: { - accountId: string; - targetSessionKey: string; - maxAgeMs: number; -}): SessionBindingRecord[] { - const manager = MANAGERS_BY_ACCOUNT_ID.get(params.accountId)?.manager; - if (!manager) { - return []; - } - return manager.setMaxAgeBySessionKey(params).map((record) => - toSessionBindingRecord(record, { - idleTimeoutMs: manager.getIdleTimeoutMs(), - maxAgeMs: manager.getMaxAgeMs(), - }), - ); -} - -export function resetMatrixThreadBindingsForTests(): void { - for (const { manager } of MANAGERS_BY_ACCOUNT_ID.values()) { - manager.stop(); - } - MANAGERS_BY_ACCOUNT_ID.clear(); - BINDINGS_BY_ACCOUNT_CONVERSATION.clear(); -} +export { + getMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +}; diff --git a/extensions/matrix/src/runtime-api.ts b/extensions/matrix/src/runtime-api.ts index ece735819df..3c447f50e2f 100644 --- a/extensions/matrix/src/runtime-api.ts +++ b/extensions/matrix/src/runtime-api.ts @@ -1 +1,2 @@ +export * from "openclaw/plugin-sdk/matrix"; export * from "../runtime-api.js"; diff --git a/extensions/matrix/thread-bindings-runtime.ts b/extensions/matrix/thread-bindings-runtime.ts new file mode 100644 index 00000000000..b0e8ff49628 --- /dev/null +++ b/extensions/matrix/thread-bindings-runtime.ts @@ -0,0 +1,4 @@ +export { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "./src/matrix/thread-bindings-shared.js"; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index a85e8997389..660fe7183fb 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -85,7 +85,7 @@ export { export { setMatrixThreadBindingIdleTimeoutBySessionKey, setMatrixThreadBindingMaxAgeBySessionKey, -} from "../../extensions/matrix/src/matrix/thread-bindings.js"; +} from "../../extensions/matrix/thread-bindings-runtime.js"; export { createTypingCallbacks } from "../channels/typing.js"; export { createChannelReplyPipeline } from "./channel-reply-pipeline.js"; export type { OpenClawConfig } from "../config/config.js"; diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index 0a7eab63727..1a44e0e45f1 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -195,8 +195,8 @@ export type PluginRuntimeChannel = { }; matrix: { threadBindings: { - setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; - setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + setIdleTimeoutBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../plugin-sdk/matrix.js").setMatrixThreadBindingMaxAgeBySessionKey; }; }; signal: { From 9d772d6eab528b48235a036ad2585348c4860902 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:16:34 -0700 Subject: [PATCH 029/137] fix(ci): normalize bundle mcp paths and skip explicit channel scans --- src/infra/outbound/channel-selection.test.ts | 17 ++++++++++ src/infra/outbound/channel-selection.ts | 8 ++--- src/plugins/bundle-mcp.ts | 33 ++++++++++++++------ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/infra/outbound/channel-selection.test.ts b/src/infra/outbound/channel-selection.test.ts index fdb4ecd4b6f..9e6a1fa74d6 100644 --- a/src/infra/outbound/channel-selection.test.ts +++ b/src/infra/outbound/channel-selection.test.ts @@ -143,6 +143,23 @@ describe("resolveMessageChannelSelection", () => { }); }); + it("does not probe configured channels when an explicit channel is available", async () => { + const isConfigured = vi.fn(async () => true); + mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "slack", isConfigured })]); + + const selection = await resolveMessageChannelSelection({ + cfg: {} as never, + channel: "slack", + }); + + expect(selection).toEqual({ + channel: "slack", + configured: [], + source: "explicit", + }); + expect(isConfigured).not.toHaveBeenCalled(); + }); + it("falls back to tool context channel when explicit channel is unknown", async () => { const selection = await resolveMessageChannelSelection({ cfg: {} as never, diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index 0e87a8e4950..f9c6f558769 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,6 @@ -import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; +import { listChannelPlugins } from "../../channels/plugins/index.js"; import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, @@ -165,7 +165,7 @@ export async function resolveMessageChannelSelection(params: { if (fallback) { return { channel: fallback, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "tool-context-fallback", }; } @@ -176,7 +176,7 @@ export async function resolveMessageChannelSelection(params: { } return { channel: availableExplicit, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "explicit", }; } @@ -188,7 +188,7 @@ export async function resolveMessageChannelSelection(params: { if (fallback) { return { channel: fallback, - configured: await listConfiguredMessageChannels(params.cfg), + configured: [], source: "tool-context-fallback", }; } diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index b0960c17a93..620eb4a0a1f 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; +import type { PluginBundleFormat } from "./types.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; @@ -13,7 +14,7 @@ import { } from "./bundle-manifest.js"; import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; -import type { PluginBundleFormat } from "./types.js"; +import { safeRealpathSync } from "./path-safety.js"; export type BundleMcpServerConfig = Record; @@ -121,6 +122,14 @@ function expandBundleRootPlaceholders(value: string, rootDir: string): string { return value.split(CLAUDE_PLUGIN_ROOT_PLACEHOLDER).join(rootDir); } +function canonicalizeBundlePath(targetPath: string): string { + return path.normalize(safeRealpathSync(targetPath) ?? path.resolve(targetPath)); +} + +function normalizeExpandedAbsolutePath(value: string): string { + return path.isAbsolute(value) ? path.normalize(value) : value; +} + function absolutizeBundleMcpServer(params: { rootDir: string; baseDir: string; @@ -137,7 +146,7 @@ function absolutizeBundleMcpServer(params: { const expanded = expandBundleRootPlaceholders(command, params.rootDir); next.command = isExplicitRelativePath(expanded) ? path.resolve(params.baseDir, expanded) - : expanded; + : normalizeExpandedAbsolutePath(expanded); } const cwd = next.cwd; @@ -150,7 +159,7 @@ function absolutizeBundleMcpServer(params: { if (typeof workingDirectory === "string") { const expanded = expandBundleRootPlaceholders(workingDirectory, params.rootDir); next.workingDirectory = path.isAbsolute(expanded) - ? expanded + ? path.normalize(expanded) : path.resolve(params.baseDir, expanded); } @@ -161,7 +170,7 @@ function absolutizeBundleMcpServer(params: { } const expanded = expandBundleRootPlaceholders(entry, params.rootDir); if (!isExplicitRelativePath(expanded)) { - return expanded; + return normalizeExpandedAbsolutePath(expanded); } return path.resolve(params.baseDir, expanded); }); @@ -171,7 +180,9 @@ function absolutizeBundleMcpServer(params: { next.env = Object.fromEntries( Object.entries(next.env).map(([key, value]) => [ key, - typeof value === "string" ? expandBundleRootPlaceholders(value, params.rootDir) : value, + typeof value === "string" + ? normalizeExpandedAbsolutePath(expandBundleRootPlaceholders(value, params.rootDir)) + : value, ]), ); } @@ -183,10 +194,11 @@ function loadBundleFileBackedMcpConfig(params: { rootDir: string; relativePath: string; }): BundleMcpConfig { - const absolutePath = path.resolve(params.rootDir, params.relativePath); + const rootDir = canonicalizeBundlePath(params.rootDir); + const absolutePath = path.resolve(rootDir, params.relativePath); const opened = openBoundaryFileSync({ absolutePath, - rootPath: params.rootDir, + rootPath: rootDir, boundaryLabel: "plugin root", rejectHardlinks: true, }); @@ -200,12 +212,12 @@ function loadBundleFileBackedMcpConfig(params: { } const raw = JSON.parse(fs.readFileSync(opened.fd, "utf-8")) as unknown; const servers = extractMcpServerMap(raw); - const baseDir = path.dirname(absolutePath); + const baseDir = canonicalizeBundlePath(path.dirname(absolutePath)); return { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ rootDir: params.rootDir, baseDir, server }), + absolutizeBundleMcpServer({ rootDir, baseDir, server }), ]), ), }; @@ -221,12 +233,13 @@ function loadBundleInlineMcpConfig(params: { if (!isRecord(params.raw.mcpServers)) { return { mcpServers: {} }; } + const baseDir = canonicalizeBundlePath(params.baseDir); const servers = extractMcpServerMap(params.raw.mcpServers); return { mcpServers: Object.fromEntries( Object.entries(servers).map(([serverName, server]) => [ serverName, - absolutizeBundleMcpServer({ rootDir: params.baseDir, baseDir: params.baseDir, server }), + absolutizeBundleMcpServer({ rootDir: baseDir, baseDir, server }), ]), ), }; From 7a57082466bb1d9550cf55cb5e6abb94301529eb Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 21:28:48 +0530 Subject: [PATCH 030/137] fix(provider): onboard azure custom endpoints via responses --- CHANGELOG.md | 2 +- src/commands/onboard-custom.test.ts | 200 ++++++++++++++++++++++++++-- src/commands/onboard-custom.ts | 126 +++++++++++++++--- 3 files changed, 300 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a26a8e80b25..12cd1cb3095 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,7 +44,6 @@ 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 @@ -93,6 +92,7 @@ Docs: https://docs.openclaw.ai - Z.AI/onboarding: add `glm-5-turbo` to the default Z.AI provider catalog so onboarding-generated configs expose the new model alongside the existing GLM defaults. (#46670) Thanks @tomsun28. - Zalo Personal/group gating: stop reapplying `dmPolicy.allowFrom` as a sender gate for already-allowlisted groups when `groupAllowFrom` is unset, so any member of an allowed group can trigger replies while DMs stay restricted. (#46663) Fixes #40146. Thanks @Takhoffman. - Zalo/plugin runtime: export `resolveClientIp` from `openclaw/plugin-sdk/zalo` so installed builds no longer crash on startup when the webhook monitor loads from the packaged extension instead of the monorepo source tree. (#46549) Thanks @No898. +- Onboarding/custom providers: store Azure OpenAI and Azure AI Foundry custom endpoints with the Responses API config shape, normalized `/openai/v1` base URLs, and Azure-safe defaults so TUI and agent runs work after setup. (#49543) Thanks @kunalk16. - Docker/live tests: mount external CLI auth homes into writable container copies, derive Codex OAuth expiry from JWT `exp`, refresh synced CLI creds instead of trusting stale cached expiry, and make gateway live probes wait on transcript output so `pnpm test:docker:all` stays green in Linux. - Plugins/install precedence: keep bundled plugins ahead of auto-discovered globals by default, but let an explicitly installed plugin record win its own duplicate-id tie so installed channel plugins load from `~/.openclaw/extensions` after `openclaw plugins install`. (#46722) Thanks @Takhoffman. - Control UI/logging: make browser-safe logger imports avoid eager temp-dir resolution so the bundled Control UI no longer crashes to a blank screen when logging reaches `tmp-openclaw-dir`. (#48469) Fixes #48062. Thanks @7inspire. diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index cf86da64211..ef97b3e4f83 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -188,7 +188,7 @@ describe("promptCustomApiConfig", () => { expect(JSON.parse(firstCall?.body ?? "{}")).toMatchObject({ max_tokens: 1 }); }); - it("uses azure-specific headers and body for openai verification probes", async () => { + it("uses azure responses-specific headers and body for openai verification probes", async () => { const prompter = createTestPrompter({ text: [ "https://my-resource.openai.azure.com", @@ -213,18 +213,16 @@ describe("promptCustomApiConfig", () => { } const parsedBody = JSON.parse(firstInit?.body ?? "{}"); - expect(firstUrl).toContain("/openai/deployments/gpt-4.1/chat/completions"); - expect(firstUrl).toContain("api-version=2024-10-21"); + expect(firstUrl).toBe("https://my-resource.openai.azure.com/openai/v1/responses"); expect(firstInit?.headers?.["api-key"]).toBe("azure-test-key"); expect(firstInit?.headers?.Authorization).toBeUndefined(); expect(firstInit?.body).toBeDefined(); - expect(parsedBody).toMatchObject({ - messages: [{ role: "user", content: "Hi" }], - max_completion_tokens: 5, + expect(parsedBody).toEqual({ + model: "gpt-4.1", + input: "Hi", + max_output_tokens: 1, stream: false, }); - expect(parsedBody).not.toHaveProperty("model"); - expect(parsedBody).not.toHaveProperty("max_tokens"); }); it("uses expanded max_tokens for anthropic verification probes", async () => { @@ -432,6 +430,192 @@ describe("applyCustomApiConfig", () => { ])("rejects $name", ({ params, expectedMessage }) => { expect(() => applyCustomApiConfig(params)).toThrow(expectedMessage); }); + + it("produces azure-specific config for Azure OpenAI URLs with reasoning model", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://user123-resource.openai.azure.com", + modelId: "o4-mini", + compatibility: "openai", + apiKey: "abcd1234", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://user123-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "abcd1234" }); + + const model = provider?.models?.find((m) => m.id === "o4-mini"); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.reasoning).toBe(true); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/${result.modelId}`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); + }); + + it("produces azure-specific config for Azure AI Foundry URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.services.ai.azure.com", + modelId: "gpt-4.1", + compatibility: "openai", + apiKey: "key123", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key123" }); + + const model = provider?.models?.find((m) => m.id === "gpt-4.1"); + expect(model?.reasoning).toBe(false); + expect(model?.input).toEqual(["text"]); + expect(model?.compat).toEqual({ supportsStore: false }); + + const modelRef = `${providerId}/gpt-4.1`; + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBeUndefined(); + }); + + it("strips pre-existing deployment path from Azure URL in stored config", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key456", + }); + const providerId = result.providerId!; + const provider = result.config.models?.providers?.[providerId]; + + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + }); + + it("re-onboard updates existing Azure provider instead of creating a duplicate", () => { + const oldProviderId = "custom-my-resource-openai-azure-com"; + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + [oldProviderId]: { + baseUrl: "https://my-resource.openai.azure.com/openai/deployments/gpt-4", + api: "openai-completions", + models: [ + { + id: "gpt-4", + name: "gpt-4", + contextWindow: 1, + maxTokens: 1, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }, + ], + }, + }, + }, + }, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "gpt-4", + compatibility: "openai", + apiKey: "key789", + }); + + expect(result.providerId).toBe(oldProviderId); + expect(result.providerIdRenamedFrom).toBeUndefined(); + const provider = result.config.models?.providers?.[oldProviderId]; + expect(provider?.baseUrl).toBe("https://my-resource.openai.azure.com/openai/v1"); + expect(provider?.api).toBe("openai-responses"); + expect(provider?.authHeader).toBe(false); + expect(provider?.headers).toEqual({ "api-key": "key789" }); + }); + + it("does not add azure fields for non-azure URLs", () => { + const result = applyCustomApiConfig({ + config: {}, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key123", + providerId: "custom", + }); + const provider = result.config.models?.providers?.custom; + + expect(provider?.api).toBe("openai-completions"); + expect(provider?.authHeader).toBeUndefined(); + expect(provider?.headers).toBeUndefined(); + expect(provider?.models?.[0]?.reasoning).toBe(false); + expect(provider?.models?.[0]?.input).toEqual(["text"]); + expect(provider?.models?.[0]?.compat).toBeUndefined(); + expect( + result.config.agents?.defaults?.models?.["custom/foo-large"]?.params?.thinking, + ).toBeUndefined(); + }); + + it("re-onboard preserves user-customized fields for non-azure models", () => { + const result = applyCustomApiConfig({ + config: { + models: { + providers: { + custom: { + baseUrl: "https://llm.example.com/v1", + api: "openai-completions", + models: [ + { + id: "foo-large", + name: "My Custom Model", + reasoning: true, + input: ["text", "image"], + cost: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 16384, + }, + ], + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://llm.example.com/v1", + modelId: "foo-large", + compatibility: "openai", + apiKey: "key", + providerId: "custom", + }); + const model = result.config.models?.providers?.custom?.models?.find( + (m) => m.id === "foo-large", + ); + expect(model?.name).toBe("My Custom Model"); + expect(model?.reasoning).toBe(true); + expect(model?.input).toEqual(["text", "image"]); + expect(model?.cost).toEqual({ input: 1, output: 2, cacheRead: 0, cacheWrite: 0 }); + expect(model?.maxTokens).toBe(16384); + expect(model?.contextWindow).toBe(131072); + }); + + it("preserves existing per-model thinking when already set for azure reasoning model", () => { + const providerId = "custom-my-resource-openai-azure-com"; + const modelRef = `${providerId}/o3-mini`; + const result = applyCustomApiConfig({ + config: { + agents: { + defaults: { + models: { + [modelRef]: { params: { thinking: "high" } }, + }, + }, + }, + } as OpenClawConfig, + baseUrl: "https://my-resource.openai.azure.com", + modelId: "o3-mini", + compatibility: "openai", + apiKey: "key", + }); + expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("high"); + }); }); describe("parseNonInteractiveCustomApiFlags", () => { diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index 9de8e3f85cf..bf4fc1edeea 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -19,6 +19,9 @@ import type { SecretInputMode } from "./onboard-types.js"; const DEFAULT_CONTEXT_WINDOW = CONTEXT_WINDOW_HARD_MIN_TOKENS; const DEFAULT_MAX_TOKENS = 4096; +// Azure OpenAI uses the Responses API which supports larger defaults +const AZURE_DEFAULT_CONTEXT_WINDOW = 400_000; +const AZURE_DEFAULT_MAX_TOKENS = 16_384; const VERIFY_TIMEOUT_MS = 30_000; function normalizeContextWindowForCustomModel(value: unknown): number { @@ -61,6 +64,32 @@ function transformAzureUrl(baseUrl: string, modelId: string): string { return `${normalizedUrl}/openai/deployments/${modelId}`; } +/** + * Transforms an Azure URL into the base URL stored in config. + * + * Example: + * https://my-resource.openai.azure.com + * => https://my-resource.openai.azure.com/openai/v1 + */ +function transformAzureConfigUrl(baseUrl: string): string { + const normalizedUrl = baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl; + if (normalizedUrl.endsWith("/openai/v1")) { + return normalizedUrl; + } + // Strip a full deployment path back to the base origin + const deploymentIdx = normalizedUrl.indexOf("/openai/deployments/"); + const base = deploymentIdx !== -1 ? normalizedUrl.slice(0, deploymentIdx) : normalizedUrl; + return `${base}/openai/v1`; +} + +function hasSameHost(a: string, b: string): boolean { + try { + return new URL(a).hostname.toLowerCase() === new URL(b).hostname.toLowerCase(); + } catch { + return false; + } +} + export type CustomApiCompatibility = "openai" | "anthropic"; type CustomApiCompatibilityChoice = CustomApiCompatibility | "unknown"; export type CustomApiResult = { @@ -174,7 +203,11 @@ function resolveUniqueEndpointId(params: { }) { const normalized = normalizeEndpointId(params.requestedId) || "custom"; const existing = params.providers[normalized]; - if (!existing?.baseUrl || existing.baseUrl === params.baseUrl) { + if ( + !existing?.baseUrl || + existing.baseUrl === params.baseUrl || + (isAzureUrl(params.baseUrl) && hasSameHost(existing.baseUrl, params.baseUrl)) + ) { return { providerId: normalized, renamed: false }; } let suffix = 2; @@ -320,26 +353,31 @@ async function requestOpenAiVerification(params: { apiKey: string; modelId: string; }): Promise { - const endpoint = resolveVerificationEndpoint({ - baseUrl: params.baseUrl, - modelId: params.modelId, - endpointPath: "chat/completions", - }); const isBaseUrlAzureUrl = isAzureUrl(params.baseUrl); const headers = isBaseUrlAzureUrl ? buildAzureOpenAiHeaders(params.apiKey) : buildOpenAiHeaders(params.apiKey); if (isBaseUrlAzureUrl) { + const endpoint = new URL( + "responses", + transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), + ).href; return await requestVerification({ endpoint, headers, body: { - messages: [{ role: "user", content: "Hi" }], - max_completion_tokens: 5, + model: params.modelId, + input: "Hi", + max_output_tokens: 1, stream: false, }, }); } else { + const endpoint = resolveVerificationEndpoint({ + baseUrl: params.baseUrl, + modelId: params.modelId, + endpointPath: "chat/completions", + }); return await requestVerification({ endpoint, headers, @@ -572,8 +610,9 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom throw new CustomApiError("invalid_model_id", "Custom provider model ID is required."); } + const isAzure = isAzureUrl(baseUrl); // Transform Azure URLs to include the deployment path for API calls - const resolvedBaseUrl = isAzureUrl(baseUrl) ? transformAzureUrl(baseUrl, modelId) : baseUrl; + const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; const providerIdResult = resolveCustomProviderId({ config: params.config, @@ -597,21 +636,39 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom const existingProvider = providers[providerId]; const existingModels = Array.isArray(existingProvider?.models) ? existingProvider.models : []; const hasModel = existingModels.some((model) => model.id === modelId); - const nextModel = { - id: modelId, - name: `${modelId} (Custom Provider)`, - contextWindow: DEFAULT_CONTEXT_WINDOW, - maxTokens: DEFAULT_MAX_TOKENS, - input: ["text"] as ["text"], - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - reasoning: false, - }; + const isLikelyReasoningModel = isAzure && /\b(o[134]|gpt-([5-9]|\d{2,}))\b/i.test(modelId); + const nextModel = isAzure + ? { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: AZURE_DEFAULT_CONTEXT_WINDOW, + maxTokens: AZURE_DEFAULT_MAX_TOKENS, + input: isLikelyReasoningModel + ? (["text", "image"] as Array<"text" | "image">) + : (["text"] as ["text"]), + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: isLikelyReasoningModel, + compat: { supportsStore: false }, + } + : { + id: modelId, + name: `${modelId} (Custom Provider)`, + contextWindow: DEFAULT_CONTEXT_WINDOW, + maxTokens: DEFAULT_MAX_TOKENS, + input: ["text"] as ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + reasoning: false, + }; const mergedModels = hasModel ? existingModels.map((model) => model.id === modelId ? { ...model, + ...(isAzure ? nextModel : {}), + name: model.name ?? nextModel.name, + cost: model.cost ?? nextModel.cost, contextWindow: normalizeContextWindowForCustomModel(model.contextWindow), + maxTokens: model.maxTokens ?? nextModel.maxTokens, } : model, ) @@ -621,6 +678,11 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom normalizeOptionalProviderApiKey(params.apiKey) ?? normalizeOptionalProviderApiKey(existingApiKey); + const providerApi = isAzure + ? ("openai-responses" as const) + : resolveProviderApi(params.compatibility); + const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; + let config: OpenClawConfig = { ...params.config, models: { @@ -631,8 +693,10 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom [providerId]: { ...existingProviderRest, baseUrl: resolvedBaseUrl, - api: resolveProviderApi(params.compatibility), + api: providerApi, ...(normalizedApiKey ? { apiKey: normalizedApiKey } : {}), + ...(isAzure ? { authHeader: false } : {}), + ...(azureHeaders ? { headers: azureHeaders } : {}), models: mergedModels.length > 0 ? mergedModels : [nextModel], }, }, @@ -640,6 +704,30 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom }; config = applyPrimaryModel(config, modelRef); + if (isAzure && isLikelyReasoningModel) { + const existingPerModelThinking = config.agents?.defaults?.models?.[modelRef]?.params?.thinking; + if (!existingPerModelThinking) { + config = { + ...config, + agents: { + ...config.agents, + defaults: { + ...config.agents?.defaults, + models: { + ...config.agents?.defaults?.models, + [modelRef]: { + ...config.agents?.defaults?.models?.[modelRef], + params: { + ...config.agents?.defaults?.models?.[modelRef]?.params, + thinking: "medium", + }, + }, + }, + }, + }, + }; + } + } if (alias) { config = { ...config, From 5b1836d700410461a43e9ec0ae4183963286fc7e Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 21:42:06 +0530 Subject: [PATCH 031/137] fix(onboard): raise azure probe output floor --- src/commands/onboard-custom.test.ts | 2 +- src/commands/onboard-custom.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index ef97b3e4f83..a8a6adc52f6 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -220,7 +220,7 @@ describe("promptCustomApiConfig", () => { expect(parsedBody).toEqual({ model: "gpt-4.1", input: "Hi", - max_output_tokens: 1, + max_output_tokens: 16, stream: false, }); }); diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index bf4fc1edeea..a24a113cbb7 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -368,7 +368,7 @@ async function requestOpenAiVerification(params: { body: { model: params.modelId, input: "Hi", - max_output_tokens: 1, + max_output_tokens: 16, stream: false, }, }); From 91104ac74057bc75ce58dfb55ff01e877ec73a0a Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 22:04:33 +0530 Subject: [PATCH 032/137] fix(onboard): respect services.ai custom provider compatibility --- CHANGELOG.md | 1 + src/commands/onboard-custom.test.ts | 42 +++++++++++++++++++++++++++-- src/commands/onboard-custom.ts | 30 +++++++++++++-------- 3 files changed, 60 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12cd1cb3095..b2c66c05ac5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,6 +163,7 @@ Docs: https://docs.openclaw.ai - 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. +- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. ### Breaking diff --git a/src/commands/onboard-custom.test.ts b/src/commands/onboard-custom.test.ts index a8a6adc52f6..7917d45ca8f 100644 --- a/src/commands/onboard-custom.test.ts +++ b/src/commands/onboard-custom.test.ts @@ -225,6 +225,44 @@ describe("promptCustomApiConfig", () => { }); }); + it("uses Azure Foundry chat-completions probes for services.ai URLs", async () => { + const prompter = createTestPrompter({ + text: [ + "https://my-resource.services.ai.azure.com", + "azure-test-key", + "deepseek-v3-0324", + "custom", + "alias", + ], + select: ["plaintext", "openai"], + }); + const fetchMock = stubFetchSequence([{ ok: true }]); + + await runPromptCustomApi(prompter); + + const firstCall = fetchMock.mock.calls[0]; + const firstUrl = firstCall?.[0]; + const firstInit = firstCall?.[1] as + | { body?: string; headers?: Record } + | undefined; + if (typeof firstUrl !== "string") { + throw new Error("Expected first verification call URL"); + } + const parsedBody = JSON.parse(firstInit?.body ?? "{}"); + + expect(firstUrl).toBe( + "https://my-resource.services.ai.azure.com/openai/deployments/deepseek-v3-0324/chat/completions?api-version=2024-10-21", + ); + expect(firstInit?.headers?.["api-key"]).toBe("azure-test-key"); + expect(firstInit?.headers?.Authorization).toBeUndefined(); + expect(parsedBody).toEqual({ + model: "deepseek-v3-0324", + messages: [{ role: "user", content: "Hi" }], + max_tokens: 1, + stream: false, + }); + }); + it("uses expanded max_tokens for anthropic verification probes", async () => { const prompter = createTestPrompter({ text: ["https://example.com", "test-key", "detected-model", "custom", "alias"], @@ -456,7 +494,7 @@ describe("applyCustomApiConfig", () => { expect(result.config.agents?.defaults?.models?.[modelRef]?.params?.thinking).toBe("medium"); }); - it("produces azure-specific config for Azure AI Foundry URLs", () => { + it("keeps selected compatibility for Azure AI Foundry URLs", () => { const result = applyCustomApiConfig({ config: {}, baseUrl: "https://my-resource.services.ai.azure.com", @@ -468,7 +506,7 @@ describe("applyCustomApiConfig", () => { const provider = result.config.models?.providers?.[providerId]; expect(provider?.baseUrl).toBe("https://my-resource.services.ai.azure.com/openai/v1"); - expect(provider?.api).toBe("openai-responses"); + expect(provider?.api).toBe("openai-completions"); expect(provider?.authHeader).toBe(false); expect(provider?.headers).toEqual({ "api-key": "key123" }); diff --git a/src/commands/onboard-custom.ts b/src/commands/onboard-custom.ts index a24a113cbb7..5afab742448 100644 --- a/src/commands/onboard-custom.ts +++ b/src/commands/onboard-custom.ts @@ -29,22 +29,30 @@ function normalizeContextWindowForCustomModel(value: unknown): number { return parsed >= CONTEXT_WINDOW_HARD_MIN_TOKENS ? parsed : CONTEXT_WINDOW_HARD_MIN_TOKENS; } -/** - * Detects if a URL is from Azure AI Foundry or Azure OpenAI. - * Matches both: - * - https://*.services.ai.azure.com (Azure AI Foundry) - * - https://*.openai.azure.com (classic Azure OpenAI) - */ -function isAzureUrl(baseUrl: string): boolean { +function isAzureFoundryUrl(baseUrl: string): boolean { try { const url = new URL(baseUrl); const host = url.hostname.toLowerCase(); - return host.endsWith(".services.ai.azure.com") || host.endsWith(".openai.azure.com"); + return host.endsWith(".services.ai.azure.com"); } catch { return false; } } +function isAzureOpenAiUrl(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + const host = url.hostname.toLowerCase(); + return host.endsWith(".openai.azure.com"); + } catch { + return false; + } +} + +function isAzureUrl(baseUrl: string): boolean { + return isAzureFoundryUrl(baseUrl) || isAzureOpenAiUrl(baseUrl); +} + /** * Transforms an Azure AI Foundry/OpenAI URL to include the deployment path. * Azure requires: https://host/openai/deployments//chat/completions?api-version=2024-xx-xx-preview @@ -357,7 +365,7 @@ async function requestOpenAiVerification(params: { const headers = isBaseUrlAzureUrl ? buildAzureOpenAiHeaders(params.apiKey) : buildOpenAiHeaders(params.apiKey); - if (isBaseUrlAzureUrl) { + if (isAzureOpenAiUrl(params.baseUrl)) { const endpoint = new URL( "responses", transformAzureConfigUrl(params.baseUrl).replace(/\/?$/, "/"), @@ -611,7 +619,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom } const isAzure = isAzureUrl(baseUrl); - // Transform Azure URLs to include the deployment path for API calls + const isAzureOpenAi = isAzureOpenAiUrl(baseUrl); const resolvedBaseUrl = isAzure ? transformAzureConfigUrl(baseUrl) : baseUrl; const providerIdResult = resolveCustomProviderId({ @@ -678,7 +686,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom normalizeOptionalProviderApiKey(params.apiKey) ?? normalizeOptionalProviderApiKey(existingApiKey); - const providerApi = isAzure + const providerApi = isAzureOpenAi ? ("openai-responses" as const) : resolveProviderApi(params.compatibility); const azureHeaders = isAzure && normalizedApiKey ? { "api-key": normalizedApiKey } : undefined; From f1e4f8e8d2784d4455f64fe552878bb84067d790 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Thu, 19 Mar 2026 22:06:10 +0530 Subject: [PATCH 033/137] fix: add changelog attribution for Azure Foundry custom providers (#50535) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2c66c05ac5..50f4c317fb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -163,7 +163,7 @@ Docs: https://docs.openclaw.ai - 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. -- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. +- Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. ### Breaking From dcbcecfb85e722156e4a9c698ded3972c0da9689 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:38:30 -0700 Subject: [PATCH 034/137] fix(ci): resolve Claude marketplace shortcuts from OS home --- src/infra/home-dir.test.ts | 30 +++++++++++++++++++++ src/infra/home-dir.ts | 46 ++++++++++++++++++++++++++++++--- src/plugins/marketplace.test.ts | 4 ++- src/plugins/marketplace.ts | 3 ++- 4 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/infra/home-dir.test.ts b/src/infra/home-dir.test.ts index 9faeda1dee5..2382b56eaac 100644 --- a/src/infra/home-dir.test.ts +++ b/src/infra/home-dir.test.ts @@ -4,6 +4,8 @@ import { expandHomePrefix, resolveEffectiveHomeDir, resolveHomeRelativePath, + resolveOsHomeDir, + resolveOsHomeRelativePath, resolveRequiredHomeDir, } from "./home-dir.js"; @@ -95,6 +97,21 @@ describe("resolveRequiredHomeDir", () => { }); }); +describe("resolveOsHomeDir", () => { + it("ignores OPENCLAW_HOME and uses HOME", () => { + expect( + resolveOsHomeDir( + { + OPENCLAW_HOME: "/srv/openclaw-home", + HOME: "/home/alice", + USERPROFILE: "C:/Users/alice", + } as NodeJS.ProcessEnv, + () => "/fallback", + ), + ).toBe(path.resolve("/home/alice")); + }); +}); + describe("expandHomePrefix", () => { it.each([ { @@ -158,3 +175,16 @@ describe("resolveHomeRelativePath", () => { ).toBe(path.resolve(process.cwd())); }); }); + +describe("resolveOsHomeRelativePath", () => { + it("expands tilde paths using the OS home instead of OPENCLAW_HOME", () => { + expect( + resolveOsHomeRelativePath("~/docs", { + env: { + OPENCLAW_HOME: "/srv/openclaw-home", + HOME: "/home/alice", + } as NodeJS.ProcessEnv, + }), + ).toBe(path.resolve("/home/alice/docs")); + }); +}); diff --git a/src/infra/home-dir.ts b/src/infra/home-dir.ts index 650cf0cadac..956eeebb278 100644 --- a/src/infra/home-dir.ts +++ b/src/infra/home-dir.ts @@ -14,12 +14,19 @@ export function resolveEffectiveHomeDir( return raw ? path.resolve(raw) : undefined; } +export function resolveOsHomeDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string | undefined { + const raw = resolveRawOsHomeDir(env, homedir); + return raw ? path.resolve(raw) : undefined; +} + function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined { const explicitHome = normalize(env.OPENCLAW_HOME); if (explicitHome) { if (explicitHome === "~" || explicitHome.startsWith("~/") || explicitHome.startsWith("~\\")) { - const fallbackHome = - normalize(env.HOME) ?? normalize(env.USERPROFILE) ?? normalizeSafe(homedir); + const fallbackHome = resolveRawOsHomeDir(env, homedir); if (fallbackHome) { return explicitHome.replace(/^~(?=$|[\\/])/, fallbackHome); } @@ -28,16 +35,18 @@ function resolveRawHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): strin return explicitHome; } + return resolveRawOsHomeDir(env, homedir); +} + +function resolveRawOsHomeDir(env: NodeJS.ProcessEnv, homedir: () => string): string | undefined { const envHome = normalize(env.HOME); if (envHome) { return envHome; } - const userProfile = normalize(env.USERPROFILE); if (userProfile) { return userProfile; } - return normalizeSafe(homedir); } @@ -56,6 +65,13 @@ export function resolveRequiredHomeDir( return resolveEffectiveHomeDir(env, homedir) ?? path.resolve(process.cwd()); } +export function resolveRequiredOsHomeDir( + env: NodeJS.ProcessEnv = process.env, + homedir: () => string = os.homedir, +): string { + return resolveOsHomeDir(env, homedir) ?? path.resolve(process.cwd()); +} + export function expandHomePrefix( input: string, opts?: { @@ -97,3 +113,25 @@ export function resolveHomeRelativePath( } return path.resolve(trimmed); } + +export function resolveOsHomeRelativePath( + input: string, + opts?: { + env?: NodeJS.ProcessEnv; + homedir?: () => string; + }, +): string { + const trimmed = input.trim(); + if (!trimmed) { + return trimmed; + } + if (trimmed.startsWith("~")) { + const expanded = expandHomePrefix(trimmed, { + home: resolveRequiredOsHomeDir(opts?.env ?? process.env, opts?.homedir ?? os.homedir), + env: opts?.env, + homedir: opts?.homedir, + }); + return path.resolve(expanded); + } + return path.resolve(trimmed); +} diff --git a/src/plugins/marketplace.test.ts b/src/plugins/marketplace.test.ts index 92918e256d4..6ae2b010556 100644 --- a/src/plugins/marketplace.test.ts +++ b/src/plugins/marketplace.test.ts @@ -111,7 +111,9 @@ describe("marketplace plugins", () => { it("resolves Claude-style plugin@marketplace shortcuts from known_marketplaces.json", async () => { await withTempDir(async (homeDir) => { + const openClawHome = path.join(homeDir, "openclaw-home"); await fs.mkdir(path.join(homeDir, ".claude", "plugins"), { recursive: true }); + await fs.mkdir(openClawHome, { recursive: true }); await fs.writeFile( path.join(homeDir, ".claude", "plugins", "known_marketplaces.json"), JSON.stringify({ @@ -127,7 +129,7 @@ describe("marketplace plugins", () => { const { resolveMarketplaceInstallShortcut } = await import("./marketplace.js"); const shortcut = await withEnvAsync( - { HOME: homeDir }, + { HOME: homeDir, OPENCLAW_HOME: openClawHome }, async () => await resolveMarketplaceInstallShortcut("superpowers@claude-plugins-official"), ); diff --git a/src/plugins/marketplace.ts b/src/plugins/marketplace.ts index 4999c3c8828..24d2fae8ba1 100644 --- a/src/plugins/marketplace.ts +++ b/src/plugins/marketplace.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { resolveArchiveKind } from "../infra/archive.js"; +import { resolveOsHomeRelativePath } from "../infra/home-dir.js"; import { runCommandWithTimeout } from "../process/exec.js"; import { resolveUserPath } from "../utils.js"; import { installPluginFromPath, type InstallPluginResult } from "./install.js"; @@ -299,7 +300,7 @@ async function pathExists(target: string): Promise { } async function readClaudeKnownMarketplaces(): Promise> { - const knownPath = resolveUserPath(CLAUDE_KNOWN_MARKETPLACES_PATH); + const knownPath = resolveOsHomeRelativePath(CLAUDE_KNOWN_MARKETPLACES_PATH); if (!(await pathExists(knownPath))) { return {}; } From 639f78d257f6568ce3c7b5d47e024ceaaf0252f4 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:38:35 -0700 Subject: [PATCH 035/137] style(format): restore import order drift --- src/infra/outbound/channel-selection.ts | 2 +- src/plugins/bundle-mcp.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/outbound/channel-selection.ts b/src/infra/outbound/channel-selection.ts index f9c6f558769..569ea343c52 100644 --- a/src/infra/outbound/channel-selection.ts +++ b/src/infra/outbound/channel-selection.ts @@ -1,6 +1,6 @@ +import { listChannelPlugins } from "../../channels/plugins/index.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import type { OpenClawConfig } from "../../config/config.js"; -import { listChannelPlugins } from "../../channels/plugins/index.js"; import { defaultRuntime } from "../../runtime.js"; import { listDeliverableMessageChannels, diff --git a/src/plugins/bundle-mcp.ts b/src/plugins/bundle-mcp.ts index 620eb4a0a1f..ebe1b369f3c 100644 --- a/src/plugins/bundle-mcp.ts +++ b/src/plugins/bundle-mcp.ts @@ -1,7 +1,6 @@ import fs from "node:fs"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; -import type { PluginBundleFormat } from "./types.js"; import { applyMergePatch } from "../config/merge-patch.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { isRecord } from "../utils.js"; @@ -15,6 +14,7 @@ import { import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; import { loadPluginManifestRegistry } from "./manifest-registry.js"; import { safeRealpathSync } from "./path-safety.js"; +import type { PluginBundleFormat } from "./types.js"; export type BundleMcpServerConfig = Record; From 7fb142d11525ff528539d62398e3843d6d9b0255 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:42:13 -0700 Subject: [PATCH 036/137] test(whatsapp): override config-runtime mock exports safely --- extensions/whatsapp/src/test-helpers.ts | 90 +++++++++++++++---------- 1 file changed, 55 insertions(+), 35 deletions(-) diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 74c5f8c3584..b71f25f9d63 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -36,44 +36,64 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); const mockModule = Object.create(null) as Record; Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); - Object.defineProperty(mockModule, "loadConfig", { - configurable: true, - enumerable: true, - writable: true, - value: () => { - const getter = (globalThis as Record)[CONFIG_KEY]; - if (typeof getter === "function") { - return getter(); - } - return DEFAULT_CONFIG; + Object.defineProperties(mockModule, { + loadConfig: { + configurable: true, + enumerable: true, + writable: true, + value: () => { + const getter = (globalThis as Record)[CONFIG_KEY]; + if (typeof getter === "function") { + return getter(); + } + return DEFAULT_CONFIG; + }, }, - }); - Object.assign(mockModule, { - updateLastRoute: async (params: { - storePath: string; - sessionKey: string; - deliveryContext: { channel: string; to: string; accountId?: string }; - }) => { - const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}"); - const store = JSON.parse(raw) as Record>; - const current = store[params.sessionKey] ?? {}; - store[params.sessionKey] = { - ...current, - lastChannel: params.deliveryContext.channel, - lastTo: params.deliveryContext.to, - lastAccountId: params.deliveryContext.accountId, - }; - await fs.writeFile(params.storePath, JSON.stringify(store)); + updateLastRoute: { + configurable: true, + enumerable: true, + writable: true, + value: async (params: { + storePath: string; + sessionKey: string; + deliveryContext: { channel: string; to: string; accountId?: string }; + }) => { + const raw = await fs.readFile(params.storePath, "utf8").catch(() => "{}"); + const store = JSON.parse(raw) as Record>; + const current = store[params.sessionKey] ?? {}; + store[params.sessionKey] = { + ...current, + lastChannel: params.deliveryContext.channel, + lastTo: params.deliveryContext.to, + lastAccountId: params.deliveryContext.accountId, + }; + await fs.writeFile(params.storePath, JSON.stringify(store)); + }, }, - loadSessionStore: (storePath: string) => { - try { - return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record; - } catch { - return {}; - } + loadSessionStore: { + configurable: true, + enumerable: true, + writable: true, + value: (storePath: string) => { + try { + return JSON.parse(fsSync.readFileSync(storePath, "utf8")) as Record; + } catch { + return {}; + } + }, + }, + recordSessionMetaFromInbound: { + configurable: true, + enumerable: true, + writable: true, + value: async () => undefined, + }, + resolveStorePath: { + configurable: true, + enumerable: true, + writable: true, + value: actual.resolveStorePath, }, - recordSessionMetaFromInbound: async () => undefined, - resolveStorePath: actual.resolveStorePath, }); return mockModule; }); From 401ffb59f538488349664fa42e554dfb36d53a3a Mon Sep 17 00:00:00 2001 From: Harold Hunt Date: Thu, 19 Mar 2026 12:51:10 -0400 Subject: [PATCH 037/137] CLI: support versioned plugin updates (#49998) Merged via squash. Prepared head SHA: 545ea60fa26bb742376237ca83c65665133bcf7c Co-authored-by: huntharo <5617868+huntharo@users.noreply.github.com> Reviewed-by: @huntharo --- CHANGELOG.md | 1 + docs/cli/plugins.md | 14 +++- docs/tools/plugin.md | 2 +- src/cli/plugins-cli.test.ts | 134 ++++++++++++++++++++++++++++++++++++ src/cli/plugins-cli.ts | 59 +++++++++++++++- src/plugins/update.test.ts | 123 +++++++++++++++++++++++++++++++++ src/plugins/update.ts | 24 ++++--- 7 files changed, 345 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 50f4c317fb1..9a37dfe581c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -164,6 +164,7 @@ Docs: https://docs.openclaw.ai - 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. - Onboarding/custom providers: keep Azure AI Foundry `*.services.ai.azure.com` custom endpoints on the selected compatibility path instead of forcing Responses, so chat-completions Foundry models still work after setup. Fixes #50528. (#50535) Thanks @obviyus. +- Plugins/update: let `openclaw plugins update ` target tracked npm installs by dist-tag or exact version, and preserve the recorded npm spec for later id-based updates. (#49998) Thanks @huntharo. ### Breaking diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 47ef4930b8a..3d4c482707f 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -138,14 +138,24 @@ state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/`). Use ### Update ```bash -openclaw plugins update +openclaw plugins update openclaw plugins update --all -openclaw plugins update --dry-run +openclaw plugins update --dry-run +openclaw plugins update @openclaw/voice-call@beta ``` Updates apply to tracked installs in `plugins.installs`, currently npm and marketplace installs. +When you pass a plugin id, OpenClaw reuses the recorded install spec for that +plugin. That means previously stored dist-tags such as `@beta` and exact pinned +versions continue to be used on later `update ` runs. + +For npm installs, you can also pass an explicit npm package spec with a dist-tag +or exact version. OpenClaw resolves that package name back to the tracked plugin +record, updates that installed plugin, and records the new npm spec for future +id-based updates. + When a stored integrity hash exists and the fetched artifact hash changes, OpenClaw prints a warning and asks for confirmation before proceeding. Use global `--yes` to bypass prompts in CI/non-interactive runs. diff --git a/docs/tools/plugin.md b/docs/tools/plugin.md index 48b60d3fe1d..16291eab32d 100644 --- a/docs/tools/plugin.md +++ b/docs/tools/plugin.md @@ -286,7 +286,7 @@ openclaw plugins install ./plugin.zip # install from a local zip openclaw plugins install -l ./extensions/voice-call # link (no copy) for dev openclaw plugins install @openclaw/voice-call # install from npm openclaw plugins install @openclaw/voice-call --pin # store exact resolved name@version -openclaw plugins update +openclaw plugins update openclaw plugins update --all openclaw plugins enable openclaw plugins disable diff --git a/src/cli/plugins-cli.test.ts b/src/cli/plugins-cli.test.ts index 50bc8633e70..4efb1990354 100644 --- a/src/cli/plugins-cli.test.ts +++ b/src/cli/plugins-cli.test.ts @@ -379,6 +379,140 @@ describe("plugins cli", () => { expect(runtimeLogs.at(-1)).toBe("No tracked plugins to update."); }); + it("maps an explicit unscoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }), + ); + }); + + it("maps an explicit scoped npm dist-tag update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "voice-call": { + source: "npm", + spec: "@openclaw/voice-call", + installPath: "/tmp/voice-call", + resolvedName: "@openclaw/voice-call", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "@openclaw/voice-call@beta"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["voice-call"], + specOverrides: { + "voice-call": "@openclaw/voice-call@beta", + }, + }), + ); + }); + + it("maps an explicit npm version update to the tracked plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server@0.2.0-beta.4"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }), + ); + }); + + it("keeps using the recorded npm tag when update is invoked by plugin id", async () => { + const config = { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + }, + }, + }, + } as OpenClawConfig; + loadConfig.mockReturnValue(config); + updateNpmInstalledPlugins.mockResolvedValue({ + config, + changed: false, + outcomes: [], + }); + + await runCommand(["plugins", "update", "openclaw-codex-app-server"]); + + expect(updateNpmInstalledPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config, + pluginIds: ["openclaw-codex-app-server"], + }), + ); + expect(updateNpmInstalledPlugins).not.toHaveBeenCalledWith( + expect.objectContaining({ + specOverrides: expect.anything(), + }), + ); + }); + it("writes updated config when updater reports changes", async () => { const cfg = { plugins: { diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 79fca829281..93e3d22c8d5 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -7,6 +7,7 @@ import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; import { resolveArchiveKind } from "../infra/archive.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; import { type BundledPluginSource, findBundledPluginSource } from "../plugins/bundled-sources.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; @@ -227,6 +228,56 @@ function createPluginInstallLogger(): { info: (msg: string) => void; warn: (msg: }; } +function extractInstalledNpmPackageName(install: PluginInstallRecord): string | undefined { + if (install.source !== "npm") { + return undefined; + } + const resolvedName = install.resolvedName?.trim(); + if (resolvedName) { + return resolvedName; + } + return ( + (install.spec ? parseRegistryNpmSpec(install.spec)?.name : undefined) ?? + (install.resolvedSpec ? parseRegistryNpmSpec(install.resolvedSpec)?.name : undefined) + ); +} + +function resolvePluginUpdateSelection(params: { + installs: Record; + rawId?: string; + all?: boolean; +}): { pluginIds: string[]; specOverrides?: Record } { + if (params.all) { + return { pluginIds: Object.keys(params.installs) }; + } + if (!params.rawId) { + return { pluginIds: [] }; + } + + const parsedSpec = parseRegistryNpmSpec(params.rawId); + if (!parsedSpec || parsedSpec.selectorKind === "none") { + return { pluginIds: [params.rawId] }; + } + + const matches = Object.entries(params.installs).filter(([, install]) => { + return extractInstalledNpmPackageName(install) === parsedSpec.name; + }); + if (matches.length !== 1) { + return { pluginIds: [params.rawId] }; + } + + const [pluginId] = matches[0]; + if (!pluginId) { + return { pluginIds: [params.rawId] }; + } + return { + pluginIds: [pluginId], + specOverrides: { + [pluginId]: parsedSpec.raw, + }, + }; +} + function logSlotWarnings(warnings: string[]) { if (warnings.length === 0) { return; @@ -1032,7 +1083,12 @@ export function registerPluginsCli(program: Command) { .action(async (id: string | undefined, opts: PluginUpdateOptions) => { const cfg = loadConfig(); const installs = cfg.plugins?.installs ?? {}; - const targets = opts.all ? Object.keys(installs) : id ? [id] : []; + const selection = resolvePluginUpdateSelection({ + installs, + rawId: id, + all: opts.all, + }); + const targets = selection.pluginIds; if (targets.length === 0) { if (opts.all) { @@ -1046,6 +1102,7 @@ export function registerPluginsCli(program: Command) { const result = await updateNpmInstalledPlugins({ config: cfg, pluginIds: targets, + specOverrides: selection.specOverrides, dryRun: opts.dryRun, logger: { info: (msg) => defaultRuntime.log(msg), diff --git a/src/plugins/update.test.ts b/src/plugins/update.test.ts index 7e93ab7ba50..96c15443ded 100644 --- a/src/plugins/update.test.ts +++ b/src/plugins/update.test.ts @@ -161,6 +161,129 @@ describe("updateNpmInstalledPlugins", () => { ]); }); + it("reuses a recorded npm dist-tag spec for id-based updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + resolvedName: "openclaw-codex-app-server", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.3", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + }); + }); + + it("uses and persists an explicit npm spec override during updates", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + npmResolution: { + name: "openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + const result = await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@beta", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@beta", + expectedPluginId: "openclaw-codex-app-server", + }), + ); + expect(result.config.plugins?.installs?.["openclaw-codex-app-server"]).toMatchObject({ + source: "npm", + spec: "openclaw-codex-app-server@beta", + installPath: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + resolvedSpec: "openclaw-codex-app-server@0.2.0-beta.4", + }); + }); + + it("skips recorded integrity checks when an explicit npm version override changes the spec", async () => { + installPluginFromNpmSpecMock.mockResolvedValue({ + ok: true, + pluginId: "openclaw-codex-app-server", + targetDir: "/tmp/openclaw-codex-app-server", + version: "0.2.0-beta.4", + extensions: ["index.ts"], + }); + + await updateNpmInstalledPlugins({ + config: { + plugins: { + installs: { + "openclaw-codex-app-server": { + source: "npm", + spec: "openclaw-codex-app-server@0.2.0-beta.3", + integrity: "sha512-old", + installPath: "/tmp/openclaw-codex-app-server", + }, + }, + }, + }, + pluginIds: ["openclaw-codex-app-server"], + specOverrides: { + "openclaw-codex-app-server": "openclaw-codex-app-server@0.2.0-beta.4", + }, + }); + + expect(installPluginFromNpmSpecMock).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "openclaw-codex-app-server@0.2.0-beta.4", + expectedIntegrity: undefined, + }), + ); + }); + it("migrates legacy unscoped install keys when a scoped npm package updates", async () => { installPluginFromNpmSpecMock.mockResolvedValue({ ok: true, diff --git a/src/plugins/update.ts b/src/plugins/update.ts index 83733159cac..6898135e527 100644 --- a/src/plugins/update.ts +++ b/src/plugins/update.ts @@ -291,6 +291,7 @@ export async function updateNpmInstalledPlugins(params: { pluginIds?: string[]; skipIds?: Set; dryRun?: boolean; + specOverrides?: Record; onIntegrityDrift?: (params: PluginUpdateIntegrityDriftParams) => boolean | Promise; }): Promise { const logger = params.logger ?? {}; @@ -329,7 +330,14 @@ export async function updateNpmInstalledPlugins(params: { continue; } - if (record.source === "npm" && !record.spec) { + const effectiveSpec = + record.source === "npm" ? (params.specOverrides?.[pluginId] ?? record.spec) : undefined; + const expectedIntegrity = + record.source === "npm" && effectiveSpec === record.spec + ? expectedIntegrityForUpdate(record.spec, record.integrity) + : undefined; + + if (record.source === "npm" && !effectiveSpec) { outcomes.push({ pluginId, status: "skipped", @@ -371,11 +379,11 @@ export async function updateNpmInstalledPlugins(params: { probe = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", dryRun: true, expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: true, @@ -408,7 +416,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "check", result: probe, }) @@ -452,10 +460,10 @@ export async function updateNpmInstalledPlugins(params: { result = record.source === "npm" ? await installPluginFromNpmSpec({ - spec: record.spec!, + spec: effectiveSpec!, mode: "update", expectedPluginId: pluginId, - expectedIntegrity: expectedIntegrityForUpdate(record.spec, record.integrity), + expectedIntegrity, onIntegrityDrift: createPluginUpdateIntegrityDriftHandler({ pluginId, dryRun: false, @@ -487,7 +495,7 @@ export async function updateNpmInstalledPlugins(params: { record.source === "npm" ? formatNpmInstallFailure({ pluginId, - spec: record.spec!, + spec: effectiveSpec!, phase: "update", result: result, }) @@ -512,7 +520,7 @@ export async function updateNpmInstalledPlugins(params: { next = recordPluginInstall(next, { pluginId: resolvedPluginId, source: "npm", - spec: record.spec, + spec: effectiveSpec, installPath: result.targetDir, version: nextVersion, ...buildNpmResolutionInstallFields(result.npmResolution), From 3dfd8eef7f949b640f5f1e21cf7767578458ea46 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 09:55:43 -0700 Subject: [PATCH 038/137] ci(node22): drop duplicate config docs check from compat lane --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ab35a297e..8f87c816488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -496,7 +496,9 @@ jobs: run: pnpm test - name: Verify npm pack under Node 22 - run: pnpm release:check + run: | + node scripts/stage-bundled-plugin-runtime-deps.mjs + node --import tsx scripts/release-check.ts skills-python: needs: [docs-scope, changed-scope] From 36f394c299a91301a84c455be2bdb418eeb2d08e Mon Sep 17 00:00:00 2001 From: fuller-stack-dev Date: Thu, 19 Mar 2026 11:16:40 -0600 Subject: [PATCH 039/137] fix(gateway): increase WS handshake timeout from 3s to 10s (#49262) * fix(gateway): increase WS handshake timeout from 3s to 10s The 3-second default is too aggressive when the event loop is under load (concurrent sessions, compaction, agent turns), causing spurious 'gateway closed (1000)' errors on CLI commands like `openclaw cron list`. Changes: - Increase DEFAULT_HANDSHAKE_TIMEOUT_MS from 3_000 to 10_000 - Add OPENCLAW_HANDSHAKE_TIMEOUT_MS env var for user override (no VITEST gate) - Keep OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS as fallback for existing tests Fixes #46892 * fix: restore VITEST guard on test env var, use || for empty-string fallback, fix formatting * fix: cover gateway handshake timeout env override (#49262) (thanks @fuller-stack-dev) --------- Co-authored-by: Wilfred Co-authored-by: Ayaan Zaidi --- CHANGELOG.md | 1 + src/gateway/server-constants.ts | 10 +++++--- .../server.auth.default-token.suite.ts | 23 +++++++++++++++++++ 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a37dfe581c..43aff8bd18c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai - Slack/startup: harden `@slack/bolt` import interop across current bundled runtime shapes so Slack monitors no longer crash with `App is not a constructor` after plugin-sdk bundling changes. (#45953) Thanks @merc1305. - Windows/gateway status: accept `schtasks` `Last Result` output as an alias for `Last Run Result`, so running scheduled-task installs no longer show `Runtime: unknown`. (#47844) Thanks @MoerAI. - ACP/acpx: resolve the bundled plugin root from the actual plugin directory so plugin-local installs stay under `dist/extensions/acpx` instead of escaping to `dist/extensions` and failing runtime setup. (#47601) Thanks @ngutman. +- Gateway/WS handshake: raise the default pre-auth handshake timeout to 10 seconds and add `OPENCLAW_HANDSHAKE_TIMEOUT_MS` as a runtime override so busy local gateways stop dropping healthy CLI connections at 3 seconds. (#49262) Thanks @fuller-stack-dev. - Gateway/websocket pairing bypass for disabled auth: skip device-pairing enforcement for Control UI operator sessions when `gateway.auth.mode=none`, so reverse-proxied dashboards no longer get stuck on `pairing required` despite auth being explicitly disabled. (#47148) Thanks @ademczuk. - Control UI/model switching: preserve the selected provider prefix when switching models from the chat dropdown, so multi-provider setups no longer send `anthropic/gpt-5.2`-style mismatches when the user picked `openai/gpt-5.2`. (#47581) Thanks @chrishham. - Control UI/storage: scope persisted settings keys by gateway base path, with migration from the legacy shared key, so multiple gateways under one domain stop overwriting each other's dashboard preferences. (#47932) Thanks @bobBot-claw. diff --git a/src/gateway/server-constants.ts b/src/gateway/server-constants.ts index 036ebc5b3fa..54dc3f794b6 100644 --- a/src/gateway/server-constants.ts +++ b/src/gateway/server-constants.ts @@ -21,10 +21,14 @@ export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => { maxChatHistoryMessagesBytes = value; } }; -export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3_000; +export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000; export const getHandshakeTimeoutMs = () => { - if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) { - const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + // User-facing env var (works in all environments); test-only var gated behind VITEST + const envKey = + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS || + (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS); + if (envKey) { + const parsed = Number(envKey); if (Number.isFinite(parsed) && parsed > 0) { return parsed; } diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts index 4d090b78cb3..ed15150a029 100644 --- a/src/gateway/server.auth.default-token.suite.ts +++ b/src/gateway/server.auth.default-token.suite.ts @@ -93,6 +93,29 @@ export function registerDefaultAuthTokenSuite(): void { } }); + test("prefers OPENCLAW_HANDSHAKE_TIMEOUT_MS and falls back on empty string", () => { + const prevHandshakeTimeout = process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; + const prevTestHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = "75"; + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20"; + try { + expect(getHandshakeTimeoutMs()).toBe(75); + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = ""; + expect(getHandshakeTimeoutMs()).toBe(20); + } finally { + if (prevHandshakeTimeout === undefined) { + delete process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; + } + if (prevTestHandshakeTimeout === undefined) { + delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = prevTestHandshakeTimeout; + } + } + }); + test("connect (req) handshake returns hello-ok payload", async () => { const { STATE_DIR, createConfigIO } = await import("../config/config.js"); const ws = await openWs(port); From 65a2917c8f741b464e3d883a104c2422a1aa4b95 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:27:41 -0700 Subject: [PATCH 040/137] docs: remove pi-mono jargon, fix features list, update Perplexity config path --- docs/concepts/agent.md | 15 ++++++--------- docs/concepts/features.md | 7 +------ docs/providers/perplexity-provider.md | 10 ++++++++-- 3 files changed, 15 insertions(+), 17 deletions(-) diff --git a/docs/concepts/agent.md b/docs/concepts/agent.md index 26d677745e4..57aff200e04 100644 --- a/docs/concepts/agent.md +++ b/docs/concepts/agent.md @@ -1,13 +1,13 @@ --- -summary: "Agent runtime (embedded pi-mono), workspace contract, and session bootstrap" +summary: "Agent runtime, workspace contract, and session bootstrap" read_when: - Changing agent runtime, workspace bootstrap, or session behavior title: "Agent Runtime" --- -# Agent Runtime 🤖 +# Agent Runtime -OpenClaw runs a single embedded agent runtime derived from **pi-mono**. +OpenClaw runs a single embedded agent runtime. ## Workspace (required) @@ -63,12 +63,9 @@ OpenClaw loads skills from three locations (workspace wins on name conflict): Skills can be gated by config/env (see `skills` in [Gateway configuration](/gateway/configuration)). -## pi-mono integration +## Runtime boundaries -OpenClaw reuses pieces of the pi-mono codebase (models/tools), but **session management, discovery, and tool wiring are OpenClaw-owned**. - -- No pi-coding agent runtime. -- No `~/.pi/agent` or `/.pi` settings are consulted. +Session management, discovery, and tool wiring are OpenClaw-owned. ## Sessions @@ -77,7 +74,7 @@ Session transcripts are stored as JSONL at: - `~/.openclaw/agents//sessions/.jsonl` The session ID is stable and chosen by OpenClaw. -Legacy Pi/Tau session folders are **not** read. +Legacy session folders from other tools are not read. ## Steering while streaming diff --git a/docs/concepts/features.md b/docs/concepts/features.md index 03528032b40..47e0d804c5d 100644 --- a/docs/concepts/features.md +++ b/docs/concepts/features.md @@ -37,7 +37,7 @@ title: "Features" - Discord bot support (channels.discord.js) - Mattermost bot support (plugin) - iMessage integration via local imsg CLI (macOS) -- Agent bridge for Pi in RPC mode with tool streaming +- Embedded agent runtime with tool streaming - Streaming and chunking for long responses - Multi-agent routing for isolated sessions per workspace or sender - Subscription auth for Anthropic and OpenAI via OAuth @@ -48,8 +48,3 @@ title: "Features" - WebChat and macOS menu bar app - iOS node with pairing, Canvas, camera, screen recording, location, and voice features - Android node with pairing, Connect tab, chat sessions, voice tab, Canvas/camera, plus device, notifications, contacts/calendar, motion, photos, and SMS commands - - -Legacy Claude, Codex, Gemini, and Opencode paths have been removed. Pi is the only -coding agent path. - diff --git a/docs/providers/perplexity-provider.md b/docs/providers/perplexity-provider.md index c0945627e39..63880385353 100644 --- a/docs/providers/perplexity-provider.md +++ b/docs/providers/perplexity-provider.md @@ -18,14 +18,20 @@ This page covers the Perplexity **provider** setup. For the Perplexity - Type: web search provider (not a model provider) - Auth: `PERPLEXITY_API_KEY` (direct) or `OPENROUTER_API_KEY` (via OpenRouter) -- Config path: `tools.web.search.perplexity.apiKey` +- Config path: `plugins.entries.perplexity.config.webSearch.apiKey` ## Quick start 1. Set the API key: ```bash -openclaw config set tools.web.search.perplexity.apiKey "pplx-xxxxxxxxxxxx" +openclaw configure --section web +``` + +Or set it directly: + +```bash +openclaw config set plugins.entries.perplexity.config.webSearch.apiKey "pplx-xxxxxxxxxxxx" ``` 2. The agent will automatically use Perplexity for web searches when configured. From 1dd857f6a6a43b0f47999ec0b8d9021e1c009909 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:28:44 -0700 Subject: [PATCH 041/137] docs: add API key prereq, first-message step, fix landing page quick start --- docs/index.md | 12 +++++++---- docs/start/getting-started.md | 39 +++++++++++++---------------------- 2 files changed, 22 insertions(+), 29 deletions(-) diff --git a/docs/index.md b/docs/index.md index 25162bc9676..270f0835287 100644 --- a/docs/index.md +++ b/docs/index.md @@ -106,15 +106,19 @@ The Gateway is the single source of truth for sessions, routing, and channel con openclaw onboard --install-daemon ``` - + + Open the Control UI in your browser and send a message: + ```bash - openclaw channels login - openclaw gateway --port 18789 + openclaw dashboard ``` + + Or connect a channel ([Telegram](/channels/telegram) is fastest) and chat from your phone. + -Need the full install and dev setup? See [Quick start](/start/quickstart). +Need the full install and dev setup? See [Getting Started](/start/getting-started). ## Dashboard diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index bd3f554cdc4..fa719093739 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -20,9 +20,11 @@ Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui). ## Prereqs - Node 24 recommended (Node 22 LTS, currently `22.16+`, still supported for compatibility) +- An API key from a model provider (Anthropic, OpenAI, Google, etc.) — onboarding will prompt you for this Check your Node version with `node --version` if you are unsure. +Windows users: WSL2 is strongly recommended. See [Windows](/platforms/windows). ## Quick setup (CLI) @@ -73,34 +75,21 @@ Check your Node version with `node --version` if you are unsure. ```bash openclaw dashboard ``` + + If the Control UI loads, your Gateway is ready. + + + + The fastest way to chat is directly in the Control UI browser tab. + Type a message and you should get an AI reply. + + Want to chat from a messaging app instead? The fastest channel setup + is usually [Telegram](/channels/telegram) (just a bot token, no QR + pairing). See [Channels](/channels) for all options. + - -If the Control UI loads, your Gateway is ready for use. - - -## Optional checks and extras - - - - Useful for quick tests or troubleshooting. - - ```bash - openclaw gateway --port 18789 - ``` - - - - Requires a configured channel. - - ```bash - openclaw message send --target +15555550123 --message "Hello from OpenClaw" - ``` - - - - ## Useful environment variables If you run OpenClaw as a service account or want custom config/state locations: From 624d5365513eb230545ac2c5ef9270f69025dcf5 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:29:57 -0700 Subject: [PATCH 042/137] docs: remove quickstart stub from hubs, add redirect to getting-started --- docs/docs.json | 11 ++++++++++- docs/start/hubs.md | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index e80697ac63d..772a8a476cd 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -47,6 +47,10 @@ ] }, "redirects": [ + { + "source": "/start/quickstart", + "destination": "/start/getting-started" + }, { "source": "/messages", "destination": "/concepts/messages" @@ -880,6 +884,7 @@ "group": "Hosting and deployment", "pages": [ "vps", + "install/docker-vm-runtime", "install/kubernetes", "install/fly", "install/hetzner", @@ -1024,7 +1029,8 @@ "pages": [ "tools/browser", "tools/browser-login", - "tools/browser-linux-troubleshooting" + "tools/browser-linux-troubleshooting", + "tools/browser-wsl2-windows-remote-cdp-troubleshooting" ] }, { @@ -1211,6 +1217,7 @@ "gateway/heartbeat", "gateway/doctor", "gateway/logging", + "logging", "gateway/gateway-lock", "gateway/background-process", "gateway/multiple-gateways", @@ -1241,6 +1248,7 @@ { "group": "Networking and discovery", "pages": [ + "network", "gateway/network-model", "gateway/pairing", "gateway/discovery", @@ -1278,6 +1286,7 @@ "cli/agent", "cli/agents", "cli/approvals", + "cli/backup", "cli/browser", "cli/channels", "cli/clawbot", diff --git a/docs/start/hubs.md b/docs/start/hubs.md index 260ec771de1..7e530f769b5 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -17,7 +17,6 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Index](/) - [Getting Started](/start/getting-started) -- [Quick start](/start/quickstart) - [Onboarding](/start/onboarding) - [Onboarding (CLI)](/start/wizard) - [Setup](/start/setup) From 0b11ee48f81daa087b335e134a4b7f948ae6534e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Thu, 19 Mar 2026 10:31:20 -0700 Subject: [PATCH 043/137] docs: fix 26 broken anchor links across 18 files --- docs/automation/hooks.md | 2 +- docs/channels/groups.md | 2 +- docs/channels/matrix.md | 2 ++ docs/channels/signal.md | 2 +- docs/channels/troubleshooting.md | 4 ++-- docs/concepts/memory.md | 2 +- docs/concepts/messages.md | 2 +- docs/concepts/models.md | 6 +++--- docs/concepts/oauth.md | 2 +- docs/gateway/configuration.md | 6 +++--- docs/gateway/sandboxing.md | 2 +- docs/gateway/security/index.md | 4 ++-- docs/help/environment.md | 2 +- docs/help/faq.md | 22 +++++++++++----------- docs/help/index.md | 2 +- docs/install/docker.md | 2 +- docs/providers/anthropic.md | 4 ++-- docs/tools/browser.md | 2 +- docs/tools/multi-agent-sandbox-tools.md | 2 +- 19 files changed, 37 insertions(+), 35 deletions(-) diff --git a/docs/automation/hooks.md b/docs/automation/hooks.md index a470bef8540..4d7dbd02533 100644 --- a/docs/automation/hooks.md +++ b/docs/automation/hooks.md @@ -1046,4 +1046,4 @@ node -e "import('./path/to/handler.ts').then(console.log)" - [CLI Reference: hooks](/cli/hooks) - [Bundled Hooks README](https://github.com/openclaw/openclaw/tree/main/src/hooks/bundled) - [Webhook Hooks](/automation/webhook) -- [Configuration](/gateway/configuration#hooks) +- [Configuration](/gateway/configuration-reference#hooks) diff --git a/docs/channels/groups.md b/docs/channels/groups.md index a6bd8621784..8895cdd18f9 100644 --- a/docs/channels/groups.md +++ b/docs/channels/groups.md @@ -116,7 +116,7 @@ Want “groups can only see folder X” instead of “no host access”? Keep `w Related: -- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) +- Configuration keys and defaults: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox) - Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) - Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index d6ec40ff4db..360bc706748 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -204,6 +204,8 @@ Bootstrap cross-signing and verification state: openclaw matrix verify bootstrap ``` +Multi-account support: use `channels.matrix.accounts` with per-account credentials and optional `name`. See [Configuration reference](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern. + Verbose bootstrap diagnostics: ```bash diff --git a/docs/channels/signal.md b/docs/channels/signal.md index cfc050b6e75..fb5747dc417 100644 --- a/docs/channels/signal.md +++ b/docs/channels/signal.md @@ -99,7 +99,7 @@ Example: } ``` -Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. +Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration-reference#multi-account-all-channels) for the shared pattern. ## Setup path B: register dedicated bot number (SMS, Linux) diff --git a/docs/channels/troubleshooting.md b/docs/channels/troubleshooting.md index a7850801948..106710ca926 100644 --- a/docs/channels/troubleshooting.md +++ b/docs/channels/troubleshooting.md @@ -38,7 +38,7 @@ Healthy baseline: | Group messages ignored | Check `requireMention` + mention patterns in config | Mention the bot or relax mention policy for that group. | | Random disconnect/relogin loops | `openclaw channels status --probe` + logs | Re-login and verify credentials directory is healthy. | -Full troubleshooting: [/channels/whatsapp#troubleshooting-quick](/channels/whatsapp#troubleshooting-quick) +Full troubleshooting: [/channels/whatsapp#troubleshooting](/channels/whatsapp#troubleshooting) ## Telegram @@ -90,7 +90,7 @@ Full troubleshooting: [/channels/slack#troubleshooting](/channels/slack#troubles Full troubleshooting: -- [/channels/imessage#troubleshooting-macos-privacy-and-security-tcc](/channels/imessage#troubleshooting-macos-privacy-and-security-tcc) +- [/channels/imessage#troubleshooting](/channels/imessage#troubleshooting) - [/channels/bluebubbles#troubleshooting](/channels/bluebubbles#troubleshooting) ## Signal diff --git a/docs/concepts/memory.md b/docs/concepts/memory.md index 2649125dc45..e020d4a9a49 100644 --- a/docs/concepts/memory.md +++ b/docs/concepts/memory.md @@ -208,7 +208,7 @@ out to QMD for retrieval. Key points: `commandTimeoutMs`, `updateTimeoutMs`, `embedTimeoutMs`). - `limits`: clamp recall payload (`maxResults`, `maxSnippetChars`, `maxInjectedChars`, `timeoutMs`). -- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration#session). +- `scope`: same schema as [`session.sendPolicy`](/gateway/configuration-reference#session). Default is DM-only (`deny` all, `allow` direct chats); loosen it to surface QMD hits in groups/channels. - `match.keyPrefix` matches the **normalized** session key (lowercased, with any diff --git a/docs/concepts/messages.md b/docs/concepts/messages.md index 4930002187e..e94092e7bbc 100644 --- a/docs/concepts/messages.md +++ b/docs/concepts/messages.md @@ -151,4 +151,4 @@ Outbound message formatting is centralized in `messages`: - `messages.responsePrefix`, `channels..responsePrefix`, and `channels..accounts..responsePrefix` (outbound prefix cascade), plus `channels.whatsapp.messagePrefix` (WhatsApp inbound prefix) - Reply threading via `replyToMode` and per-channel defaults -Details: [Configuration](/gateway/configuration#messages) and channel docs. +Details: [Configuration](/gateway/configuration-reference#messages) and channel docs. diff --git a/docs/concepts/models.md b/docs/concepts/models.md index 0a32e1b5d8b..d9a76cabc64 100644 --- a/docs/concepts/models.md +++ b/docs/concepts/models.md @@ -58,7 +58,7 @@ Model refs are normalized to lowercase. Provider aliases like `z.ai/*` normalize to `zai/*`. Provider configuration examples (including OpenCode) live in -[/gateway/configuration](/gateway/configuration#opencode). +[/providers/opencode](/providers/opencode). ## "Model is not allowed" (and why replies stop) @@ -82,9 +82,9 @@ Example allowlist config: ```json5 { agent: { - model: { primary: "anthropic/claude-sonnet-4-5" }, + model: { primary: "anthropic/claude-sonnet-4-6" }, models: { - "anthropic/claude-sonnet-4-5": { alias: "Sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "anthropic/claude-opus-4-6": { alias: "Opus" }, }, }, diff --git a/docs/concepts/oauth.md b/docs/concepts/oauth.md index 4766687ad51..2589dcaa8f9 100644 --- a/docs/concepts/oauth.md +++ b/docs/concepts/oauth.md @@ -50,7 +50,7 @@ Legacy import-only file (still supported, but not the main store): - `~/.openclaw/credentials/oauth.json` (imported into `auth-profiles.json` on first use) -All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration#auth-storage-oauth--api-keys) +All of the above also respect `$OPENCLAW_STATE_DIR` (state dir override). Full reference: [/gateway/configuration](/gateway/configuration-reference#auth-storage) For static secret refs and runtime snapshot activation behavior, see [Secrets Management](/gateway/secrets). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index b8977ca10ac..42977c2b6f1 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -112,11 +112,11 @@ When validation fails: agents: { defaults: { model: { - primary: "anthropic/claude-sonnet-4-5", + primary: "anthropic/claude-sonnet-4-6", fallbacks: ["openai/gpt-5.2"], }, models: { - "anthropic/claude-sonnet-4-5": { alias: "Sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "Sonnet" }, "openai/gpt-5.2": { alias: "GPT" }, }, }, @@ -251,7 +251,7 @@ When validation fails: Build the image first: `scripts/sandbox-setup.sh` - See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#sandbox) for all options. + See [Sandboxing](/gateway/sandboxing) for the full guide and [full reference](/gateway/configuration-reference#agents-defaults-sandbox) for all options. diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index 736dc7c6261..12650357724 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -463,7 +463,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden ## Related docs - [OpenShell](/gateway/openshell) -- managed sandbox backend setup, workspace modes, and config reference -- [Sandbox Configuration](/gateway/configuration#agentsdefaults-sandbox) +- [Sandbox Configuration](/gateway/configuration-reference#agents-defaults-sandbox) - [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) -- debugging "why is this blocked?" - [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) -- per-agent overrides and precedence - [Security](/gateway/security) diff --git a/docs/gateway/security/index.md b/docs/gateway/security/index.md index 8cea1b42766..26cfbc4d6df 100644 --- a/docs/gateway/security/index.md +++ b/docs/gateway/security/index.md @@ -5,7 +5,7 @@ read_when: title: "Security" --- -# Security 🔒 +# Security > [!WARNING] > **Personal assistant trust model:** this guidance assumes one trusted operator boundary per gateway (single-user/personal assistant model). @@ -25,7 +25,7 @@ This page explains hardening **within that model**. It does not claim hostile mu ## Quick check: `openclaw security audit` -See also: [Formal Verification (Security Models)](/security/formal-verification/) +See also: [Formal Verification (Security Models)](/security/formal-verification) Run this regularly (especially after changing config or exposing network surfaces): diff --git a/docs/help/environment.md b/docs/help/environment.md index 860129bde37..45faad7c66c 100644 --- a/docs/help/environment.md +++ b/docs/help/environment.md @@ -90,7 +90,7 @@ You can reference env vars directly in config string values using `${VAR_NAME}` } ``` -See [Configuration: Env var substitution](/gateway/configuration#env-var-substitution-in-config) for full details. +See [Configuration: Env var substitution](/gateway/configuration-reference#env-var-substitution) for full details. ## Secret refs vs `${ENV}` strings diff --git a/docs/help/faq.md b/docs/help/faq.md index 5e892da6a7b..9122af6119e 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -13,7 +13,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ## Table of contents - [Quick start and first-run setup] - - [I am stuck - fastest way to get unstuck](#i-am-stuck---fastest-way-to-get-unstuck) + - [I am stuck - fastest way to get unstuck](#i-am-stuck-fastest-way-to-get-unstuck) - [Recommended way to install and set up OpenClaw](#recommended-way-to-install-and-set-up-openclaw) - [How do I open the dashboard after onboarding?](#how-do-i-open-the-dashboard-after-onboarding) - [How do I authenticate the dashboard (token) on localhost vs remote?](#how-do-i-authenticate-the-dashboard-token-on-localhost-vs-remote) @@ -449,7 +449,7 @@ section is the latest shipped version. Entries are grouped by **Highlights**, ** Some Comcast/Xfinity connections incorrectly block `docs.openclaw.ai` via Xfinity Advanced Security. Disable it or allowlist `docs.openclaw.ai`, then retry. More -detail: [Troubleshooting](/help/troubleshooting#docsopenclawai-shows-an-ssl-error-comcastxfinity). +detail: [Troubleshooting](/help/faq#docsopenclawai-shows-an-ssl-error-comcast-xfinity). Please help us unblock it by reporting here: [https://spa.xfinity.com/check_url_status](https://spa.xfinity.com/check_url_status). If you still can't reach the site, the docs are mirrored on GitHub: @@ -497,7 +497,7 @@ Rough guide: - **Onboarding:** 5-15 minutes depending on how many channels/models you configure If it hangs, use [Installer stuck](/help/faq#installer-stuck-how-do-i-get-more-feedback) -and the fast debug loop in [I am stuck](/help/faq#i-am-stuck---fastest-way-to-get-unstuck). +and the fast debug loop in [I am stuck](/help/faq#i-am-stuck-fastest-way-to-get-unstuck). ### How do I try the latest bits @@ -858,7 +858,7 @@ Third-party (less private): - DM `@userinfobot` or `@getidsbot`. -See [/channels/telegram](/channels/telegram#access-control-dms--groups). +See [/channels/telegram](/channels/telegram#access-control-and-activation). ### Can multiple people use one WhatsApp number with different OpenClaw instances @@ -1259,7 +1259,7 @@ Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-ma Setup walkthrough + example config: [Groups: personal DMs + public groups](/channels/groups#pattern-personal-dms-public-groups-single-agent) -Key config reference: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox) +Key config reference: [Gateway configuration](/gateway/configuration-reference#agents-defaults-sandbox) ### How do I bind a host folder into the sandbox @@ -2293,7 +2293,7 @@ Aliases come from `agents.defaults.models..alias`. Example: model: { primary: "anthropic/claude-opus-4-6" }, models: { "anthropic/claude-opus-4-6": { alias: "opus" }, - "anthropic/claude-sonnet-4-5": { alias: "sonnet" }, + "anthropic/claude-sonnet-4-6": { alias: "sonnet" }, "anthropic/claude-haiku-4-5": { alias: "haiku" }, }, }, @@ -2311,8 +2311,8 @@ OpenRouter (pay-per-token; many models): { agents: { defaults: { - model: { primary: "openrouter/anthropic/claude-sonnet-4-5" }, - models: { "openrouter/anthropic/claude-sonnet-4-5": {} }, + model: { primary: "openrouter/anthropic/claude-sonnet-4-6" }, + models: { "openrouter/anthropic/claude-sonnet-4-6": {} }, }, }, env: { OPENROUTER_API_KEY: "sk-or-..." }, @@ -2635,7 +2635,7 @@ Service/supervisor logs (when the gateway runs via launchd/systemd): - Linux: `journalctl --user -u openclaw-gateway[-].service -n 200 --no-pager` - Windows: `schtasks /Query /TN "OpenClaw Gateway ()" /V /FO LIST` -See [Troubleshooting](/gateway/troubleshooting#log-locations) for more. +See [Troubleshooting](/gateway/troubleshooting) for more. ### How do I start/stop/restart the Gateway service @@ -2917,7 +2917,7 @@ If it is still noisy, check the session settings in the Control UI and set verbo to **inherit**. Also confirm you are not using a bot profile with `verboseDefault` set to `on` in config. -Docs: [Thinking and verbose](/tools/thinking), [Security](/gateway/security#reasoning--verbose-output-in-groups). +Docs: [Thinking and verbose](/tools/thinking), [Security](/gateway/security#reasoning-verbose-output-in-groups). ### How do I stopcancel a running task @@ -3000,7 +3000,7 @@ You can add options like `debounce:2s cap:25 drop:summarize` for followup modes. **Q: "What's the default model for Anthropic with an API key?"** -**A:** In OpenClaw, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-5` or `anthropic/claude-opus-4-6`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn't find Anthropic credentials in the expected `auth-profiles.json` for the agent that's running. +**A:** In OpenClaw, credentials and model selection are separate. Setting `ANTHROPIC_API_KEY` (or storing an Anthropic API key in auth profiles) enables authentication, but the actual default model is whatever you configure in `agents.defaults.model.primary` (for example, `anthropic/claude-sonnet-4-6` or `anthropic/claude-opus-4-6`). If you see `No credentials found for profile "anthropic:default"`, it means the Gateway couldn't find Anthropic credentials in the expected `auth-profiles.json` for the agent that's running. --- diff --git a/docs/help/index.md b/docs/help/index.md index 80aa5d304e8..5d0942909b6 100644 --- a/docs/help/index.md +++ b/docs/help/index.md @@ -11,7 +11,7 @@ title: "Help" If you want a quick “get unstuck” flow, start here: - **Troubleshooting:** [Start here](/help/troubleshooting) -- **Install sanity (Node/npm/PATH):** [Install](/install#nodejs--npm-path-sanity) +- **Install sanity (Node/npm/PATH):** [Install](/install/node#troubleshooting) - **Gateway issues:** [Gateway troubleshooting](/gateway/troubleshooting) - **Logs:** [Logging](/logging) and [Gateway logging](/gateway/logging) - **Repairs:** [Doctor](/gateway/doctor) diff --git a/docs/install/docker.md b/docs/install/docker.md index f4913a5138a..f80d0809fc8 100644 --- a/docs/install/docker.md +++ b/docs/install/docker.md @@ -29,7 +29,7 @@ Sandboxing details: [Sandboxing](/gateway/sandboxing) - At least 2 GB RAM for image build (`pnpm install` may be OOM-killed on 1 GB hosts with exit 137) - Enough disk for images + logs - If running on a VPS/public host, review - [Security hardening for network exposure](/gateway/security#04-network-exposure-bind--port--firewall), + [Security hardening for network exposure](/gateway/security#0-4-network-exposure-bind-port-firewall), especially Docker `DOCKER-USER` firewall policy. ## Containerized Gateway (Docker Compose) diff --git a/docs/providers/anthropic.md b/docs/providers/anthropic.md index d16d76f6315..a1f2e212463 100644 --- a/docs/providers/anthropic.md +++ b/docs/providers/anthropic.md @@ -57,7 +57,7 @@ OpenClaw's shared `/fast` toggle also supports direct Anthropic API-key traffic. agents: { defaults: { models: { - "anthropic/claude-sonnet-4-5": { + "anthropic/claude-sonnet-4-6": { params: { fastMode: true }, }, }, @@ -228,7 +228,7 @@ openclaw onboard --auth-choice setup-token ## Notes - Generate the setup-token with `claude setup-token` and paste it, or run `openclaw models auth setup-token` on the gateway host. -- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token. See [/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription](/gateway/troubleshooting#oauth-token-refresh-failed-anthropic-claude-subscription). +- If you see “OAuth token refresh failed …” on a Claude subscription, re-auth with a setup-token. See [/gateway/troubleshooting](/gateway/troubleshooting). - Auth details + reuse rules are in [/concepts/oauth](/concepts/oauth). ## Troubleshooting diff --git a/docs/tools/browser.md b/docs/tools/browser.md index dc044450742..4797bc7409b 100644 --- a/docs/tools/browser.md +++ b/docs/tools/browser.md @@ -581,7 +581,7 @@ Notes: - `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref=""`). - `--format aria`: returns the accessibility tree (no refs; inspection only). - `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars). - - Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration#browser-openclaw-managed-browser)). + - Config default (tool/CLI only): set `browser.snapshotDefaults.mode: "efficient"` to use efficient snapshots when the caller does not pass a mode (see [Gateway configuration](/gateway/configuration-reference#browser)). - Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`. - `--frame "