Compare commits

..

2 Commits

Author SHA1 Message Date
Nimrod Gutman
d31c59fedc test(plugins): avoid literal duplicate import specifier 2026-03-19 15:05:52 +02:00
Nimrod Gutman
e530563375 fix(plugins): share command registry across module graphs 2026-03-19 14:50:48 +02:00
90 changed files with 461 additions and 1868 deletions

View File

@@ -21,14 +21,10 @@ runs:
run: |
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
else
# Use the exact base SHA from the event payload — stable regardless
# of base branch movement (avoids origin/<ref> drift).
BASE="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
# Fail-safe: if we can't diff, assume non-docs (run everything)

View File

@@ -4,7 +4,6 @@ on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -32,8 +31,8 @@ jobs:
- name: Ensure docs-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect docs-only changes
id: check
@@ -62,8 +61,8 @@ jobs:
- name: Ensure changed-scope base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Detect changed scopes
id: scope
@@ -73,12 +72,8 @@ jobs:
if [ "${{ github.event_name }}" = "push" ]; then
BASE="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
BASE="${{ github.event.pull_request.base.sha }}"
fi
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
@@ -101,8 +96,8 @@ jobs:
- name: Ensure changed-extensions base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -113,31 +108,14 @@ jobs:
- name: Detect changed extensions
id: changed
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE_SHA="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE_SHA="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
export BASE_SHA
node --input-type=module <<'EOF'
import { appendFileSync } from "node:fs";
import {
listAvailableExtensionIds,
listChangedExtensionIds,
} from "./scripts/test-extension.mjs";
import { listChangedExtensionIds } from "./scripts/test-extension.mjs";
const baseSha = process.env.BASE_SHA?.trim();
const extensionIds = baseSha
? listChangedExtensionIds({ base: baseSha, head: "HEAD" })
: listAvailableExtensionIds();
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
@@ -557,8 +535,8 @@ jobs:
- name: Ensure secrets base commit
uses: ./.github/actions/ensure-base-commit
with:
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
base-sha: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
@@ -593,19 +571,11 @@ jobs:
run: pre-commit run --all-files detect-private-key
- name: Audit changed GitHub workflows with zizmor
env:
BASE_SHA: ${{ github.event_name == 'push' && github.event.before || github.event.pull_request.base.sha }}
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "push" ]; then
BASE_SHA="${{ github.event.before }}"
elif [ "${{ github.event_name }}" = "pull_request" ]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
else
DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
git fetch --no-tags --depth=50 origin "${DEFAULT_BRANCH}" || true
BASE_SHA="$(git merge-base HEAD "origin/${DEFAULT_BRANCH}" 2>/dev/null || true)"
fi
if [ -z "${BASE_SHA:-}" ] || [ "${BASE_SHA}" = "0000000000000000000000000000000000000000" ]; then
echo "No usable base SHA detected; skipping zizmor."
exit 0

View File

@@ -47,10 +47,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Hooks/Windows: preserve Windows-aware hook path handling across plugin-managed hook loading and bundle MCP config resolution, so path aliases and canonicalization differences no longer drop hook metadata or break bundled MCP launches.
- Outbound/channels: skip full configured-channel scans when explicit channel selection already determines the target, so explicit sends and broadcasts avoid slow unrelated plugin configuration checks.
- Tlon/install: fetch `@tloncorp/api` from the pinned HTTPS tarball artifact instead of a Git transport URL so installs no longer depend on GitHub SSH access.
- WhatsApp/install: fetch the Baileys `libsignal` dependency from a pinned GitHub HTTPS tarball so frozen installs and Windows CI no longer depend on `git@github.com` access.
- CLI/Ollama onboarding: keep the interactive model picker for explicit `openclaw onboard --auth-choice ollama` runs so setup still selects a default model without reintroducing pre-picker auto-pulls. (#49249) Thanks @BruceMacD.
- Plugins/bundler TDZ: fix `RESERVED_COMMANDS` temporal dead zone error that prevented device-pair, phone-control, and talk-voice plugins from registering when the bundler placed the commands module after call sites in the same output chunk. Thanks @BunsDev.
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.

View File

@@ -9,7 +9,6 @@ struct ExecApprovalEvaluation {
let env: [String: String]
let resolution: ExecCommandResolution?
let allowlistResolutions: [ExecCommandResolution]
let allowAlwaysPatterns: [String]
let allowlistMatches: [ExecAllowlistEntry]
let allowlistSatisfied: Bool
let allowlistMatch: ExecAllowlistEntry?
@@ -32,16 +31,9 @@ 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: allowlistRawCommand,
cwd: cwd,
env: env)
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
command: command,
rawCommand: rawCommand,
cwd: cwd,
env: env)
let allowlistMatches = security == .allowlist
@@ -68,7 +60,6 @@ enum ExecApprovalEvaluator {
env: env,
resolution: allowlistResolutions.first,
allowlistResolutions: allowlistResolutions,
allowAlwaysPatterns: allowAlwaysPatterns,
allowlistMatches: allowlistMatches,
allowlistSatisfied: allowlistSatisfied,
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,

View File

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

View File

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

View File

@@ -12,24 +12,14 @@ 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 {
@@ -38,7 +28,6 @@ enum ExecEnvInvocationUnwrapper {
}
if expectsOptionValue {
expectsOptionValue = false
usesModifiers = true
idx += 1
continue
}
@@ -47,7 +36,6 @@ enum ExecEnvInvocationUnwrapper {
break
}
if self.isEnvAssignment(token) {
usesModifiers = true
idx += 1
continue
}
@@ -55,12 +43,10 @@ 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
}
@@ -77,7 +63,6 @@ enum ExecEnvInvocationUnwrapper {
lower.hasPrefix("--ignore-signal=") ||
lower.hasPrefix("--block-signal=")
{
usesModifiers = true
idx += 1
continue
}
@@ -85,8 +70,8 @@ enum ExecEnvInvocationUnwrapper {
}
break
}
guard !expectsOptionValue, idx < command.count else { return nil }
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
guard idx < command.count else { return nil }
return Array(command[idx...])
}
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
@@ -99,13 +84,10 @@ enum ExecEnvInvocationUnwrapper {
guard ExecCommandToken.basenameLower(token) == "env" else {
break
}
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
break
}
if unwrapped.usesModifiers {
break
}
current = unwrapped.command
current = unwrapped
depth += 1
}
return current

View File

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

View File

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

View File

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

View File

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

View File

@@ -240,7 +240,7 @@ struct ExecAllowlistTests {
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
@Test func `resolve for allowlist unwraps 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/env")
#expect(resolutions[0].executableName == "env")
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
#expect(resolutions[0].executableName == "touch")
}
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
let resolutions = ExecCommandResolution.resolveForAllowlist(
command: command,
@@ -260,33 +260,8 @@ struct ExecAllowlistTests {
cwd: nil,
env: ["PATH": "/usr/bin:/bin"])
#expect(resolutions.count == 1)
#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"])
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
#expect(resolutions[0].executableName == "printf")
}
@Test func `match all requires every segment to match`() {

View File

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

View File

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

View File

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

View File

@@ -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 "openclaw/plugin-sdk/googlechat";
export * from "../../src/plugin-sdk/googlechat.js";

View File

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

View File

@@ -1,19 +1,3 @@
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";
export {
createMatrixThreadBindingManager,
resetMatrixThreadBindingsForTests,
} from "./src/matrix/thread-bindings.js";
export { setMatrixRuntime } from "./src/runtime.js";
export * from "./helper-api.js";

View File

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

View File

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

View File

@@ -15,8 +15,8 @@ import {
createTextPairingAdapter,
listResolvedDirectoryEntriesFromSources,
} from "openclaw/plugin-sdk/channel-runtime";
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
import {
buildChannelConfigSchema,
buildProbeChannelStatusSummary,
@@ -47,6 +47,7 @@ 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)
@@ -189,6 +190,7 @@ function matchMatrixAcpConversation(params: {
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
id: "matrix",
meta,
setupWizard: matrixSetupWizard,
pairing: createTextPairingAdapter({
idLabel: "matrixUserId",
message: PAIRING_APPROVED_MESSAGE,

View File

@@ -521,9 +521,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 = console.log.mock.calls.at(-1)?.[0];
expect(typeof jsonOutput).toBe("string");
expect(JSON.parse(String(jsonOutput))).toEqual(
expect.objectContaining({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../../../src/config/config.js";
import {
createTestRegistry,
type OpenClawConfig,
resolveAgentRoute,
__testing as sessionBindingTesting,
registerSessionBindingAdapter,
sessionBindingTesting,
setActivePluginRegistry,
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
} 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";
import { matrixPlugin } from "../../channel.js";
import { resolveMatrixInboundRoute } from "./route.js";

View File

@@ -222,10 +222,7 @@ 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(async () => new Response(payload, { status: 200 }));
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const client = new MatrixClient("https://matrix.example.org", "token");

View File

@@ -4,7 +4,6 @@ import { EventEmitter } from "node:events";
import {
ClientEvent,
MatrixEventEvent,
Preset,
createClient as createMatrixJsClient,
type MatrixClient as MatrixJsClient,
type MatrixEvent,
@@ -350,9 +349,7 @@ export class MatrixClient {
}
hasPersistedSyncState(): boolean {
// 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;
return this.syncStore?.hasSavedSync() === true;
}
private async ensureStartedForCryptoControlPlane(): Promise<void> {
@@ -369,7 +366,6 @@ export class MatrixClient {
}
this.decryptBridge.stop();
// Final persist on shutdown
this.syncStore?.markCleanShutdown();
this.stopPersistPromise = Promise.all([
persistIdbToDisk({
snapshotPath: this.idbSnapshotPath,
@@ -551,7 +547,7 @@ export class MatrixClient {
const result = await this.client.createRoom({
invite: [remoteUserId],
is_direct: true,
preset: Preset.TrustedPrivateChat,
preset: "trusted_private_chat",
initial_state: initialState,
});
return result.room_id;

View File

@@ -621,6 +621,14 @@ export async function createMatrixThreadBindingManager(params: {
});
return record ? toSessionBindingRecord(record, defaults) : null;
},
setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) =>
manager
.setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs })
.map((record) => toSessionBindingRecord(record, defaults)),
setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) =>
manager
.setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs })
.map((record) => toSessionBindingRecord(record, defaults)),
touch: (bindingId, at) => {
manager.touchBinding(bindingId, at);
},

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
"description": "OpenClaw Tlon/Urbit channel plugin",
"type": "module",
"dependencies": {
"@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87",
"@tloncorp/api": "git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87",
"@tloncorp/tlon-skill": "0.2.2",
"@urbit/aura": "^3.0.0",
"zod": "^4.3.6"

View File

@@ -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=macmini node scripts/test-parallel.mjs",
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial 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",

2
pnpm-lock.yaml generated
View File

@@ -536,7 +536,7 @@ importers:
extensions/tlon:
dependencies:
'@tloncorp/api':
specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
specifier: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87
version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
'@tloncorp/tlon-skill':
specifier: 0.2.2

View File

@@ -55,13 +55,11 @@ 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";
@@ -164,17 +162,6 @@ 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;
@@ -255,25 +242,9 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
return isUnitConfigTestFile(file);
});
const defaultHeavyUnitFileLimit =
testProfile === "serial"
? 0
: isMacMiniProfile
? 90
: testProfile === "low"
? 20
: highMemLocalHost
? 80
: 60;
testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60;
const defaultHeavyUnitLaneCount =
testProfile === "serial"
? 0
: isMacMiniProfile
? 6
: testProfile === "low"
? 2
: highMemLocalHost
? 5
: 4;
testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4;
const heavyUnitFileLimit = parseEnvNumber(
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
defaultHeavyUnitFileLimit,
@@ -567,16 +538,12 @@ 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) &&
!isMacMiniProfile;
testProfile !== "low" && testProfile !== "serial" && !(!isCI && nodeMajor >= 25);
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
const resolvedOverride =
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const parallelGatewayEnabled =
!isMacMiniProfile &&
(process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost));
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 ||
@@ -603,52 +570,45 @@ const defaultWorkerBudget =
extensions: 4,
gateway: 1,
}
: isMacMiniProfile
: testProfile === "serial"
? {
unit: 3,
unit: 1,
unitIsolated: 1,
extensions: 1,
gateway: 1,
}
: testProfile === "serial"
: testProfile === "max"
? {
unit: 1,
unitIsolated: 1,
extensions: 1,
gateway: 1,
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))),
}
: testProfile === "max"
: highMemLocalHost
? {
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))),
// 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))),
}
: highMemLocalHost
: lowMemLocalHost
? {
// 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))),
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
unit: 2,
unitIsolated: 1,
extensions: 4,
gateway: 1,
}
: 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,
};
: {
// 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.
@@ -806,52 +766,21 @@ 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);
}
return runEntriesWithLimit(entries, extraArgs);
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;
};
const shutdown = (signal) => {
@@ -871,17 +800,6 @@ 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(
@@ -916,28 +834,9 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
process.exit(2);
}
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);
}
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
if (failedParallel !== undefined) {
process.exit(failedParallel);
}
for (const entry of serialRuns) {

View File

@@ -12,7 +12,6 @@ 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";
@@ -105,7 +104,7 @@ function createSessionBindingCapabilities() {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"] satisfies SessionBindingPlacement[],
placements: ["current", "child"] as const,
};
}
@@ -180,8 +179,8 @@ 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) => await hoisted.initializeSessionMock(params),
closeSession: async (params) => await hoisted.closeSessionMock(params),
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
const args = argsUnknown as {
@@ -1040,7 +1039,7 @@ describe("spawnAcpDirect", () => {
...hoisted.state.cfg.channels,
telegram: {
threadBindings: {
enabled: true,
spawnAcpSessions: true,
},
},
},

View File

@@ -11,12 +11,20 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num
});
}
function drainChunks(chunker: EmbeddedBlockChunker, force = false) {
function drainChunks(chunker: EmbeddedBlockChunker) {
const chunks: string[] = [];
chunker.drain({ force, emit: (chunk) => chunks.push(chunk) });
chunker.drain({ force: false, 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({
@@ -46,25 +54,12 @@ describe("EmbeddedBlockChunker", () => {
expect(chunker.bufferedText).toMatch(/^After/);
});
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("flushes paragraph boundaries before minChars 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("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
});
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
@@ -102,7 +97,7 @@ describe("EmbeddedBlockChunker", () => {
it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => {
const chunker = new EmbeddedBlockChunker({
minChars: 10,
minChars: 100,
maxChars: 200,
breakPreference: "paragraph",
flushOnParagraph: true,

View File

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

View File

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

View File

@@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
}
export function formatEnvelopeTimestamp(
function formatTimestamp(
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 = formatEnvelopeTimestamp(params.timestamp, resolved);
const ts = formatTimestamp(params.timestamp, resolved);
if (ts) {
parts.push(ts);
}

View File

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

View File

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

View File

@@ -3,22 +3,26 @@ 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 { normalizeMessageChannel } from "../../utils/message-channel.js";
import {
INTERNAL_MESSAGE_CHANNEL,
listDeliverableMessageChannels,
} from "../../utils/message-channel.js";
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
const DEFAULT_BLOCK_STREAM_MIN = 800;
const DEFAULT_BLOCK_STREAM_MAX = 1200;
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
const getBlockChunkProviders = () =>
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
if (!provider) {
return undefined;
}
const normalized = normalizeMessageChannel(provider);
if (!normalized) {
return undefined;
}
return normalized as TextChunkProvider;
const cleaned = provider.trim().toLowerCase();
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
? (cleaned as TextChunkProvider)
: undefined;
}
function resolveProviderChunkContext(
@@ -66,7 +70,7 @@ export type BlockStreamingCoalescing = {
maxChars: number;
idleMs: number;
joiner: string;
/** Internal escape hatch for transports that truly need per-enqueue flushing. */
/** When true, the coalescer flushes the buffer on each enqueue (paragraph-boundary flush). */
flushOnEnqueue?: boolean;
};
@@ -147,7 +151,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
: chunking.breakPreference === "newline"
? "\n"
: "\n\n"),
...(coalescingDefaults?.flushOnEnqueue === true ? { flushOnEnqueue: true } : {}),
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
};
return { chunking, coalescing };
@@ -161,9 +165,9 @@ export function resolveBlockStreamingChunking(
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
// 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.
// 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.
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
@@ -192,6 +196,7 @@ export function resolveBlockStreamingCoalescing(
maxChars: number;
breakPreference: "paragraph" | "newline" | "sentence";
},
opts?: { chunkMode?: "length" | "newline" },
): BlockStreamingCoalescing | undefined {
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
cfg,
@@ -199,6 +204,9 @@ 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;
@@ -233,5 +241,6 @@ export function resolveBlockStreamingCoalescing(
maxChars,
idleMs,
joiner,
flushOnEnqueue: chunkMode === "newline",
};
}

View File

@@ -21,7 +21,6 @@ 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 {
@@ -293,7 +292,6 @@ 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
? {
@@ -303,7 +301,6 @@ export async function runPreparedReply(
: {}),
}
: { ...sessionCtx, ThreadStarterBody: undefined },
envelopeOptions,
);
const baseBodyForPrompt = isBareSessionReset
? baseBodyFinal

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,9 @@
import fs from "node:fs";
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,
resetMatrixThreadBindingsForTests,
setMatrixRuntime,
} from "../../../../extensions/matrix/runtime-api.js";
import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
@@ -134,39 +126,12 @@ type DirectoryContractEntry = {
type SessionBindingContractEntry = {
id: string;
expectedCapabilities: SessionBindingCapabilities;
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
getCapabilities: () => SessionBindingCapabilities;
bindAndResolve: () => Promise<SessionBindingRecord>;
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
cleanup: () => Promise<void> | void;
};
const matrixSessionBindingAuth = {
accountId: "default",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "token",
} as const;
function createMatrixSessionBindingStateDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "matrix-session-binding-contract-"));
}
async function setupMatrixSessionBindingManager() {
setMatrixRuntime({
state: {
resolveStateDir: () => createMatrixSessionBindingStateDir(),
},
} as never);
return await createMatrixThreadBindingManager({
accountId: "default",
auth: matrixSessionBindingAuth,
client: {} as never,
idleTimeoutMs: 24 * 60 * 60 * 1000,
maxAgeMs: 0,
enableSweeper: false,
});
}
function expectResolvedSessionBinding(params: {
channel: string;
accountId: string;
@@ -743,58 +708,6 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [
});
},
},
{
id: "matrix",
expectedCapabilities: {
adapterAvailable: true,
bindSupported: true,
unbindSupported: true,
placements: ["current", "child"],
},
getCapabilities: async () => {
await setupMatrixSessionBindingManager();
return getSessionBindingService().getCapabilities({
channel: "matrix",
accountId: "default",
});
},
bindAndResolve: async () => {
await setupMatrixSessionBindingManager();
const service = getSessionBindingService();
const binding = await service.bind({
targetSessionKey: "agent:matrix:child:thread-1",
targetKind: "subagent",
conversation: {
channel: "matrix",
accountId: "default",
conversationId: "!room:example",
},
placement: "child",
metadata: {
label: "codex-matrix",
introText: "matrix contract intro",
},
});
expectResolvedSessionBinding({
channel: "matrix",
accountId: "default",
conversationId: "$root",
targetSessionKey: "agent:matrix:child:thread-1",
});
return binding;
},
unbindAndVerify: unbindAndExpectClearedSessionBinding,
cleanup: async () => {
const manager = await setupMatrixSessionBindingManager();
manager.stop();
resetMatrixThreadBindingsForTests();
expectClearedSessionBinding({
channel: "matrix",
accountId: "default",
conversationId: "$root",
});
},
},
{
id: "telegram",
expectedCapabilities: {

View File

@@ -1,36 +1,16 @@
import { beforeEach, describe, vi } from "vitest";
import { beforeEach, describe } 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/runtime-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";
const sendMessageMatrixMock = vi.hoisted(() =>
vi.fn(async (_to: string, _message: string, opts?: { threadId?: string }) => ({
messageId: opts?.threadId ? "$reply" : "$root",
roomId: "!room:example",
})),
);
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: sendMessageMatrixMock,
};
});
beforeEach(() => {
sessionBindingTesting.resetSessionBindingAdaptersForTests();
discordThreadBindingTesting.resetThreadBindingsForTests();
feishuThreadBindingTesting.resetFeishuThreadBindingsForTests();
resetMatrixThreadBindingsForTests();
telegramThreadBindingTesting.resetTelegramThreadBindingsForTests();
sendMessageMatrixMock.mockClear();
});
for (const entry of sessionBindingContractRegistry) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -110,7 +110,6 @@ 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;
@@ -300,10 +299,6 @@ vi.mock("./doctor-state-migrations.js", () => ({
runLegacyStateMigrations,
}));
vi.mock("../gateway/server-startup-matrix-migration.js", () => ({
runStartupMatrixMigration,
}));
export function mockDoctorConfigSnapshot(
params: {
config?: Record<string, unknown>;
@@ -398,7 +393,6 @@ 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);

View File

@@ -1,70 +0,0 @@
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),
}),
}),
);
},
);
});

View File

@@ -17,7 +17,6 @@ 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";
@@ -237,19 +236,6 @@ 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({

View File

@@ -15,14 +15,13 @@ type MatrixMigrationLogger = {
async function runBestEffortMatrixMigrationStep(params: {
label: string;
log: MatrixMigrationLogger;
logPrefix?: string;
run: () => Promise<unknown>;
}): Promise<void> {
try {
await params.run();
} catch (err) {
params.log.warn?.(
`${params.logPrefix?.trim() || "gateway"}: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
`gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
);
}
}
@@ -31,8 +30,6 @@ export async function runStartupMatrixMigration(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
log: MatrixMigrationLogger;
trigger?: string;
logPrefix?: string;
deps?: {
maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot;
autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState;
@@ -46,8 +43,6 @@ 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 });
@@ -63,13 +58,13 @@ export async function runStartupMatrixMigration(params: {
try {
await createSnapshot({
trigger,
trigger: "gateway-startup",
env,
log: params.log,
});
} catch (err) {
params.log.warn?.(
`${logPrefix}: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
);
return;
}
@@ -77,7 +72,6 @@ export async function runStartupMatrixMigration(params: {
await runBestEffortMatrixMigrationStep({
label: "legacy Matrix state migration",
log: params.log,
logPrefix,
run: () =>
migrateLegacyState({
cfg: params.cfg,
@@ -88,7 +82,6 @@ export async function runStartupMatrixMigration(params: {
await runBestEffortMatrixMigrationStep({
label: "legacy Matrix encrypted-state preparation",
log: params.log,
logPrefix,
run: () =>
prepareLegacyCrypto({
cfg: params.cfg,

View File

@@ -36,10 +36,6 @@ 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,
@@ -109,7 +105,6 @@ 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";
@@ -524,27 +519,6 @@ export async function startGatewayServer(
writeConfig: writeConfigFile,
log,
});
await runStartupMatrixMigration({
cfg: cfgAtStart,
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);

View File

@@ -1,54 +0,0 @@
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<ReturnType<typeof startGatewayServer>> | 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(),
}),
);
});
});

View File

@@ -4,8 +4,6 @@ import os from "node:os";
import path from "node:path";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { clearPluginDiscoveryCache } from "../plugins/discovery.js";
import { clearPluginManifestRegistryCache } from "../plugins/manifest-registry.js";
import {
clearInternalHooks,
createInternalHookEvent,
@@ -26,8 +24,6 @@ describe("bundle plugin hooks", () => {
beforeEach(async () => {
clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fsp.mkdir(workspaceDir, { recursive: true });
previousBundledHooksDir = process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
@@ -36,8 +32,6 @@ describe("bundle plugin hooks", () => {
afterEach(() => {
clearInternalHooks();
clearPluginDiscoveryCache();
clearPluginManifestRegistryCache();
if (previousBundledHooksDir === undefined) {
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
} else {

View File

@@ -37,7 +37,6 @@ export type Hook = {
description: string;
source: "openclaw-bundled" | "openclaw-managed" | "openclaw-workspace" | "openclaw-plugin";
pluginId?: string;
frontmatter?: ParsedHookFrontmatter;
filePath: string; // Path to HOOK.md
baseDir: string; // Directory containing hook
handlerPath: string; // Path to handler module (handler.ts/js)

View File

@@ -14,7 +14,14 @@ import {
resolveHookInvocationPolicy,
} from "./frontmatter.js";
import { resolvePluginHookDirs } from "./plugin-hooks.js";
import type { Hook, HookEligibilityContext, HookEntry, HookSnapshot, HookSource } from "./types.js";
import type {
Hook,
HookEligibilityContext,
HookEntry,
HookSnapshot,
HookSource,
ParsedHookFrontmatter,
} from "./types.js";
type HookPackageManifest = {
name?: string;
@@ -74,16 +81,8 @@ function loadHookFromDir(params: {
nameHint?: string;
}): Hook | null {
const hookMdPath = path.join(params.hookDir, "HOOK.md");
const safeHookMdPath = resolveBoundaryFilePath({
absolutePath: hookMdPath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
if (!safeHookMdPath) {
return null;
}
const content = readBoundaryFileUtf8({
absolutePath: safeHookMdPath,
absolutePath: hookMdPath,
rootPath: params.hookDir,
boundaryLabel: "hook directory",
});
@@ -128,8 +127,7 @@ function loadHookFromDir(params: {
description,
source: params.source,
pluginId: params.pluginId,
frontmatter,
filePath: safeHookMdPath,
filePath: hookMdPath,
baseDir,
handlerPath,
};
@@ -214,7 +212,15 @@ export function loadHookEntriesFromDir(params: {
pluginId: params.pluginId,
});
return hooks.map((hook) => {
const frontmatter = hook.frontmatter ?? {};
let frontmatter: ParsedHookFrontmatter = {};
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
}
const entry: HookEntry = {
hook: {
...hook,
@@ -297,7 +303,15 @@ function loadHookEntries(
}
return Array.from(merged.values()).map((hook) => {
const frontmatter = hook.frontmatter ?? {};
let frontmatter: ParsedHookFrontmatter = {};
const raw = readBoundaryFileUtf8({
absolutePath: hook.filePath,
rootPath: hook.baseDir,
boundaryLabel: "hook directory",
});
if (raw !== null) {
frontmatter = parseFrontmatter(raw);
}
return {
hook,
frontmatter,

View File

@@ -2,7 +2,6 @@ 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,
@@ -90,13 +89,13 @@ describe("matrix plugin helper resolution", () => {
].join("\n"),
);
const cfg: OpenClawConfig = {
const cfg = {
plugins: {
load: {
paths: [customRoot],
},
},
};
} as const;
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
@@ -161,13 +160,13 @@ describe("matrix plugin helper resolution", () => {
return;
}
const cfg: OpenClawConfig = {
const cfg = {
plugins: {
load: {
paths: [customRoot],
},
},
};
} as const;
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
await expect(

View File

@@ -143,24 +143,6 @@ describe("resolveMessageChannelSelection", () => {
});
});
it("skips configured-channel scanning when includeConfigured is false", async () => {
const isConfigured = vi.fn(async () => true);
mocks.listChannelPlugins.mockReturnValue([makePlugin({ id: "whatsapp", isConfigured })]);
const selection = await resolveMessageChannelSelection({
cfg: {} as never,
channel: "telegram",
includeConfigured: false,
});
expect(selection).toEqual({
channel: "telegram",
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,

View File

@@ -146,15 +146,11 @@ export async function resolveMessageChannelSelection(params: {
cfg: OpenClawConfig;
channel?: string | null;
fallbackChannel?: string | null;
includeConfigured?: boolean;
}): Promise<{
channel: MessageChannelId;
configured: MessageChannelId[];
source: MessageChannelSelectionSource;
}> {
const includeConfigured = params.includeConfigured !== false;
const resolveConfigured = async () =>
includeConfigured ? await listConfiguredMessageChannels(params.cfg) : [];
const normalized = normalizeMessageChannel(params.channel);
if (normalized) {
const availableExplicit = resolveAvailableKnownChannel({
@@ -169,7 +165,7 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) {
return {
channel: fallback,
configured: await resolveConfigured(),
configured: await listConfiguredMessageChannels(params.cfg),
source: "tool-context-fallback",
};
}
@@ -180,7 +176,7 @@ export async function resolveMessageChannelSelection(params: {
}
return {
channel: availableExplicit,
configured: await resolveConfigured(),
configured: await listConfiguredMessageChannels(params.cfg),
source: "explicit",
};
}
@@ -192,12 +188,12 @@ export async function resolveMessageChannelSelection(params: {
if (fallback) {
return {
channel: fallback,
configured: await resolveConfigured(),
configured: await listConfiguredMessageChannels(params.cfg),
source: "tool-context-fallback",
};
}
const configured = await resolveConfigured();
const configured = await listConfiguredMessageChannels(params.cfg);
if (configured.length === 1) {
return { channel: configured[0], configured, source: "single-configured" };
}

View File

@@ -222,12 +222,10 @@ async function resolveChannel(
params: Record<string, unknown>,
toolContext?: { currentChannelProvider?: string },
) {
const explicitChannel = readStringParam(params, "channel");
const selection = await resolveMessageChannelSelection({
cfg,
channel: explicitChannel,
channel: readStringParam(params, "channel"),
fallbackChannel: toolContext?.currentChannelProvider,
includeConfigured: !explicitChannel,
});
if (selection.source === "tool-context-fallback") {
params.channel = selection.channel;
@@ -320,13 +318,14 @@ async function handleBroadcastAction(
throw new Error("Broadcast requires at least one target in --targets.");
}
const channelHint = readStringParam(params, "channel");
const configured = await listConfiguredMessageChannels(input.cfg);
if (configured.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
const targetChannels =
channelHint && channelHint.trim().toLowerCase() !== "all"
? [await resolveChannel(input.cfg, { channel: channelHint }, input.toolContext)]
: await listConfiguredMessageChannels(input.cfg);
if (targetChannels.length === 0) {
throw new Error("Broadcast requires at least one configured channel.");
}
: configured;
const results: Array<{
channel: ChannelId;
to: string;

View File

@@ -56,7 +56,6 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
timeout: "none",
kick: "none",
ban: "none",
"set-profile": "none",
"set-presence": "none",
"download-file": "none",
};

View File

@@ -1,11 +1,7 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelOutboundAdapter, ChannelPlugin } from "../../channels/plugins/types.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createMSTeamsTestPlugin,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
import { createIMessageTestPlugin } from "../../test-utils/imessage-test-plugin.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
@@ -246,78 +242,6 @@ describe("sendPoll channel normalization", () => {
});
});
describe("implicit single-channel selection", () => {
it("keeps single configured channel fallback for sendMessage when channel is omitted", async () => {
const sendText = vi.fn(async () => ({ channel: "msteams", messageId: "m1" }));
setRegistry(
createTestRegistry([
{
pluginId: "msteams",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: () => true,
},
}),
outbound: {
...createMSTeamsOutbound(),
sendText,
},
},
},
]),
);
const result = await sendMessage({
cfg: {},
to: "conversation:19:abc@thread.tacv2",
content: "hi",
});
expect(result.channel).toBe("msteams");
expect(sendText).toHaveBeenCalled();
});
it("keeps single configured channel fallback for sendPoll when channel is omitted", async () => {
setRegistry(
createTestRegistry([
{
pluginId: "msteams",
source: "test",
plugin: {
...createChannelTestPluginBase({
id: "msteams",
label: "Microsoft Teams",
docsPath: "/channels/msteams",
config: {
listAccountIds: () => ["default"],
resolveAccount: () => ({}),
isConfigured: () => true,
},
}),
outbound: createMSTeamsOutbound({ includePoll: true }),
},
},
]),
);
const result = await sendPoll({
cfg: {},
to: "conversation:19:abc@thread.tacv2",
question: "Lunch?",
options: ["Pizza", "Sushi"],
});
expect(result.channel).toBe("msteams");
});
});
const setMattermostGatewayRegistry = () => {
setRegistry(
createTestRegistry([

View File

@@ -132,12 +132,10 @@ async function resolveRequiredChannel(params: {
cfg: OpenClawConfig;
channel?: string;
}): Promise<string> {
const explicitChannel = typeof params.channel === "string" ? params.channel.trim() : "";
return (
await resolveMessageChannelSelection({
cfg: params.cfg,
channel: explicitChannel || undefined,
includeConfigured: !explicitChannel,
channel: params.channel,
})
).channel;
}

View File

@@ -65,7 +65,6 @@ describe("isPathInside", () => {
it("accepts identical and nested paths but rejects escapes", () => {
expect(isPathInside("/workspace/root", "/workspace/root")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/nested/file.txt")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/..cache/file.txt")).toBe(true);
expect(isPathInside("/workspace/root", "/workspace/root/../escape.txt")).toBe(false);
});
@@ -76,9 +75,6 @@ describe("isPathInside", () => {
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\Nested\File.txt`),
).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..cache\file.txt`),
).toBe(true);
expect(
isPathInside(String.raw`C:\workspace\root`, String.raw`C:\workspace\root\..\escape.txt`),
).toBe(false);

View File

@@ -37,20 +37,11 @@ export function isPathInside(root: string, target: string): boolean {
const rootForCompare = normalizeWindowsPathForComparison(path.win32.resolve(root));
const targetForCompare = normalizeWindowsPathForComparison(path.win32.resolve(target));
const relative = path.win32.relative(rootForCompare, targetForCompare);
return (
relative === "" ||
(relative !== ".." &&
!relative.startsWith(`..\\`) &&
!relative.startsWith("../") &&
!path.win32.isAbsolute(relative))
);
return relative === "" || (!relative.startsWith("..") && !path.win32.isAbsolute(relative));
}
const resolvedRoot = path.resolve(root);
const resolvedTarget = path.resolve(target);
const relative = path.relative(resolvedRoot, resolvedTarget);
return (
relative === "" ||
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}

View File

@@ -14,7 +14,6 @@ import type {
OpenClawPluginConfigSchema,
OpenClawPluginDefinition,
PluginInteractiveTelegramHandlerContext,
PluginCommandContext,
} from "../plugins/types.js";
export type {
@@ -53,7 +52,6 @@ export type {
ProviderAuthResult,
OpenClawPluginCommandDefinition,
OpenClawPluginDefinition,
PluginCommandContext,
PluginLogger,
PluginInteractiveTelegramHandlerContext,
} from "../plugins/types.js";

View File

@@ -35,11 +35,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record<string, readonly string[]> = {
'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";',
],

View File

@@ -11,10 +11,6 @@ function getServerArgs(value: unknown): unknown[] | undefined {
return isRecord(value) && Array.isArray(value.args) ? value.args : undefined;
}
function normalizePluginPath(value: string): string {
return path.normalize(value.replaceAll("/", path.sep));
}
const tempHarness = createBundleMcpTempHarness();
afterEach(async () => {
@@ -50,6 +46,8 @@ describe("loadEnabledBundleMcpConfig", () => {
const loadedServer = loaded.config.mcpServers.bundleProbe;
const loadedArgs = getServerArgs(loadedServer);
const loadedServerPath = typeof loadedArgs?.[0] === "string" ? loadedArgs[0] : undefined;
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
expect(isRecord(loadedServer) ? loadedServer.command : undefined).toBe("node");
expect(loadedArgs).toHaveLength(1);
@@ -58,7 +56,7 @@ describe("loadEnabledBundleMcpConfig", () => {
throw new Error("expected bundled MCP args to include the server path");
}
expect(await fs.realpath(loadedServerPath)).toBe(resolvedServerPath);
expect(loadedServer.cwd).toBe(pluginRoot);
expect(loadedServer.cwd).toBe(resolvedPluginRoot);
} finally {
env.restore();
}
@@ -180,19 +178,19 @@ describe("loadEnabledBundleMcpConfig", () => {
},
},
});
const resolvedPluginRoot = await fs.realpath(pluginRoot);
expect(loaded.diagnostics).toEqual([]);
const inlineProbe = loaded.config.mcpServers.inlineProbe;
expect(isRecord(inlineProbe)).toBe(true);
expect(normalizePluginPath(String(isRecord(inlineProbe) ? inlineProbe.command : ""))).toBe(
normalizePluginPath(path.join(pluginRoot, "bin", "server.sh")),
);
expect(getServerArgs(inlineProbe)?.map((arg) => normalizePluginPath(String(arg)))).toEqual([
normalizePluginPath(path.join(pluginRoot, "servers", "probe.mjs")),
normalizePluginPath(path.join(pluginRoot, "local-probe.mjs")),
]);
expect(isRecord(inlineProbe) ? inlineProbe.cwd : undefined).toBe(pluginRoot);
expect(isRecord(inlineProbe) ? inlineProbe.env : undefined).toEqual({
PLUGIN_ROOT: pluginRoot,
expect(loaded.config.mcpServers.inlineProbe).toEqual({
command: path.join(resolvedPluginRoot, "bin", "server.sh"),
args: [
path.join(resolvedPluginRoot, "servers", "probe.mjs"),
path.join(resolvedPluginRoot, "local-probe.mjs"),
],
cwd: resolvedPluginRoot,
env: {
PLUGIN_ROOT: resolvedPluginRoot,
},
});
} finally {
env.restore();

View File

@@ -327,7 +327,7 @@ export function loadEnabledBundleMcpConfig(params: {
const loaded = loadBundleMcpConfig({
pluginId: record.id,
rootDir: record.format === "bundle" ? record.source : record.rootDir,
rootDir: record.rootDir,
bundleFormat: record.bundleFormat,
});
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;

View File

@@ -108,6 +108,65 @@ describe("registerPluginCommand", () => {
expect(getPluginCommandSpecs("slack")).toEqual([]);
});
it("shares plugin commands across duplicated module instances", async () => {
const duplicateSpecifier = "./commands.js?duplicate=1";
const duplicateCommands = (await import(duplicateSpecifier)) as {
clearPluginCommands: () => void;
getPluginCommandSpecs: (provider?: string) => Array<{
name: string;
description: string;
acceptsArgs: boolean;
}>;
registerPluginCommand: typeof registerPluginCommand;
};
clearPluginCommands();
duplicateCommands.clearPluginCommands();
expect(
duplicateCommands.registerPluginCommand("demo-plugin", {
name: "phone",
description: "Phone control",
acceptsArgs: true,
nativeNames: {
telegram: "phone",
},
handler: async () => ({ text: "ok" }),
}),
).toEqual({ ok: true });
expect(getPluginCommandSpecs("telegram")).toEqual([
{
name: "phone",
description: "Phone control",
acceptsArgs: true,
},
]);
clearPluginCommands();
expect(duplicateCommands.getPluginCommandSpecs("telegram")).toEqual([]);
expect(
registerPluginCommand("demo-plugin", {
name: "voice",
description: "Voice control",
acceptsArgs: false,
nativeNames: {
telegram: "voice",
},
handler: async () => ({ text: "ok" }),
}),
).toEqual({ ok: true });
expect(duplicateCommands.getPluginCommandSpecs("telegram")).toEqual([
{
name: "voice",
description: "Voice control",
acceptsArgs: false,
},
]);
});
it("matches provider-specific native aliases back to the canonical command", () => {
const result = registerPluginCommand("demo-plugin", {
name: "voice",

View File

@@ -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,28 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
pluginRoot?: string;
};
// Registry of plugin commands
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
type PluginCommandRegistryState = {
pluginCommands: Map<string, RegisteredPluginCommand>;
registryLocked: boolean;
};
// Lock to prevent modifications during command execution
let registryLocked = false;
const PLUGIN_COMMAND_REGISTRY_STATE_KEY = Symbol.for("openclaw.plugins.command-registry-state");
/**
* Keep plugin command state on globalThis so source-loaded plugin-sdk shims,
* bundled dist extensions, and Jiti-transpiled copies all share one registry.
* Without this, plugin registration can succeed in one module graph while
* Telegram/Discord read commands from a different copy and see an empty set.
*/
const registryState = resolveGlobalSingleton<PluginCommandRegistryState>(
PLUGIN_COMMAND_REGISTRY_STATE_KEY,
() => ({
pluginCommands: new Map<string, RegisteredPluginCommand>(),
registryLocked: false,
}),
);
const pluginCommands = registryState.pluginCommands;
// Maximum allowed length for command arguments (defense in depth)
const MAX_ARGS_LENGTH = 4096;
@@ -172,7 +190,7 @@ export function registerPluginCommand(
opts?: { pluginName?: string; pluginRoot?: string },
): CommandRegistrationResult {
// Prevent registration while commands are being processed
if (registryLocked) {
if (registryState.registryLocked) {
return { ok: false, error: "Cannot register commands while processing is in progress" };
}
@@ -451,7 +469,7 @@ export async function executePluginCommand(params: {
};
// Lock registry during execution to prevent concurrent modifications
registryLocked = true;
registryState.registryLocked = true;
try {
const result = await command.handler(ctx);
logVerbose(
@@ -464,7 +482,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;
registryState.registryLocked = false;
}
}

View File

@@ -1,29 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
const originalPlatform = process.platform;
function setPlatform(value: NodeJS.Platform): void {
Object.defineProperty(process, "platform", {
configurable: true,
value,
});
}
afterEach(() => {
setPlatform(originalPlatform);
vi.restoreAllMocks();
});
describe("security scan path guards", () => {
it("uses Windows-aware containment checks for differently normalized paths", async () => {
setPlatform("win32");
const { isPathInside } = await import("./scan-paths.js");
expect(
isPathInside(String.raw`C:\Workspace\Root`, String.raw`c:\workspace\root\hooks\hook`),
).toBe(true);
expect(
isPathInside(String.raw`\\?\C:\Workspace\Root`, String.raw`C:\workspace\root\hooks\hook`),
).toBe(true);
});
});

View File

@@ -1,8 +1,11 @@
import fs from "node:fs";
import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js";
import path from "node:path";
export function isPathInside(basePath: string, candidatePath: string): boolean {
return isBoundaryPathInside(basePath, candidatePath);
const base = path.resolve(basePath);
const candidate = path.resolve(candidatePath);
const rel = path.relative(base, candidate);
return rel === "" || (!rel.startsWith(`..${path.sep}`) && rel !== ".." && !path.isAbsolute(rel));
}
function safeRealpathSync(filePath: string): string | null {

View File

@@ -1,8 +0,0 @@
export type { OpenClawConfig } from "../../../src/config/config.js";
export {
__testing as sessionBindingTesting,
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";