mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-09 23:41:55 +08:00
Compare commits
25 Commits
fix/plugin
...
codex/wind
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f571f6d534 | ||
|
|
52020d3a0c | ||
|
|
84b1e3296c | ||
|
|
726ccf4706 | ||
|
|
46aa10c04a | ||
|
|
e63bedb74b | ||
|
|
8d66245825 | ||
|
|
cc4464f2ce | ||
|
|
62de0853f3 | ||
|
|
b2213f147e | ||
|
|
080b574ad6 | ||
|
|
8b5206cc67 | ||
|
|
24daa04d67 | ||
|
|
9ec1f01b5a | ||
|
|
15fd465a48 | ||
|
|
9cd74ca94b | ||
|
|
20728e1035 | ||
|
|
47b02435c1 | ||
|
|
75e6c8fe9c | ||
|
|
16129272dc | ||
|
|
f8eb23de1c | ||
|
|
34ee75b174 | ||
|
|
4443cc771a | ||
|
|
f69450b170 | ||
|
|
c4a4050ce4 |
@@ -21,10 +21,14 @@ runs:
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
elif [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
# 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)
|
||||
|
||||
60
.github/workflows/ci.yml
vendored
60
.github/workflows/ci.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
@@ -31,8 +32,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.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
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 }}
|
||||
|
||||
- name: Detect docs-only changes
|
||||
id: check
|
||||
@@ -61,8 +62,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.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
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 }}
|
||||
|
||||
- name: Detect changed scopes
|
||||
id: scope
|
||||
@@ -72,8 +73,12 @@ jobs:
|
||||
|
||||
if [ "${{ github.event_name }}" = "push" ]; then
|
||||
BASE="${{ github.event.before }}"
|
||||
else
|
||||
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)"
|
||||
fi
|
||||
|
||||
node scripts/ci-changed-scope.mjs --base "$BASE" --head HEAD
|
||||
@@ -96,8 +101,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.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
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 }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -108,14 +113,31 @@ 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 { listChangedExtensionIds } from "./scripts/test-extension.mjs";
|
||||
import {
|
||||
listAvailableExtensionIds,
|
||||
listChangedExtensionIds,
|
||||
} from "./scripts/test-extension.mjs";
|
||||
|
||||
const extensionIds = listChangedExtensionIds({ base: process.env.BASE_SHA, head: "HEAD" });
|
||||
const baseSha = process.env.BASE_SHA?.trim();
|
||||
const extensionIds = baseSha
|
||||
? listChangedExtensionIds({ base: baseSha, head: "HEAD" })
|
||||
: listAvailableExtensionIds();
|
||||
const matrix = JSON.stringify({ include: extensionIds.map((extension) => ({ extension })) });
|
||||
|
||||
appendFileSync(process.env.GITHUB_OUTPUT, `has_changed_extensions=${extensionIds.length > 0}\n`, "utf8");
|
||||
@@ -535,8 +557,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.pull_request.base.sha }}
|
||||
fetch-ref: ${{ github.event_name == 'push' && github.ref_name || github.event.pull_request.base.ref }}
|
||||
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 }}
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
@@ -571,11 +593,19 @@ 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
|
||||
|
||||
@@ -47,6 +47,10 @@ 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.
|
||||
|
||||
@@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
|
||||
let env: [String: String]
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowAlwaysPatterns: [String]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
@@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
|
||||
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
|
||||
command: command,
|
||||
rawCommand: rawCommand)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
rawCommand: allowlistRawCommand,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowlistMatches = security == .allowlist
|
||||
@@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
|
||||
env: env,
|
||||
resolution: allowlistResolutions.first,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowAlwaysPatterns: allowAlwaysPatterns,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
|
||||
@@ -378,7 +378,7 @@ private enum ExecHostExecutor {
|
||||
let context = await self.buildContext(
|
||||
request: request,
|
||||
command: validatedRequest.command,
|
||||
rawCommand: validatedRequest.displayCommand)
|
||||
rawCommand: validatedRequest.evaluationRawCommand)
|
||||
|
||||
switch ExecHostRequestEvaluator.evaluate(
|
||||
context: context,
|
||||
@@ -476,13 +476,7 @@ private enum ExecHostExecutor {
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in context.allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: candidate)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
for pattern in context.allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,23 @@ struct ExecCommandResolution {
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolveAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [String]
|
||||
{
|
||||
var patterns: [String] = []
|
||||
var seen = Set<String>()
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: 0,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return patterns
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
@@ -101,6 +118,115 @@ struct ExecCommandResolution {
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func collectAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
depth: Int,
|
||||
patterns: inout [String],
|
||||
seen: inout Set<String>)
|
||||
{
|
||||
guard depth < 3, !command.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
ExecCommandToken.basenameLower(token0) == "env",
|
||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||
!envUnwrapped.isEmpty
|
||||
{
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: envUnwrapped,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: shellMultiplexer,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
return
|
||||
}
|
||||
for segment in segments {
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else {
|
||||
continue
|
||||
}
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: tokens,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||
seen.insert(pattern).inserted
|
||||
else {
|
||||
return
|
||||
}
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrapper = ExecCommandToken.basenameLower(token0)
|
||||
guard wrapper == "busybox" || wrapper == "toybox" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var appletIndex = 1
|
||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||
appletIndex += 1
|
||||
}
|
||||
guard appletIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !applet.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
||||
let shellWrappers = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
guard shellWrappers.contains(normalizedApplet) else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[appletIndex...])
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@@ -12,14 +12,24 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
struct UnwrapResult {
|
||||
let command: [String]
|
||||
let usesModifiers: Bool
|
||||
}
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
self.unwrapWithMetadata(command)?.command
|
||||
}
|
||||
|
||||
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
var usesModifiers = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
@@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
@@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
@@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
if unwrapped.usesModifiers {
|
||||
break
|
||||
}
|
||||
current = unwrapped.command
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
struct ExecHostValidatedRequest {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ExecHostPolicyDecision {
|
||||
@@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case let .ok(resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
return .success(ExecHostValidatedRequest(
|
||||
command: command,
|
||||
displayCommand: resolved.displayCommand,
|
||||
evaluationRawCommand: resolved.evaluationRawCommand))
|
||||
case let .invalid(message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
|
||||
@@ -3,6 +3,7 @@ import Foundation
|
||||
enum ExecSystemRunCommandValidator {
|
||||
struct ResolvedCommand {
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ValidationResult {
|
||||
@@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
|
||||
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
let formattedArgv = ExecCommandFormatter.displayString(for: command)
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
ExecCommandFormatter.displayString(for: command)
|
||||
nil
|
||||
}
|
||||
|
||||
if let raw = normalizedRaw, raw != inferred {
|
||||
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
|
||||
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
||||
}
|
||||
|
||||
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
|
||||
return .ok(ResolvedCommand(
|
||||
displayCommand: formattedArgv,
|
||||
evaluationRawCommand: self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)))
|
||||
}
|
||||
|
||||
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
|
||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
|
||||
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)
|
||||
}
|
||||
|
||||
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
||||
@@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func allowlistEvaluationRawCommand(
|
||||
normalizedRaw: String?,
|
||||
shellIsWrapper: Bool,
|
||||
previewCommand: String?) -> String?
|
||||
{
|
||||
guard shellIsWrapper else {
|
||||
return normalizedRaw
|
||||
}
|
||||
guard let normalizedRaw else {
|
||||
return nil
|
||||
}
|
||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||
}
|
||||
|
||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
|
||||
@@ -507,8 +507,7 @@ actor MacNodeRuntime {
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: evaluation.security,
|
||||
agentId: evaluation.agentId,
|
||||
command: command,
|
||||
allowlistResolutions: evaluation.allowlistResolutions)
|
||||
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
|
||||
|
||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
@@ -795,15 +794,11 @@ extension MacNodeRuntime {
|
||||
persistAllowlist: Bool,
|
||||
security: ExecSecurity,
|
||||
agentId: String?,
|
||||
command: [String],
|
||||
allowlistResolutions: [ExecCommandResolution])
|
||||
allowAlwaysPatterns: [String])
|
||||
{
|
||||
guard persistAllowlist, security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
||||
continue
|
||||
}
|
||||
for pattern in allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@@ -240,7 +240,7 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@@ -248,11 +248,11 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@@ -260,8 +260,33 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: nil,
|
||||
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||
agentId: nil)
|
||||
|
||||
#expect(evaluation.displayCommand == rawCommand)
|
||||
#expect(evaluation.allowlistResolutions.count == 1)
|
||||
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
|
||||
@@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstWriteDate = try Self.modificationDate(at: url)
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondWriteDate = try Self.modificationDate(at: url)
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#expect(firstWriteDate == secondWriteDate)
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
struct MissingDateError: Error {}
|
||||
throw MissingDateError()
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
}
|
||||
return date
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
|
||||
env: [:],
|
||||
resolution: nil,
|
||||
allowlistResolutions: [],
|
||||
allowAlwaysPatterns: [],
|
||||
allowlistMatches: [],
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: nil,
|
||||
|
||||
@@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Private runtime barrel for the bundled Google Chat extension.
|
||||
// Keep this barrel thin and aligned with the curated plugin-sdk/googlechat surface.
|
||||
|
||||
export * from "../../src/plugin-sdk/googlechat.js";
|
||||
export * from "openclaw/plugin-sdk/googlechat";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
@@ -14,6 +16,20 @@ describe("matrix plugin registration", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the matrix runtime api through Jiti", () => {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
||||
});
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
|
||||
|
||||
expect(jiti(runtimeApiPath)).toMatchObject({
|
||||
requiresExplicitMatrixDefaultAccount: expect.any(Function),
|
||||
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the channel without bootstrapping crypto runtime", () => {
|
||||
const runtime = {} as never;
|
||||
matrixPlugin.register({
|
||||
|
||||
@@ -1,3 +1,19 @@
|
||||
export * from "openclaw/plugin-sdk/matrix";
|
||||
export * from "./src/auth-precedence.js";
|
||||
export * from "./helper-api.js";
|
||||
export {
|
||||
findMatrixAccountEntry,
|
||||
hashMatrixAccessToken,
|
||||
listMatrixEnvAccountIds,
|
||||
resolveConfiguredMatrixAccountIds,
|
||||
resolveMatrixChannelConfig,
|
||||
resolveMatrixCredentialsFilename,
|
||||
resolveMatrixEnvAccountToken,
|
||||
resolveMatrixHomeserverKey,
|
||||
resolveMatrixLegacyFlatStoreRoot,
|
||||
sanitizeMatrixPathSegment,
|
||||
} from "./helper-api.js";
|
||||
export {
|
||||
createMatrixThreadBindingManager,
|
||||
resetMatrixThreadBindingsForTests,
|
||||
} from "./src/matrix/thread-bindings.js";
|
||||
export { setMatrixRuntime } from "./src/runtime.js";
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [] };
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toContain("poll");
|
||||
@@ -74,7 +74,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [], schema: null };
|
||||
const actions = discovery.actions;
|
||||
const properties =
|
||||
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
|
||||
@@ -87,64 +87,66 @@ describe("matrixMessageActions", () => {
|
||||
});
|
||||
|
||||
it("hides gated actions when the default Matrix account disables them", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
export const matrixChannelRuntime = {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
matrixOutbound,
|
||||
probeMatrix,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixTargets,
|
||||
|
||||
@@ -15,8 +15,8 @@ import {
|
||||
createTextPairingAdapter,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import { matrixSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
@@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: {
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
setupWizard: matrixSetupWizard,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "matrixUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
|
||||
@@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
|
||||
const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(
|
||||
-1,
|
||||
)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
rooms: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {},
|
||||
summary: { "m.heroes": [] },
|
||||
state: { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
@@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
@@ -88,6 +91,50 @@ describe("FileBackedMatrixSyncStore", () => {
|
||||
},
|
||||
]);
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
|
||||
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
await firstStore.flush();
|
||||
|
||||
const afterDirtyPersist = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
|
||||
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const afterCleanShutdown = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
|
||||
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const restartedStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
|
||||
await restartedStore.setSyncData(createSyncResponse("s456"));
|
||||
await restartedStore.flush();
|
||||
|
||||
const afterNewSync = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterNewSync.hasSavedSync()).toBe(true);
|
||||
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
|
||||
});
|
||||
|
||||
it("coalesces background persistence until the debounce window elapses", async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = {
|
||||
version: number;
|
||||
savedSync: ISyncData | null;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
cleanShutdown?: boolean;
|
||||
};
|
||||
|
||||
function createAsyncLock() {
|
||||
@@ -52,7 +53,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
nextBatch: value.nextBatch,
|
||||
accountData: value.accountData,
|
||||
roomsData: value.roomsData,
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
// Older Matrix state files stored the raw /sync-shaped payload directly.
|
||||
@@ -64,7 +65,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
? value.account_data.events
|
||||
: [],
|
||||
roomsData: isRecord(value.rooms) ? value.rooms : {},
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -76,6 +77,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
version?: unknown;
|
||||
savedSync?: unknown;
|
||||
clientOptions?: unknown;
|
||||
cleanShutdown?: unknown;
|
||||
};
|
||||
const savedSync = toPersistedSyncData(parsed.savedSync);
|
||||
if (parsed.version === STORE_VERSION) {
|
||||
@@ -85,6 +87,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
clientOptions: isRecord(parsed.clientOptions)
|
||||
? (parsed.clientOptions as IStoredClientOpts)
|
||||
: undefined,
|
||||
cleanShutdown: parsed.cleanShutdown === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,6 +96,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(parsed),
|
||||
cleanShutdown: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -119,6 +123,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
private savedSync: ISyncData | null = null;
|
||||
private savedClientOptions: IStoredClientOpts | undefined;
|
||||
private readonly hadSavedSyncOnLoad: boolean;
|
||||
private readonly hadCleanShutdownOnLoad: boolean;
|
||||
private cleanShutdown = false;
|
||||
private dirty = false;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private persistPromise: Promise<void> | null = null;
|
||||
@@ -128,11 +134,13 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
let restoredCleanShutdown = false;
|
||||
try {
|
||||
const raw = readFileSync(this.storagePath, "utf8");
|
||||
const persisted = readPersistedStore(raw);
|
||||
restoredSavedSync = persisted?.savedSync ?? null;
|
||||
restoredClientOptions = persisted?.clientOptions;
|
||||
restoredCleanShutdown = persisted?.cleanShutdown === true;
|
||||
} catch {
|
||||
// Missing or unreadable sync cache should not block startup.
|
||||
}
|
||||
@@ -140,6 +148,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
this.savedSync = restoredSavedSync;
|
||||
this.savedClientOptions = restoredClientOptions;
|
||||
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
|
||||
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
|
||||
this.cleanShutdown = this.hadCleanShutdownOnLoad;
|
||||
|
||||
if (this.savedSync) {
|
||||
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
|
||||
@@ -154,6 +164,10 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
return this.hadSavedSyncOnLoad;
|
||||
}
|
||||
|
||||
hasSavedSyncFromCleanShutdown(): boolean {
|
||||
return this.hadCleanShutdownOnLoad;
|
||||
}
|
||||
|
||||
override getSavedSync(): Promise<ISyncData | null> {
|
||||
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
|
||||
}
|
||||
@@ -205,9 +219,15 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
await super.deleteAllData();
|
||||
this.savedSync = null;
|
||||
this.savedClientOptions = undefined;
|
||||
this.cleanShutdown = false;
|
||||
await fs.rm(this.storagePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
markCleanShutdown(): void {
|
||||
this.cleanShutdown = true;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
@@ -224,6 +244,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
}
|
||||
|
||||
private markDirtyAndSchedulePersist(): void {
|
||||
this.cleanShutdown = false;
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
@@ -242,6 +263,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
const payload: PersistedMatrixSyncStore = {
|
||||
version: STORE_VERSION,
|
||||
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
|
||||
cleanShutdown: this.cleanShutdown === true,
|
||||
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
|
||||
};
|
||||
try {
|
||||
|
||||
1
extensions/matrix/src/matrix/index.ts
Normal file
1
extensions/matrix/src/matrix/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
@@ -62,7 +62,7 @@ function createHarness(params?: {
|
||||
const ensureVerificationDmTracked = vi.fn(
|
||||
params?.ensureVerificationDmTracked ?? (async () => null),
|
||||
);
|
||||
const sendMessage = vi.fn(async () => "$notice");
|
||||
const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice");
|
||||
const invalidateRoom = vi.fn();
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const formatNativeDependencyHint = vi.fn(() => "install hint");
|
||||
|
||||
@@ -100,6 +100,7 @@ function createHandlerHarness() {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => false,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
needsRoomAliasesForConfig: false,
|
||||
});
|
||||
|
||||
await handler(
|
||||
|
||||
@@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => {
|
||||
hasPersistedSyncState: vi.fn(() => false),
|
||||
};
|
||||
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
|
||||
let startClientError: Error | null = null;
|
||||
const resolveTextChunkLimit = vi.fn<
|
||||
(cfg: unknown, channel: unknown, accountId?: unknown) => number
|
||||
>(() => 4000);
|
||||
@@ -18,17 +17,17 @@ const hoisted = vi.hoisted(() => {
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const stopThreadBindingManager = vi.fn();
|
||||
const stopSharedClientInstance = vi.fn();
|
||||
const releaseSharedClientInstance = vi.fn(async () => true);
|
||||
const setActiveMatrixClient = vi.fn();
|
||||
return {
|
||||
callOrder,
|
||||
client,
|
||||
createMatrixRoomMessageHandler,
|
||||
logger,
|
||||
releaseSharedClientInstance,
|
||||
resolveTextChunkLimit,
|
||||
setActiveMatrixClient,
|
||||
startClientError,
|
||||
stopSharedClientInstance,
|
||||
startClientError: null as Error | null,
|
||||
stopThreadBindingManager,
|
||||
};
|
||||
});
|
||||
@@ -128,7 +127,10 @@ vi.mock("../client.js", () => ({
|
||||
hoisted.callOrder.push("start-client");
|
||||
return hoisted.client;
|
||||
}),
|
||||
stopSharedClientInstance: hoisted.stopSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../client/shared.js", () => ({
|
||||
releaseSharedClientInstance: hoisted.releaseSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../config-update.js", () => ({
|
||||
@@ -207,8 +209,8 @@ describe("monitorMatrixProvider", () => {
|
||||
hoisted.callOrder.length = 0;
|
||||
hoisted.startClientError = null;
|
||||
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
|
||||
hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
|
||||
hoisted.setActiveMatrixClient.mockReset();
|
||||
hoisted.stopSharedClientInstance.mockReset();
|
||||
hoisted.stopThreadBindingManager.mockReset();
|
||||
hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
|
||||
hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
|
||||
@@ -252,12 +254,13 @@ describe("monitorMatrixProvider", () => {
|
||||
await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
|
||||
|
||||
expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
|
||||
});
|
||||
|
||||
it("disables cold-start backlog dropping when sync state already exists", async () => {
|
||||
it("disables cold-start backlog dropping only when sync state is cleanly persisted", async () => {
|
||||
hoisted.client.hasPersistedSyncState.mockReturnValue(true);
|
||||
const { monitorMatrixProvider } = await import("./index.js");
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -17,8 +17,8 @@ import {
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientInstance,
|
||||
} from "../client.js";
|
||||
import { releaseSharedClientInstance } from "../client/shared.js";
|
||||
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { resolveMatrixMonitorConfig } from "./config.js";
|
||||
@@ -131,7 +131,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
setActiveMatrixClient(client, auth.accountId);
|
||||
let cleanedUp = false;
|
||||
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
|
||||
const cleanup = () => {
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
try {
|
||||
threadBindingManager?.stop();
|
||||
} finally {
|
||||
stopSharedClientInstance(client);
|
||||
await releaseSharedClientInstance(client, "persist");
|
||||
setActiveMatrixClient(null, auth.accountId);
|
||||
}
|
||||
};
|
||||
@@ -273,19 +273,32 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
cleanup();
|
||||
resolve();
|
||||
const stopAndResolve = async () => {
|
||||
try {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
await cleanup();
|
||||
} catch (err) {
|
||||
logger.warn("matrix: failed during monitor shutdown cleanup", {
|
||||
error: String(err),
|
||||
});
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (opts.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
void stopAndResolve();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
opts.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void stopAndResolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
await cleanup();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
createTestRegistry,
|
||||
type OpenClawConfig,
|
||||
resolveAgentRoute,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../../../../src/infra/outbound/session-binding-service.js";
|
||||
import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js";
|
||||
sessionBindingTesting,
|
||||
setActivePluginRegistry,
|
||||
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
|
||||
import { matrixPlugin } from "../../channel.js";
|
||||
import { resolveMatrixInboundRoute } from "./route.js";
|
||||
|
||||
|
||||
@@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => {
|
||||
|
||||
it("prefers authenticated client media downloads", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(payload, { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixEventEvent,
|
||||
Preset,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
@@ -349,7 +350,9 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
hasPersistedSyncState(): boolean {
|
||||
return this.syncStore?.hasSavedSync() === true;
|
||||
// Only trust restart replay when the previous process completed a final
|
||||
// sync-store persist. A stale cursor can make Matrix re-surface old events.
|
||||
return this.syncStore?.hasSavedSyncFromCleanShutdown() === true;
|
||||
}
|
||||
|
||||
private async ensureStartedForCryptoControlPlane(): Promise<void> {
|
||||
@@ -366,6 +369,7 @@ export class MatrixClient {
|
||||
}
|
||||
this.decryptBridge.stop();
|
||||
// Final persist on shutdown
|
||||
this.syncStore?.markCleanShutdown();
|
||||
this.stopPersistPromise = Promise.all([
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
@@ -547,7 +551,7 @@ export class MatrixClient {
|
||||
const result = await this.client.createRoom({
|
||||
invite: [remoteUserId],
|
||||
is_direct: true,
|
||||
preset: "trusted_private_chat",
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: initialState,
|
||||
});
|
||||
return result.room_id;
|
||||
|
||||
@@ -621,14 +621,6 @@ export async function createMatrixThreadBindingManager(params: {
|
||||
});
|
||||
return record ? toSessionBindingRecord(record, defaults) : null;
|
||||
},
|
||||
setIdleTimeoutBySession: ({ targetSessionKey, idleTimeoutMs }) =>
|
||||
manager
|
||||
.setIdleTimeoutBySessionKey({ targetSessionKey, idleTimeoutMs })
|
||||
.map((record) => toSessionBindingRecord(record, defaults)),
|
||||
setMaxAgeBySession: ({ targetSessionKey, maxAgeMs }) =>
|
||||
manager
|
||||
.setMaxAgeBySessionKey({ targetSessionKey, maxAgeMs })
|
||||
.map((record) => toSessionBindingRecord(record, defaults)),
|
||||
touch: (bindingId, at) => {
|
||||
manager.touchBinding(bindingId, at);
|
||||
},
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizardAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import {
|
||||
@@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
||||
type MatrixOnboardingStatus = {
|
||||
channel: typeof channel;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
};
|
||||
|
||||
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
|
||||
|
||||
type MatrixOnboardingConfigureContext = {
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
options?: unknown;
|
||||
forceAllowFrom: boolean;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
shouldPromptAccountIds: boolean;
|
||||
};
|
||||
|
||||
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
|
||||
configured: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type MatrixOnboardingAdapter = {
|
||||
channel: typeof channel;
|
||||
getStatus: (ctx: {
|
||||
cfg: CoreConfig;
|
||||
options?: unknown;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
}) => Promise<MatrixOnboardingStatus>;
|
||||
configure: (
|
||||
ctx: MatrixOnboardingConfigureContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
|
||||
configureInteractive?: (
|
||||
ctx: MatrixOnboardingInteractiveContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
|
||||
afterConfigWritten?: (ctx: {
|
||||
previousCfg: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) => Promise<void> | void;
|
||||
dmPolicy?: ChannelSetupDmPolicy;
|
||||
disable?: (cfg: CoreConfig) => CoreConfig;
|
||||
};
|
||||
|
||||
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
|
||||
return normalizeAccountId(
|
||||
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
|
||||
@@ -473,7 +518,7 @@ async function runMatrixConfigure(params: {
|
||||
return { cfg: next, accountId };
|
||||
}
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "../../src/plugin-sdk/nextcloud-talk.js";
|
||||
export * from "openclaw/plugin-sdk/nextcloud-talk";
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "OpenClaw Tlon/Urbit channel plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tloncorp/api": "git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87",
|
||||
"@tloncorp/api": "https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87",
|
||||
"@tloncorp/tlon-skill": "0.2.2",
|
||||
"@urbit/aura": "^3.0.0",
|
||||
"zod": "^4.3.6"
|
||||
|
||||
@@ -655,7 +655,7 @@
|
||||
"test:install:e2e:openai": "OPENCLAW_E2E_MODELS=openai CLAWDBOT_E2E_MODELS=openai bash scripts/test-install-sh-e2e-docker.sh",
|
||||
"test:install:smoke": "bash scripts/test-install-sh-docker.sh",
|
||||
"test:live": "OPENCLAW_LIVE_TEST=1 CLAWDBOT_LIVE_TEST=1 vitest run --config vitest.live.config.ts",
|
||||
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=serial node scripts/test-parallel.mjs",
|
||||
"test:macmini": "OPENCLAW_TEST_VM_FORKS=0 OPENCLAW_TEST_PROFILE=macmini node scripts/test-parallel.mjs",
|
||||
"test:parallels:linux": "bash scripts/e2e/parallels-linux-smoke.sh",
|
||||
"test:parallels:macos": "bash scripts/e2e/parallels-macos-smoke.sh",
|
||||
"test:parallels:windows": "bash scripts/e2e/parallels-windows-smoke.sh",
|
||||
|
||||
2
pnpm-lock.yaml
generated
2
pnpm-lock.yaml
generated
@@ -536,7 +536,7 @@ importers:
|
||||
extensions/tlon:
|
||||
dependencies:
|
||||
'@tloncorp/api':
|
||||
specifier: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87
|
||||
specifier: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
|
||||
version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87
|
||||
'@tloncorp/tlon-skill':
|
||||
specifier: 0.2.2
|
||||
|
||||
@@ -55,11 +55,13 @@ const includeExtensionsSuite = process.env.OPENCLAW_TEST_INCLUDE_EXTENSIONS ===
|
||||
const rawTestProfile = process.env.OPENCLAW_TEST_PROFILE?.trim().toLowerCase();
|
||||
const testProfile =
|
||||
rawTestProfile === "low" ||
|
||||
rawTestProfile === "macmini" ||
|
||||
rawTestProfile === "max" ||
|
||||
rawTestProfile === "normal" ||
|
||||
rawTestProfile === "serial"
|
||||
? rawTestProfile
|
||||
: "normal";
|
||||
const isMacMiniProfile = testProfile === "macmini";
|
||||
// Even on low-memory hosts, keep the isolated lane split so files like
|
||||
// git-commit.test.ts still get the worker/process isolation they require.
|
||||
const shouldSplitUnitRuns = testProfile !== "serial";
|
||||
@@ -162,6 +164,17 @@ const parsePassthroughArgs = (args) => {
|
||||
};
|
||||
const { fileFilters: passthroughFileFilters, optionArgs: passthroughOptionArgs } =
|
||||
parsePassthroughArgs(passthroughArgs);
|
||||
const passthroughMetadataFlags = new Set(["-h", "--help", "--listTags", "--clearCache"]);
|
||||
const passthroughMetadataOnly =
|
||||
passthroughArgs.length > 0 &&
|
||||
passthroughFileFilters.length === 0 &&
|
||||
passthroughOptionArgs.every((arg) => {
|
||||
if (!arg.startsWith("-")) {
|
||||
return false;
|
||||
}
|
||||
const [flag] = arg.split("=", 1);
|
||||
return passthroughMetadataFlags.has(flag);
|
||||
});
|
||||
const countExplicitEntryFilters = (entryArgs) => {
|
||||
const { fileFilters } = parsePassthroughArgs(entryArgs.slice(2));
|
||||
return fileFilters.length > 0 ? fileFilters.length : null;
|
||||
@@ -242,9 +255,25 @@ const allKnownUnitFiles = allKnownTestFiles.filter((file) => {
|
||||
return isUnitConfigTestFile(file);
|
||||
});
|
||||
const defaultHeavyUnitFileLimit =
|
||||
testProfile === "serial" ? 0 : testProfile === "low" ? 20 : highMemLocalHost ? 80 : 60;
|
||||
testProfile === "serial"
|
||||
? 0
|
||||
: isMacMiniProfile
|
||||
? 90
|
||||
: testProfile === "low"
|
||||
? 20
|
||||
: highMemLocalHost
|
||||
? 80
|
||||
: 60;
|
||||
const defaultHeavyUnitLaneCount =
|
||||
testProfile === "serial" ? 0 : testProfile === "low" ? 2 : highMemLocalHost ? 5 : 4;
|
||||
testProfile === "serial"
|
||||
? 0
|
||||
: isMacMiniProfile
|
||||
? 6
|
||||
: testProfile === "low"
|
||||
? 2
|
||||
: highMemLocalHost
|
||||
? 5
|
||||
: 4;
|
||||
const heavyUnitFileLimit = parseEnvNumber(
|
||||
"OPENCLAW_TEST_HEAVY_UNIT_FILE_LIMIT",
|
||||
defaultHeavyUnitFileLimit,
|
||||
@@ -538,12 +567,16 @@ const targetedEntries = (() => {
|
||||
// Node 25 local runs still show cross-process worker shutdown contention even
|
||||
// after moving the known heavy files into singleton lanes.
|
||||
const topLevelParallelEnabled =
|
||||
testProfile !== "low" && testProfile !== "serial" && !(!isCI && nodeMajor >= 25);
|
||||
testProfile !== "low" &&
|
||||
testProfile !== "serial" &&
|
||||
!(!isCI && nodeMajor >= 25) &&
|
||||
!isMacMiniProfile;
|
||||
const overrideWorkers = Number.parseInt(process.env.OPENCLAW_TEST_WORKERS ?? "", 10);
|
||||
const resolvedOverride =
|
||||
Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
|
||||
const parallelGatewayEnabled =
|
||||
process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost);
|
||||
!isMacMiniProfile &&
|
||||
(process.env.OPENCLAW_TEST_PARALLEL_GATEWAY === "1" || (!isCI && highMemLocalHost));
|
||||
// Keep gateway serial by default except when explicitly requested or on high-memory local hosts.
|
||||
const keepGatewaySerial =
|
||||
isWindowsCi ||
|
||||
@@ -570,45 +603,52 @@ const defaultWorkerBudget =
|
||||
extensions: 4,
|
||||
gateway: 1,
|
||||
}
|
||||
: testProfile === "serial"
|
||||
: isMacMiniProfile
|
||||
? {
|
||||
unit: 1,
|
||||
unit: 3,
|
||||
unitIsolated: 1,
|
||||
extensions: 1,
|
||||
gateway: 1,
|
||||
}
|
||||
: testProfile === "max"
|
||||
: testProfile === "serial"
|
||||
? {
|
||||
unit: localWorkers,
|
||||
unitIsolated: Math.min(4, localWorkers),
|
||||
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
|
||||
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
|
||||
unit: 1,
|
||||
unitIsolated: 1,
|
||||
extensions: 1,
|
||||
gateway: 1,
|
||||
}
|
||||
: highMemLocalHost
|
||||
: testProfile === "max"
|
||||
? {
|
||||
// After peeling measured hotspots into dedicated lanes, the shared
|
||||
// unit-fast lane shuts down more reliably with a slightly smaller
|
||||
// worker fan-out than the old "max it out" local default.
|
||||
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
|
||||
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)),
|
||||
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
|
||||
gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))),
|
||||
unit: localWorkers,
|
||||
unitIsolated: Math.min(4, localWorkers),
|
||||
extensions: Math.max(1, Math.min(6, Math.floor(localWorkers / 2))),
|
||||
gateway: Math.max(1, Math.min(2, Math.floor(localWorkers / 4))),
|
||||
}
|
||||
: lowMemLocalHost
|
||||
: highMemLocalHost
|
||||
? {
|
||||
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
|
||||
unit: 2,
|
||||
unitIsolated: 1,
|
||||
extensions: 4,
|
||||
gateway: 1,
|
||||
}
|
||||
: {
|
||||
// 64-95 GiB local hosts: conservative split with some parallel headroom.
|
||||
unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))),
|
||||
unitIsolated: 1,
|
||||
// After peeling measured hotspots into dedicated lanes, the shared
|
||||
// unit-fast lane shuts down more reliably with a slightly smaller
|
||||
// worker fan-out than the old "max it out" local default.
|
||||
unit: Math.max(4, Math.min(10, Math.floor((localWorkers * 5) / 8))),
|
||||
unitIsolated: Math.max(1, Math.min(2, Math.floor(localWorkers / 6) || 1)),
|
||||
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
|
||||
gateway: 1,
|
||||
};
|
||||
gateway: Math.max(2, Math.min(6, Math.floor(localWorkers / 2))),
|
||||
}
|
||||
: lowMemLocalHost
|
||||
? {
|
||||
// Sub-64 GiB local hosts are prone to OOM with large vmFork runs.
|
||||
unit: 2,
|
||||
unitIsolated: 1,
|
||||
extensions: 4,
|
||||
gateway: 1,
|
||||
}
|
||||
: {
|
||||
// 64-95 GiB local hosts: conservative split with some parallel headroom.
|
||||
unit: Math.max(2, Math.min(8, Math.floor(localWorkers / 2))),
|
||||
unitIsolated: 1,
|
||||
extensions: Math.max(1, Math.min(4, Math.floor(localWorkers / 4))),
|
||||
gateway: 1,
|
||||
};
|
||||
|
||||
// Keep worker counts predictable for local runs; trim macOS CI workers to avoid worker crashes/OOM.
|
||||
// In CI on linux/windows, prefer Vitest defaults to avoid cross-test interference from lower worker counts.
|
||||
@@ -766,21 +806,52 @@ const run = async (entry, extraArgs = []) => {
|
||||
return 0;
|
||||
};
|
||||
|
||||
const runEntriesWithLimit = async (entries, extraArgs = [], concurrency = 1) => {
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedConcurrency = Math.max(1, Math.floor(concurrency));
|
||||
if (normalizedConcurrency <= 1) {
|
||||
for (const entry of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await run(entry, extraArgs);
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let nextIndex = 0;
|
||||
let firstFailure;
|
||||
const worker = async () => {
|
||||
while (firstFailure === undefined) {
|
||||
const entryIndex = nextIndex;
|
||||
nextIndex += 1;
|
||||
if (entryIndex >= entries.length) {
|
||||
return;
|
||||
}
|
||||
const code = await run(entries[entryIndex], extraArgs);
|
||||
if (code !== 0 && firstFailure === undefined) {
|
||||
firstFailure = code;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const workerCount = Math.min(normalizedConcurrency, entries.length);
|
||||
await Promise.all(Array.from({ length: workerCount }, () => worker()));
|
||||
return firstFailure;
|
||||
};
|
||||
|
||||
const runEntries = async (entries, extraArgs = []) => {
|
||||
if (topLevelParallelEnabled) {
|
||||
const codes = await Promise.all(entries.map((entry) => run(entry, extraArgs)));
|
||||
return codes.find((code) => code !== 0);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const code = await run(entry, extraArgs);
|
||||
if (code !== 0) {
|
||||
return code;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
return runEntriesWithLimit(entries, extraArgs);
|
||||
};
|
||||
|
||||
const shutdown = (signal) => {
|
||||
@@ -800,6 +871,17 @@ if (process.env.OPENCLAW_TEST_LIST_LANES === "1") {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (passthroughMetadataOnly) {
|
||||
const exitCode = await runOnce(
|
||||
{
|
||||
name: "vitest-meta",
|
||||
args: ["vitest", "run"],
|
||||
},
|
||||
passthroughOptionArgs,
|
||||
);
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
if (targetedEntries.length > 0) {
|
||||
if (passthroughRequiresSingleRun && targetedEntries.length > 1) {
|
||||
console.error(
|
||||
@@ -834,9 +916,28 @@ if (passthroughRequiresSingleRun && passthroughOptionArgs.length > 0) {
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
|
||||
if (failedParallel !== undefined) {
|
||||
process.exit(failedParallel);
|
||||
if (isMacMiniProfile && targetedEntries.length === 0) {
|
||||
const unitFastEntry = parallelRuns.find((entry) => entry.name === "unit-fast");
|
||||
if (unitFastEntry) {
|
||||
const unitFastCode = await run(unitFastEntry, passthroughOptionArgs);
|
||||
if (unitFastCode !== 0) {
|
||||
process.exit(unitFastCode);
|
||||
}
|
||||
}
|
||||
const deferredEntries = parallelRuns.filter((entry) => entry.name !== "unit-fast");
|
||||
const failedMacMiniParallel = await runEntriesWithLimit(
|
||||
deferredEntries,
|
||||
passthroughOptionArgs,
|
||||
3,
|
||||
);
|
||||
if (failedMacMiniParallel !== undefined) {
|
||||
process.exit(failedMacMiniParallel);
|
||||
}
|
||||
} else {
|
||||
const failedParallel = await runEntries(parallelRuns, passthroughOptionArgs);
|
||||
if (failedParallel !== undefined) {
|
||||
process.exit(failedParallel);
|
||||
}
|
||||
}
|
||||
|
||||
for (const entry of serialRuns) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js";
|
||||
import {
|
||||
__testing as sessionBindingServiceTesting,
|
||||
registerSessionBindingAdapter,
|
||||
type SessionBindingPlacement,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js";
|
||||
@@ -104,7 +105,7 @@ function createSessionBindingCapabilities() {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
placements: ["current", "child"] satisfies SessionBindingPlacement[],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => {
|
||||
metaCleared: false,
|
||||
});
|
||||
getAcpSessionManagerSpy.mockReset().mockReturnValue({
|
||||
initializeSession: async (params) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params) => await hoisted.closeSessionMock(params),
|
||||
initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params: unknown) => await hoisted.closeSessionMock(params),
|
||||
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
|
||||
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
|
||||
const args = argsUnknown as {
|
||||
@@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => {
|
||||
...hoisted.state.cfg.channels,
|
||||
telegram: {
|
||||
threadBindings: {
|
||||
spawnAcpSessions: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -11,20 +11,12 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num
|
||||
});
|
||||
}
|
||||
|
||||
function drainChunks(chunker: EmbeddedBlockChunker) {
|
||||
function drainChunks(chunker: EmbeddedBlockChunker, force = false) {
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
chunker.drain({ force, emit: (chunk) => chunks.push(chunk) });
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function expectFlushAtFirstParagraphBreak(text: string) {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
chunker.append(text);
|
||||
const chunks = drainChunks(chunker);
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
}
|
||||
|
||||
describe("EmbeddedBlockChunker", () => {
|
||||
it("breaks at paragraph boundary right after fence close", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
@@ -54,12 +46,25 @@ describe("EmbeddedBlockChunker", () => {
|
||||
expect(chunker.bufferedText).toMatch(/^After/);
|
||||
});
|
||||
|
||||
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
|
||||
it("waits until minChars before flushing paragraph boundaries when flushOnParagraph is set", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 30, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.");
|
||||
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks).toEqual(["First paragraph.\n\nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Third paragraph.");
|
||||
});
|
||||
|
||||
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
|
||||
it("still force flushes buffered paragraphs below minChars at the end", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n \nSecond paragraph.");
|
||||
|
||||
expect(drainChunks(chunker)).toEqual([]);
|
||||
expect(drainChunks(chunker, true)).toEqual(["First paragraph.\n \nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
|
||||
@@ -97,7 +102,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 100,
|
||||
minChars: 10,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ export type BlockReplyChunking = {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
/** When true, flush eagerly on \n\n paragraph boundaries regardless of minChars. */
|
||||
/** When true, prefer \n\n paragraph boundaries once minChars has been satisfied. */
|
||||
flushOnParagraph?: boolean;
|
||||
};
|
||||
|
||||
@@ -129,7 +129,7 @@ export class EmbeddedBlockChunker {
|
||||
const minChars = Math.max(1, Math.floor(this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
|
||||
if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (this.#buffer.length < minChars && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,12 +150,12 @@ export class EmbeddedBlockChunker {
|
||||
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
|
||||
const remainingLength = reopenPrefix.length + (source.length - start);
|
||||
|
||||
if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) {
|
||||
if (!force && remainingLength < minChars) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.#chunking.flushOnParagraph && !force) {
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start);
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars);
|
||||
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
|
||||
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
|
||||
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
|
||||
@@ -175,12 +175,7 @@ export class EmbeddedBlockChunker {
|
||||
const breakResult =
|
||||
force && remainingLength <= maxChars
|
||||
? this.#pickSoftBreakIndex(view, fenceSpans, 1, start)
|
||||
: this.#pickBreakIndex(
|
||||
view,
|
||||
fenceSpans,
|
||||
force || this.#chunking.flushOnParagraph ? 1 : undefined,
|
||||
start,
|
||||
);
|
||||
: this.#pickBreakIndex(view, fenceSpans, force ? 1 : undefined, start);
|
||||
if (breakResult.index <= 0) {
|
||||
if (force) {
|
||||
emit(`${reopenPrefix}${source.slice(start)}`);
|
||||
@@ -205,7 +200,7 @@ export class EmbeddedBlockChunker {
|
||||
|
||||
const nextLength =
|
||||
(reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start);
|
||||
if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (nextLength < minChars && !force) {
|
||||
break;
|
||||
}
|
||||
if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
@@ -401,6 +396,7 @@ function findNextParagraphBreak(
|
||||
buffer: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
startIndex = 0,
|
||||
minCharsFromStart = 1,
|
||||
): ParagraphBreak | null {
|
||||
if (startIndex < 0) {
|
||||
return null;
|
||||
@@ -413,6 +409,9 @@ function findNextParagraphBreak(
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
if (index - startIndex < minCharsFromStart) {
|
||||
continue;
|
||||
}
|
||||
if (!isSafeFenceBreak(fenceSpans, index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn(
|
||||
const embeddedRunMock = {
|
||||
isEmbeddedPiRunActive: vi.fn(() => false),
|
||||
isEmbeddedPiRunStreaming: vi.fn(() => false),
|
||||
queueEmbeddedPiMessage: vi.fn(() => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
|
||||
queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true),
|
||||
};
|
||||
const { subagentRegistryMock } = vi.hoisted(() => ({
|
||||
subagentRegistryMock: {
|
||||
@@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void {
|
||||
setRuntimeConfigSnapshot(configOverride);
|
||||
}
|
||||
|
||||
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
|
||||
return new Proxy(sessionStore, {
|
||||
function loadSessionStoreFixture(): ReturnType<typeof configSessions.loadSessionStore> {
|
||||
return new Proxy(sessionStore as ReturnType<typeof configSessions.loadSessionStore>, {
|
||||
get(target, key: string | symbol) {
|
||||
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
|
||||
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
|
||||
return {
|
||||
sessionId: key,
|
||||
updatedAt: Date.now(),
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
};
|
||||
}
|
||||
return target[key as keyof typeof target];
|
||||
},
|
||||
@@ -207,7 +213,11 @@ describe("subagent announce formatting", () => {
|
||||
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
|
||||
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
|
||||
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
|
||||
getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock);
|
||||
getGlobalHookRunnerSpy
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
() => hookRunnerMock as unknown as ReturnType<typeof hookRunnerGlobal.getGlobalHookRunner>,
|
||||
);
|
||||
readLatestAssistantReplySpy
|
||||
.mockReset()
|
||||
.mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey));
|
||||
|
||||
@@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
|
||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
export function formatEnvelopeTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
@@ -179,7 +179,7 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
if (params.ip?.trim()) {
|
||||
parts.push(sanitizeEnvelopeHeaderPart(params.ip.trim()));
|
||||
}
|
||||
const ts = formatTimestamp(params.timestamp, resolved);
|
||||
const ts = formatEnvelopeTimestamp(params.timestamp, resolved);
|
||||
if (ts) {
|
||||
parts.push(ts);
|
||||
}
|
||||
|
||||
@@ -89,8 +89,8 @@ export function createBlockReplyCoalescer(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
// When flushOnEnqueue is set (chunkMode="newline"), each enqueued payload is treated
|
||||
// as a separate paragraph and flushed immediately so delivery matches streaming boundaries.
|
||||
// When flushOnEnqueue is set, treat each enqueued payload as its own outbound block
|
||||
// and flush immediately instead of waiting for coalescing thresholds.
|
||||
if (flushOnEnqueue) {
|
||||
if (bufferText) {
|
||||
void flush({ force: true });
|
||||
|
||||
@@ -44,6 +44,34 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
|
||||
expect(resolved.coalescing.idleMs).toBe(0);
|
||||
});
|
||||
|
||||
it("honors newline chunkMode for plugin channels even before the plugin registry is loaded", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
chunkMode: "newline",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
blockStreamingChunk: {
|
||||
minChars: 1,
|
||||
maxChars: 4000,
|
||||
breakPreference: "paragraph",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveEffectiveBlockStreamingConfig({
|
||||
cfg,
|
||||
provider: "bluebubbles",
|
||||
});
|
||||
|
||||
expect(resolved.chunking.flushOnParagraph).toBe(true);
|
||||
expect(resolved.coalescing.flushOnEnqueue).toBeUndefined();
|
||||
expect(resolved.coalescing.joiner).toBe("\n\n");
|
||||
});
|
||||
|
||||
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -3,26 +3,22 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
|
||||
import { resolveAccountEntry } from "../../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
||||
const getBlockChunkProviders = () =>
|
||||
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
|
||||
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
: undefined;
|
||||
const normalized = normalizeMessageChannel(provider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized as TextChunkProvider;
|
||||
}
|
||||
|
||||
function resolveProviderChunkContext(
|
||||
@@ -70,7 +66,7 @@ export type BlockStreamingCoalescing = {
|
||||
maxChars: number;
|
||||
idleMs: number;
|
||||
joiner: string;
|
||||
/** When true, the coalescer flushes the buffer on each enqueue (paragraph-boundary flush). */
|
||||
/** Internal escape hatch for transports that truly need per-enqueue flushing. */
|
||||
flushOnEnqueue?: boolean;
|
||||
};
|
||||
|
||||
@@ -151,7 +147,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
|
||||
: chunking.breakPreference === "newline"
|
||||
? "\n"
|
||||
: "\n\n"),
|
||||
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
|
||||
...(coalescingDefaults?.flushOnEnqueue === true ? { flushOnEnqueue: true } : {}),
|
||||
};
|
||||
|
||||
return { chunking, coalescing };
|
||||
@@ -165,9 +161,9 @@ export function resolveBlockStreamingChunking(
|
||||
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// When chunkMode="newline", the outbound delivery splits on paragraph boundaries.
|
||||
// The block chunker should flush eagerly on \n\n boundaries during streaming,
|
||||
// regardless of minChars, so each paragraph is sent as its own message.
|
||||
// When chunkMode="newline", outbound delivery prefers paragraph boundaries.
|
||||
// Keep the chunker paragraph-aware during streaming, but still let minChars
|
||||
// control when a buffered paragraph is ready to flush.
|
||||
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
@@ -196,7 +192,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
},
|
||||
opts?: { chunkMode?: "length" | "newline" },
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
|
||||
cfg,
|
||||
@@ -204,9 +199,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
accountId,
|
||||
);
|
||||
|
||||
// Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries
|
||||
// when chunkMode="newline", matching the delivery-time splitting behavior.
|
||||
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
|
||||
const providerDefaults = providerId
|
||||
? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults
|
||||
: undefined;
|
||||
@@ -241,6 +233,5 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars,
|
||||
idleMs,
|
||||
joiner,
|
||||
flushOnEnqueue: chunkMode === "newline",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { clearCommandLane, getQueueSize } from "../../process/command-queue.js";
|
||||
import { normalizeMainKey } from "../../routing/session-key.js";
|
||||
import { isReasoningTagProvider } from "../../utils/provider-utils.js";
|
||||
import { hasControlCommand } from "../command-detection.js";
|
||||
import { resolveEnvelopeFormatOptions } from "../envelope.js";
|
||||
import { buildInboundMediaNote } from "../media-note.js";
|
||||
import type { MsgContext, TemplateContext } from "../templating.js";
|
||||
import {
|
||||
@@ -292,6 +293,7 @@ export async function runPreparedReply(
|
||||
isNewSession &&
|
||||
((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset);
|
||||
const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody;
|
||||
const envelopeOptions = resolveEnvelopeFormatOptions(cfg);
|
||||
const inboundUserContext = buildInboundUserContextPrefix(
|
||||
isNewSession
|
||||
? {
|
||||
@@ -301,6 +303,7 @@ export async function runPreparedReply(
|
||||
: {}),
|
||||
}
|
||||
: { ...sessionCtx, ThreadStarterBody: undefined },
|
||||
envelopeOptions,
|
||||
);
|
||||
const baseBodyForPrompt = isBareSessionReset
|
||||
? baseBodyFinal
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withEnv } from "../../test-utils/env.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js";
|
||||
|
||||
@@ -217,6 +218,25 @@ describe("buildInboundUserContextPrefix", () => {
|
||||
expect(conversationInfo["timestamp"]).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it("honors envelope user timezone for conversation timestamps", () => {
|
||||
withEnv({ TZ: "America/Los_Angeles" }, () => {
|
||||
const text = buildInboundUserContextPrefix(
|
||||
{
|
||||
ChatType: "group",
|
||||
MessageSid: "msg-with-user-tz",
|
||||
Timestamp: Date.UTC(2026, 2, 19, 0, 0),
|
||||
} as TemplateContext,
|
||||
{
|
||||
timezone: "user",
|
||||
userTimezone: "Asia/Tokyo",
|
||||
},
|
||||
);
|
||||
|
||||
const conversationInfo = parseConversationInfoPayload(text);
|
||||
expect(conversationInfo["timestamp"]).toBe("Thu 2026-03-19 09:00 GMT+9");
|
||||
});
|
||||
});
|
||||
|
||||
it("omits invalid timestamps instead of throwing", () => {
|
||||
expect(() =>
|
||||
buildInboundUserContextPrefix({
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { normalizeChatType } from "../../channels/chat-type.js";
|
||||
import { resolveSenderLabel } from "../../channels/sender-label.js";
|
||||
import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js";
|
||||
import type { EnvelopeFormatOptions } from "../envelope.js";
|
||||
import { formatEnvelopeTimestamp } from "../envelope.js";
|
||||
import type { TemplateContext } from "../templating.js";
|
||||
|
||||
function safeTrim(value: unknown): string | undefined {
|
||||
@@ -11,24 +12,14 @@ function safeTrim(value: unknown): string | undefined {
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function formatConversationTimestamp(value: unknown): string | undefined {
|
||||
function formatConversationTimestamp(
|
||||
value: unknown,
|
||||
envelope?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
const formatted = formatZonedTimestamp(date);
|
||||
if (!formatted) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date);
|
||||
return weekday ? `${weekday} ${formatted}` : formatted;
|
||||
} catch {
|
||||
return formatted;
|
||||
}
|
||||
return formatEnvelopeTimestamp(value, envelope);
|
||||
}
|
||||
|
||||
function resolveInboundChannel(ctx: TemplateContext): string | undefined {
|
||||
@@ -81,7 +72,10 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string {
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
export function buildInboundUserContextPrefix(
|
||||
ctx: TemplateContext,
|
||||
envelope?: EnvelopeFormatOptions,
|
||||
): string {
|
||||
const blocks: string[] = [];
|
||||
const chatType = normalizeChatType(ctx.ChatType);
|
||||
const isDirect = !chatType || chatType === "direct";
|
||||
@@ -94,7 +88,7 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string {
|
||||
const messageId = safeTrim(ctx.MessageSid);
|
||||
const messageIdFull = safeTrim(ctx.MessageSidFull);
|
||||
const resolvedMessageId = messageId ?? messageIdFull;
|
||||
const timestampStr = formatConversationTimestamp(ctx.Timestamp);
|
||||
const timestampStr = formatConversationTimestamp(ctx.Timestamp, envelope);
|
||||
|
||||
const conversationInfo = {
|
||||
message_id: shouldIncludeConversationInfo ? resolvedMessageId : undefined,
|
||||
|
||||
@@ -675,6 +675,39 @@ describe("block reply coalescer", () => {
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("keeps buffering newline-style chunks until minChars is reached", async () => {
|
||||
vi.useFakeTimers();
|
||||
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||
minChars: 25,
|
||||
maxChars: 2000,
|
||||
idleMs: 50,
|
||||
joiner: "\n\n",
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("force flushes buffered newline-style chunks even below minChars", async () => {
|
||||
const { flushes, coalescer } = createBlockCoalescerHarness({
|
||||
minChars: 100,
|
||||
maxChars: 2000,
|
||||
idleMs: 50,
|
||||
joiner: "\n\n",
|
||||
});
|
||||
|
||||
coalescer.enqueue({ text: "First paragraph" });
|
||||
coalescer.enqueue({ text: "Second paragraph" });
|
||||
await coalescer.flush({ force: true });
|
||||
|
||||
expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]);
|
||||
coalescer.stop();
|
||||
});
|
||||
|
||||
it("flushes immediately per enqueue when flushOnEnqueue is set", async () => {
|
||||
const cases = [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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 {
|
||||
@@ -126,12 +134,39 @@ type DirectoryContractEntry = {
|
||||
type SessionBindingContractEntry = {
|
||||
id: string;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -708,6 +743,58 @@ 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: {
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
import { beforeEach, describe } from "vitest";
|
||||
import { beforeEach, describe, vi } from "vitest";
|
||||
import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js";
|
||||
import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js";
|
||||
import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/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) {
|
||||
|
||||
@@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: {
|
||||
}
|
||||
|
||||
export function installSessionBindingContractSuite(params: {
|
||||
getCapabilities: () => SessionBindingCapabilities;
|
||||
getCapabilities: () => SessionBindingCapabilities | Promise<SessionBindingCapabilities>;
|
||||
bindAndResolve: () => Promise<SessionBindingRecord>;
|
||||
unbindAndVerify: (binding: SessionBindingRecord) => Promise<void>;
|
||||
cleanup: () => Promise<void> | void;
|
||||
expectedCapabilities: SessionBindingCapabilities;
|
||||
}) {
|
||||
it("registers the expected session binding capabilities", () => {
|
||||
expect(params.getCapabilities()).toEqual(params.expectedCapabilities);
|
||||
it("registers the expected session binding capabilities", async () => {
|
||||
expect(await params.getCapabilities()).toEqual(params.expectedCapabilities);
|
||||
});
|
||||
|
||||
it("binds and resolves a session binding through the shared service", async () => {
|
||||
|
||||
@@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [
|
||||
"timeout",
|
||||
"kick",
|
||||
"ban",
|
||||
"set-profile",
|
||||
"set-presence",
|
||||
"download-file",
|
||||
] as const;
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("registerAgentCommands", () => {
|
||||
"--agent",
|
||||
"ops",
|
||||
"--bind",
|
||||
"matrix-js:ops",
|
||||
"matrix:ops",
|
||||
"--bind",
|
||||
"telegram",
|
||||
"--json",
|
||||
@@ -182,7 +182,7 @@ describe("registerAgentCommands", () => {
|
||||
expect(agentsBindCommandMock).toHaveBeenCalledWith(
|
||||
{
|
||||
agent: "ops",
|
||||
bind: ["matrix-js:ops", "telegram"],
|
||||
bind: ["matrix:ops", "telegram"],
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
|
||||
@@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getChannelPlugin: (channel: string) => {
|
||||
if (channel === "matrix-js") {
|
||||
if (channel === "matrix") {
|
||||
return {
|
||||
id: "matrix-js",
|
||||
id: "matrix",
|
||||
setup: {
|
||||
resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(),
|
||||
},
|
||||
@@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => {
|
||||
return actual.getChannelPlugin(channel);
|
||||
},
|
||||
normalizeChannelId: (channel: string) => {
|
||||
if (channel.trim().toLowerCase() === "matrix-js") {
|
||||
return "matrix-js";
|
||||
if (channel.trim().toLowerCase() === "matrix") {
|
||||
return "matrix";
|
||||
}
|
||||
return actual.normalizeChannelId(channel);
|
||||
},
|
||||
@@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => {
|
||||
...baseConfigSnapshot,
|
||||
config: {
|
||||
bindings: [
|
||||
{ agentId: "main", match: { channel: "matrix-js" } },
|
||||
{ agentId: "main", match: { channel: "matrix" } },
|
||||
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
|
||||
],
|
||||
},
|
||||
@@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
await agentsBindingsCommand({}, runtime);
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js"));
|
||||
expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix"));
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
expect.stringContaining("ops <- telegram accountId=work"),
|
||||
);
|
||||
@@ -76,23 +76,29 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "telegram" } }],
|
||||
bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("defaults matrix-js accountId to the target agent id when omitted", async () => {
|
||||
it("defaults matrix accountId to the target agent id when omitted", async () => {
|
||||
readConfigFileSnapshotMock.mockResolvedValue({
|
||||
...baseConfigSnapshot,
|
||||
config: {},
|
||||
});
|
||||
|
||||
await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime);
|
||||
await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime);
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }],
|
||||
bindings: [
|
||||
{
|
||||
type: "route",
|
||||
agentId: "main",
|
||||
match: { channel: "matrix", accountId: "main" },
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
@@ -123,7 +129,7 @@ describe("agents bind/unbind commands", () => {
|
||||
config: {
|
||||
agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] },
|
||||
bindings: [
|
||||
{ agentId: "main", match: { channel: "matrix-js" } },
|
||||
{ agentId: "main", match: { channel: "matrix" } },
|
||||
{ agentId: "ops", match: { channel: "telegram", accountId: "work" } },
|
||||
],
|
||||
},
|
||||
@@ -133,7 +139,7 @@ describe("agents bind/unbind commands", () => {
|
||||
|
||||
expect(writeConfigFileMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix-js" } }],
|
||||
bindings: [{ agentId: "main", match: { channel: "matrix" } }],
|
||||
}),
|
||||
);
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
|
||||
@@ -350,14 +350,15 @@ export async function channelsAddCommand(
|
||||
|
||||
await writeConfigFile(nextConfig);
|
||||
runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`);
|
||||
if (plugin.setup.afterAccountConfigWritten) {
|
||||
const setup = plugin.setup;
|
||||
if (setup?.afterAccountConfigWritten) {
|
||||
await runCollectedChannelOnboardingPostWriteHooks({
|
||||
hooks: [
|
||||
{
|
||||
channel,
|
||||
accountId,
|
||||
run: async ({ cfg: writtenCfg, runtime: hookRuntime }) =>
|
||||
await plugin.setup.afterAccountConfigWritten?.({
|
||||
await setup.afterAccountConfigWritten?.({
|
||||
previousCfg: cfg,
|
||||
cfg: writtenCfg,
|
||||
accountId,
|
||||
|
||||
@@ -106,10 +106,16 @@ export async function channelsRemoveCommand(
|
||||
if (resolvedPluginState?.configChanged) {
|
||||
cfg = resolvedPluginState.cfg;
|
||||
}
|
||||
channel = resolvedPluginState?.channelId ?? channel;
|
||||
const plugin = resolvedPluginState?.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
|
||||
const resolvedChannel = resolvedPluginState?.channelId ?? channel;
|
||||
if (!resolvedChannel) {
|
||||
runtime.error(`Unknown channel: ${rawChannel}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
channel = resolvedChannel;
|
||||
const plugin = resolvedPluginState?.plugin ?? getChannelPlugin(resolvedChannel);
|
||||
if (!plugin) {
|
||||
runtime.error(`Unknown channel: ${channel}`);
|
||||
runtime.error(`Unknown channel: ${resolvedChannel}`);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import * as noteModule from "../terminal/note.js";
|
||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||
@@ -203,6 +204,250 @@ describe("doctor config flow", () => {
|
||||
).toBe("existing-session");
|
||||
});
|
||||
|
||||
it("previews Matrix legacy sync-store migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy sync store:");
|
||||
expect(warning?.[0]).toContain(
|
||||
'Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("previews Matrix encrypted-state migration in read-only mode", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({
|
||||
stateDir,
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
});
|
||||
await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(accountRoot, "crypto", "bot-sdk.json"),
|
||||
JSON.stringify({ deviceId: "DEVICE123" }),
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
});
|
||||
|
||||
const warning = noteSpy.mock.calls.find(
|
||||
(call) =>
|
||||
call[1] === "Doctor warnings" &&
|
||||
String(call[0]).includes("Matrix encrypted-state migration is pending"),
|
||||
);
|
||||
expect(warning?.[0]).toContain("Legacy crypto store:");
|
||||
expect(warning?.[0]).toContain("New recovery key file:");
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("migrates Matrix legacy state on doctor repair", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "matrix", "bot-storage.json"),
|
||||
'{"next_batch":"s1"}',
|
||||
);
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const migratedRoot = path.join(
|
||||
stateDir,
|
||||
"matrix",
|
||||
"accounts",
|
||||
"default",
|
||||
"matrix.example.org__bot_example.org",
|
||||
);
|
||||
const migratedChildren = await fs.readdir(migratedRoot);
|
||||
expect(migratedChildren.length).toBe(1);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await fs
|
||||
.access(path.join(stateDir, "matrix", "bot-storage.json"))
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
expect(
|
||||
noteSpy.mock.calls.some(
|
||||
(call) =>
|
||||
call[1] === "Doctor changes" &&
|
||||
String(call[0]).includes("Matrix plugin upgraded in place."),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const stateDir = path.join(home, ".openclaw");
|
||||
await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(stateDir, "openclaw.json"),
|
||||
JSON.stringify({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}');
|
||||
|
||||
await loadAndMaybeMigrateDoctorConfig({
|
||||
options: { nonInteractive: true, repair: true },
|
||||
confirm: async () => false,
|
||||
});
|
||||
|
||||
const snapshotDir = path.join(home, "Backups", "openclaw-migrations");
|
||||
const snapshotEntries = await fs.readdir(snapshotDir);
|
||||
expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true);
|
||||
|
||||
const marker = JSON.parse(
|
||||
await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"),
|
||||
) as {
|
||||
archivePath: string;
|
||||
};
|
||||
expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations"));
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Matrix is installed from a stale custom path", async () => {
|
||||
const doctorWarnings = await collectDoctorWarnings({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: "/tmp/openclaw-matrix-missing",
|
||||
installPath: "/tmp/openclaw-matrix-missing",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
doctorWarnings.some(
|
||||
(line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("warns when Matrix is installed from an existing custom path", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const pluginPath = path.join(home, "matrix-plugin");
|
||||
await fs.mkdir(pluginPath, { recursive: true });
|
||||
|
||||
const doctorWarnings = await collectDoctorWarnings({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
installs: {
|
||||
matrix: {
|
||||
source: "path",
|
||||
sourcePath: pluginPath,
|
||||
installPath: pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("notes legacy browser extension migration changes", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
try {
|
||||
|
||||
@@ -26,6 +26,23 @@ import {
|
||||
isTrustedSafeBinPath,
|
||||
normalizeTrustedSafeBinDirs,
|
||||
} from "../infra/exec-safe-bin-trust.js";
|
||||
import {
|
||||
autoPrepareLegacyMatrixCrypto,
|
||||
detectLegacyMatrixCrypto,
|
||||
} from "../infra/matrix-legacy-crypto.js";
|
||||
import {
|
||||
autoMigrateLegacyMatrixState,
|
||||
detectLegacyMatrixState,
|
||||
} from "../infra/matrix-legacy-state.js";
|
||||
import {
|
||||
hasActionableMatrixMigration,
|
||||
hasPendingMatrixMigration,
|
||||
maybeCreateMatrixMigrationSnapshot,
|
||||
} from "../infra/matrix-migration-snapshot.js";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
import { readChannelAllowFromStore } from "../pairing/pairing-store.js";
|
||||
import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js";
|
||||
import {
|
||||
@@ -312,6 +329,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo
|
||||
return hits;
|
||||
}
|
||||
|
||||
function formatMatrixLegacyStatePreview(
|
||||
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
|
||||
): string {
|
||||
return [
|
||||
"- Matrix plugin upgraded in place.",
|
||||
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
|
||||
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
|
||||
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
|
||||
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function formatMatrixLegacyCryptoPreview(
|
||||
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
|
||||
): string[] {
|
||||
const notes: string[] = [];
|
||||
for (const warning of detection.warnings) {
|
||||
notes.push(`- ${warning}`);
|
||||
}
|
||||
for (const plan of detection.plans) {
|
||||
notes.push(
|
||||
[
|
||||
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
|
||||
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
|
||||
`- New recovery key file: ${plan.recoveryKeyPath}`,
|
||||
`- Migration state file: ${plan.statePath}`,
|
||||
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
return notes;
|
||||
}
|
||||
|
||||
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
|
||||
const issue = await detectPluginInstallPathIssue({
|
||||
pluginId: "matrix",
|
||||
install: cfg.plugins?.installs?.matrix,
|
||||
});
|
||||
if (!issue) {
|
||||
return [];
|
||||
}
|
||||
return formatPluginInstallPathIssue({
|
||||
issue,
|
||||
pluginLabel: "Matrix",
|
||||
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
|
||||
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
|
||||
formatCommand: formatCliCommand,
|
||||
}).map((entry) => `- ${entry}`);
|
||||
}
|
||||
|
||||
async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
@@ -1699,6 +1766,110 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
|
||||
}
|
||||
}
|
||||
|
||||
const matrixLegacyState = detectLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const matrixLegacyCrypto = detectLegacyMatrixCrypto({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const pendingMatrixMigration = hasPendingMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
const actionableMatrixMigration = hasActionableMatrixMigration({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (shouldRepair) {
|
||||
let matrixSnapshotReady = true;
|
||||
if (actionableMatrixMigration) {
|
||||
try {
|
||||
const snapshot = await maybeCreateMatrixMigrationSnapshot({
|
||||
trigger: "doctor-fix",
|
||||
env: process.env,
|
||||
});
|
||||
note(
|
||||
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
|
||||
"Doctor changes",
|
||||
);
|
||||
} catch (err) {
|
||||
matrixSnapshotReady = false;
|
||||
note(
|
||||
`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`,
|
||||
"Doctor warnings",
|
||||
);
|
||||
note(
|
||||
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
} else if (pendingMatrixMigration) {
|
||||
note(
|
||||
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
if (matrixSnapshotReady) {
|
||||
const matrixStateRepair = await autoMigrateLegacyMatrixState({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (matrixStateRepair.changes.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Matrix plugin upgraded in place.",
|
||||
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
|
||||
"- No user action required.",
|
||||
].join("\n"),
|
||||
"Doctor changes",
|
||||
);
|
||||
}
|
||||
if (matrixStateRepair.warnings.length > 0) {
|
||||
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
|
||||
}
|
||||
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
|
||||
cfg: candidate,
|
||||
env: process.env,
|
||||
});
|
||||
if (matrixCryptoRepair.changes.length > 0) {
|
||||
note(
|
||||
[
|
||||
"Matrix encrypted-state migration prepared.",
|
||||
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
|
||||
].join("\n"),
|
||||
"Doctor changes",
|
||||
);
|
||||
}
|
||||
if (matrixCryptoRepair.warnings.length > 0) {
|
||||
note(
|
||||
matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"),
|
||||
"Doctor warnings",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (matrixLegacyState) {
|
||||
if ("warning" in matrixLegacyState) {
|
||||
note(`- ${matrixLegacyState.warning}`, "Doctor warnings");
|
||||
} else {
|
||||
note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings");
|
||||
}
|
||||
}
|
||||
if (
|
||||
!shouldRepair &&
|
||||
(matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0)
|
||||
) {
|
||||
for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) {
|
||||
note(preview, "Doctor warnings");
|
||||
}
|
||||
}
|
||||
|
||||
const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate);
|
||||
if (matrixInstallWarnings.length > 0) {
|
||||
note(matrixInstallWarnings.join("\n"), "Doctor warnings");
|
||||
}
|
||||
|
||||
const missingDefaultAccountBindingWarnings =
|
||||
collectMissingDefaultAccountBindingWarnings(candidate);
|
||||
if (missingDefaultAccountBindingWarnings.length > 0) {
|
||||
|
||||
@@ -110,6 +110,7 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
}) as unknown as MockFn;
|
||||
export const runStartupMatrixMigration = vi.fn().mockResolvedValue(undefined) as unknown as MockFn;
|
||||
|
||||
function createLegacyStateMigrationDetectionResult(params?: {
|
||||
hasLegacySessions?: boolean;
|
||||
@@ -299,6 +300,10 @@ vi.mock("./doctor-state-migrations.js", () => ({
|
||||
runLegacyStateMigrations,
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/server-startup-matrix-migration.js", () => ({
|
||||
runStartupMatrixMigration,
|
||||
}));
|
||||
|
||||
export function mockDoctorConfigSnapshot(
|
||||
params: {
|
||||
config?: Record<string, unknown>;
|
||||
@@ -393,6 +398,7 @@ beforeEach(() => {
|
||||
serviceRestart.mockReset().mockResolvedValue(undefined);
|
||||
serviceUninstall.mockReset().mockResolvedValue(undefined);
|
||||
callGateway.mockReset().mockRejectedValue(new Error("gateway closed"));
|
||||
runStartupMatrixMigration.mockReset().mockResolvedValue(undefined);
|
||||
|
||||
originalIsTTY = process.stdin.isTTY;
|
||||
setStdinTty(true);
|
||||
|
||||
70
src/commands/doctor.matrix-migration.test.ts
Normal file
70
src/commands/doctor.matrix-migration.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createDoctorRuntime,
|
||||
mockDoctorConfigSnapshot,
|
||||
runStartupMatrixMigration,
|
||||
} from "./doctor.e2e-harness.js";
|
||||
import "./doctor.fast-path-mocks.js";
|
||||
|
||||
vi.mock("../plugins/providers.js", () => ({
|
||||
resolvePluginProviders: vi.fn(() => []),
|
||||
}));
|
||||
|
||||
const DOCTOR_MIGRATION_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000;
|
||||
let doctorCommand: typeof import("./doctor.js").doctorCommand;
|
||||
|
||||
describe("doctor command", () => {
|
||||
beforeAll(async () => {
|
||||
({ doctorCommand } = await import("./doctor.js"));
|
||||
});
|
||||
|
||||
it(
|
||||
"runs Matrix startup migration during repair flows",
|
||||
{ timeout: DOCTOR_MIGRATION_TIMEOUT_MS },
|
||||
async () => {
|
||||
mockDoctorConfigSnapshot({
|
||||
config: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
parsed: {
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await doctorCommand(createDoctorRuntime(), { nonInteractive: true, repair: true });
|
||||
|
||||
expect(runStartupMatrixMigration).toHaveBeenCalledTimes(1);
|
||||
expect(runStartupMatrixMigration).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: expect.objectContaining({
|
||||
channels: {
|
||||
matrix: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "tok-123",
|
||||
},
|
||||
},
|
||||
}),
|
||||
trigger: "doctor-fix",
|
||||
logPrefix: "doctor",
|
||||
log: expect.objectContaining({
|
||||
info: expect.any(Function),
|
||||
warn: expect.any(Function),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import { resolveGatewayService } from "../daemon/service.js";
|
||||
import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js";
|
||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||
import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js";
|
||||
import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -236,6 +237,19 @@ export async function doctorCommand(
|
||||
await noteMacLaunchAgentOverrides();
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
||||
|
||||
if (prompter.shouldRepair) {
|
||||
await runStartupMatrixMigration({
|
||||
cfg,
|
||||
env: process.env,
|
||||
log: {
|
||||
info: (message) => runtime.log(message),
|
||||
warn: (message) => runtime.error(message),
|
||||
},
|
||||
trigger: "doctor-fix",
|
||||
logPrefix: "doctor",
|
||||
});
|
||||
}
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
await noteChromeMcpBrowserReadiness(cfg);
|
||||
await noteOpenAIOAuthTlsPrerequisites({
|
||||
|
||||
@@ -15,13 +15,14 @@ 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?.(
|
||||
`gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
|
||||
`${params.logPrefix?.trim() || "gateway"}: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +31,8 @@ export async function runStartupMatrixMigration(params: {
|
||||
cfg: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
log: MatrixMigrationLogger;
|
||||
trigger?: string;
|
||||
logPrefix?: string;
|
||||
deps?: {
|
||||
maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot;
|
||||
autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState;
|
||||
@@ -43,6 +46,8 @@ export async function runStartupMatrixMigration(params: {
|
||||
params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState;
|
||||
const prepareLegacyCrypto =
|
||||
params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto;
|
||||
const trigger = params.trigger?.trim() || "gateway-startup";
|
||||
const logPrefix = params.logPrefix?.trim() || "gateway";
|
||||
const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env });
|
||||
const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env });
|
||||
|
||||
@@ -58,13 +63,13 @@ export async function runStartupMatrixMigration(params: {
|
||||
|
||||
try {
|
||||
await createSnapshot({
|
||||
trigger: "gateway-startup",
|
||||
trigger,
|
||||
env,
|
||||
log: params.log,
|
||||
});
|
||||
} catch (err) {
|
||||
params.log.warn?.(
|
||||
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
|
||||
`${logPrefix}: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -72,6 +77,7 @@ export async function runStartupMatrixMigration(params: {
|
||||
await runBestEffortMatrixMigrationStep({
|
||||
label: "legacy Matrix state migration",
|
||||
log: params.log,
|
||||
logPrefix,
|
||||
run: () =>
|
||||
migrateLegacyState({
|
||||
cfg: params.cfg,
|
||||
@@ -82,6 +88,7 @@ export async function runStartupMatrixMigration(params: {
|
||||
await runBestEffortMatrixMigrationStep({
|
||||
label: "legacy Matrix encrypted-state preparation",
|
||||
log: params.log,
|
||||
logPrefix,
|
||||
run: () =>
|
||||
prepareLegacyCrypto({
|
||||
cfg: params.cfg,
|
||||
|
||||
@@ -36,6 +36,10 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js";
|
||||
import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js";
|
||||
import { getMachineDisplayName } from "../infra/machine-name.js";
|
||||
import { ensureOpenClawCliOnPath } from "../infra/path-env.js";
|
||||
import {
|
||||
detectPluginInstallPathIssue,
|
||||
formatPluginInstallPathIssue,
|
||||
} from "../infra/plugin-install-path-warnings.js";
|
||||
import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js";
|
||||
import {
|
||||
primeRemoteSkillsCache,
|
||||
@@ -105,6 +109,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
||||
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
||||
import { logGatewayStartup } from "./server-startup-log.js";
|
||||
import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js";
|
||||
import { startGatewaySidecars } from "./server-startup.js";
|
||||
import { startGatewayTailscaleExposure } from "./server-tailscale.js";
|
||||
import { createWizardSessionTracker } from "./server-wizard-sessions.js";
|
||||
@@ -519,6 +524,27 @@ 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);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const runStartupMatrixMigrationMock = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock("./server-startup-matrix-migration.js", () => ({
|
||||
runStartupMatrixMigration: runStartupMatrixMigrationMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("gateway startup Matrix migration wiring", () => {
|
||||
let server: Awaited<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(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,8 @@ 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,
|
||||
@@ -24,6 +26,8 @@ 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;
|
||||
@@ -32,6 +36,8 @@ describe("bundle plugin hooks", () => {
|
||||
|
||||
afterEach(() => {
|
||||
clearInternalHooks();
|
||||
clearPluginDiscoveryCache();
|
||||
clearPluginManifestRegistryCache();
|
||||
if (previousBundledHooksDir === undefined) {
|
||||
delete process.env.OPENCLAW_BUNDLED_HOOKS_DIR;
|
||||
} else {
|
||||
|
||||
@@ -37,6 +37,7 @@ 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)
|
||||
|
||||
@@ -14,14 +14,7 @@ import {
|
||||
resolveHookInvocationPolicy,
|
||||
} from "./frontmatter.js";
|
||||
import { resolvePluginHookDirs } from "./plugin-hooks.js";
|
||||
import type {
|
||||
Hook,
|
||||
HookEligibilityContext,
|
||||
HookEntry,
|
||||
HookSnapshot,
|
||||
HookSource,
|
||||
ParsedHookFrontmatter,
|
||||
} from "./types.js";
|
||||
import type { Hook, HookEligibilityContext, HookEntry, HookSnapshot, HookSource } from "./types.js";
|
||||
|
||||
type HookPackageManifest = {
|
||||
name?: string;
|
||||
@@ -81,11 +74,19 @@ function loadHookFromDir(params: {
|
||||
nameHint?: string;
|
||||
}): Hook | null {
|
||||
const hookMdPath = path.join(params.hookDir, "HOOK.md");
|
||||
const content = readBoundaryFileUtf8({
|
||||
const safeHookMdPath = resolveBoundaryFilePath({
|
||||
absolutePath: hookMdPath,
|
||||
rootPath: params.hookDir,
|
||||
boundaryLabel: "hook directory",
|
||||
});
|
||||
if (!safeHookMdPath) {
|
||||
return null;
|
||||
}
|
||||
const content = readBoundaryFileUtf8({
|
||||
absolutePath: safeHookMdPath,
|
||||
rootPath: params.hookDir,
|
||||
boundaryLabel: "hook directory",
|
||||
});
|
||||
if (content === null) {
|
||||
return null;
|
||||
}
|
||||
@@ -127,7 +128,8 @@ function loadHookFromDir(params: {
|
||||
description,
|
||||
source: params.source,
|
||||
pluginId: params.pluginId,
|
||||
filePath: hookMdPath,
|
||||
frontmatter,
|
||||
filePath: safeHookMdPath,
|
||||
baseDir,
|
||||
handlerPath,
|
||||
};
|
||||
@@ -212,15 +214,7 @@ export function loadHookEntriesFromDir(params: {
|
||||
pluginId: params.pluginId,
|
||||
});
|
||||
return hooks.map((hook) => {
|
||||
let frontmatter: ParsedHookFrontmatter = {};
|
||||
const raw = readBoundaryFileUtf8({
|
||||
absolutePath: hook.filePath,
|
||||
rootPath: hook.baseDir,
|
||||
boundaryLabel: "hook directory",
|
||||
});
|
||||
if (raw !== null) {
|
||||
frontmatter = parseFrontmatter(raw);
|
||||
}
|
||||
const frontmatter = hook.frontmatter ?? {};
|
||||
const entry: HookEntry = {
|
||||
hook: {
|
||||
...hook,
|
||||
@@ -303,15 +297,7 @@ function loadHookEntries(
|
||||
}
|
||||
|
||||
return Array.from(merged.values()).map((hook) => {
|
||||
let frontmatter: ParsedHookFrontmatter = {};
|
||||
const raw = readBoundaryFileUtf8({
|
||||
absolutePath: hook.filePath,
|
||||
rootPath: hook.baseDir,
|
||||
boundaryLabel: "hook directory",
|
||||
});
|
||||
if (raw !== null) {
|
||||
frontmatter = parseFrontmatter(raw);
|
||||
}
|
||||
const frontmatter = hook.frontmatter ?? {};
|
||||
return {
|
||||
hook,
|
||||
frontmatter,
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
isMatrixLegacyCryptoInspectorAvailable,
|
||||
loadMatrixLegacyCryptoInspector,
|
||||
@@ -89,13 +90,13 @@ describe("matrix plugin helper resolution", () => {
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true);
|
||||
const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({
|
||||
@@ -160,13 +161,13 @@ describe("matrix plugin helper resolution", () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const cfg = {
|
||||
const cfg: OpenClawConfig = {
|
||||
plugins: {
|
||||
load: {
|
||||
paths: [customRoot],
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
};
|
||||
|
||||
expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false);
|
||||
await expect(
|
||||
|
||||
@@ -143,6 +143,24 @@ 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,
|
||||
|
||||
@@ -146,11 +146,15 @@ 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({
|
||||
@@ -165,7 +169,7 @@ export async function resolveMessageChannelSelection(params: {
|
||||
if (fallback) {
|
||||
return {
|
||||
channel: fallback,
|
||||
configured: await listConfiguredMessageChannels(params.cfg),
|
||||
configured: await resolveConfigured(),
|
||||
source: "tool-context-fallback",
|
||||
};
|
||||
}
|
||||
@@ -176,7 +180,7 @@ export async function resolveMessageChannelSelection(params: {
|
||||
}
|
||||
return {
|
||||
channel: availableExplicit,
|
||||
configured: await listConfiguredMessageChannels(params.cfg),
|
||||
configured: await resolveConfigured(),
|
||||
source: "explicit",
|
||||
};
|
||||
}
|
||||
@@ -188,12 +192,12 @@ export async function resolveMessageChannelSelection(params: {
|
||||
if (fallback) {
|
||||
return {
|
||||
channel: fallback,
|
||||
configured: await listConfiguredMessageChannels(params.cfg),
|
||||
configured: await resolveConfigured(),
|
||||
source: "tool-context-fallback",
|
||||
};
|
||||
}
|
||||
|
||||
const configured = await listConfiguredMessageChannels(params.cfg);
|
||||
const configured = await resolveConfigured();
|
||||
if (configured.length === 1) {
|
||||
return { channel: configured[0], configured, source: "single-configured" };
|
||||
}
|
||||
|
||||
@@ -222,10 +222,12 @@ async function resolveChannel(
|
||||
params: Record<string, unknown>,
|
||||
toolContext?: { currentChannelProvider?: string },
|
||||
) {
|
||||
const explicitChannel = readStringParam(params, "channel");
|
||||
const selection = await resolveMessageChannelSelection({
|
||||
cfg,
|
||||
channel: readStringParam(params, "channel"),
|
||||
channel: explicitChannel,
|
||||
fallbackChannel: toolContext?.currentChannelProvider,
|
||||
includeConfigured: !explicitChannel,
|
||||
});
|
||||
if (selection.source === "tool-context-fallback") {
|
||||
params.channel = selection.channel;
|
||||
@@ -318,14 +320,13 @@ 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)]
|
||||
: configured;
|
||||
: await listConfiguredMessageChannels(input.cfg);
|
||||
if (targetChannels.length === 0) {
|
||||
throw new Error("Broadcast requires at least one configured channel.");
|
||||
}
|
||||
const results: Array<{
|
||||
channel: ChannelId;
|
||||
to: string;
|
||||
|
||||
@@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record<ChannelMessageActionName, Messag
|
||||
timeout: "none",
|
||||
kick: "none",
|
||||
ban: "none",
|
||||
"set-profile": "none",
|
||||
"set-presence": "none",
|
||||
"download-file": "none",
|
||||
};
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 { createMSTeamsTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js";
|
||||
import {
|
||||
createChannelTestPluginBase,
|
||||
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";
|
||||
|
||||
@@ -242,6 +246,78 @@ 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([
|
||||
|
||||
@@ -132,10 +132,12 @@ 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: params.channel,
|
||||
channel: explicitChannel || undefined,
|
||||
includeConfigured: !explicitChannel,
|
||||
})
|
||||
).channel;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ 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);
|
||||
});
|
||||
|
||||
@@ -75,6 +76,9 @@ 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);
|
||||
|
||||
@@ -37,11 +37,20 @@ 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.startsWith("..") && !path.win32.isAbsolute(relative));
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." &&
|
||||
!relative.startsWith(`..\\`) &&
|
||||
!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.startsWith("..") && !path.isAbsolute(relative));
|
||||
return (
|
||||
relative === "" ||
|
||||
(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
OpenClawPluginConfigSchema,
|
||||
OpenClawPluginDefinition,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
PluginCommandContext,
|
||||
} from "../plugins/types.js";
|
||||
|
||||
export type {
|
||||
@@ -52,6 +53,7 @@ export type {
|
||||
ProviderAuthResult,
|
||||
OpenClawPluginCommandDefinition,
|
||||
OpenClawPluginDefinition,
|
||||
PluginCommandContext,
|
||||
PluginLogger,
|
||||
PluginInteractiveTelegramHandlerContext,
|
||||
} from "../plugins/types.js";
|
||||
|
||||
@@ -35,6 +35,11 @@ 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";',
|
||||
],
|
||||
|
||||
@@ -11,6 +11,10 @@ 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 () => {
|
||||
@@ -46,8 +50,6 @@ 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);
|
||||
@@ -56,7 +58,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(resolvedPluginRoot);
|
||||
expect(loadedServer.cwd).toBe(pluginRoot);
|
||||
} finally {
|
||||
env.restore();
|
||||
}
|
||||
@@ -178,19 +180,19 @@ describe("loadEnabledBundleMcpConfig", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const resolvedPluginRoot = await fs.realpath(pluginRoot);
|
||||
|
||||
expect(loaded.diagnostics).toEqual([]);
|
||||
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,
|
||||
},
|
||||
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,
|
||||
});
|
||||
} finally {
|
||||
env.restore();
|
||||
|
||||
@@ -327,7 +327,7 @@ export function loadEnabledBundleMcpConfig(params: {
|
||||
|
||||
const loaded = loadBundleMcpConfig({
|
||||
pluginId: record.id,
|
||||
rootDir: record.rootDir,
|
||||
rootDir: record.format === "bundle" ? record.source : record.rootDir,
|
||||
bundleFormat: record.bundleFormat,
|
||||
});
|
||||
merged = applyMergePatch(merged, loaded.config) as BundleMcpConfig;
|
||||
|
||||
@@ -108,65 +108,6 @@ 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",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
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,
|
||||
@@ -26,28 +25,11 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & {
|
||||
pluginRoot?: string;
|
||||
};
|
||||
|
||||
type PluginCommandRegistryState = {
|
||||
pluginCommands: Map<string, RegisteredPluginCommand>;
|
||||
registryLocked: boolean;
|
||||
};
|
||||
// Registry of plugin commands
|
||||
const pluginCommands: Map<string, RegisteredPluginCommand> = new Map();
|
||||
|
||||
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;
|
||||
// Lock to prevent modifications during command execution
|
||||
let registryLocked = false;
|
||||
|
||||
// Maximum allowed length for command arguments (defense in depth)
|
||||
const MAX_ARGS_LENGTH = 4096;
|
||||
@@ -190,7 +172,7 @@ export function registerPluginCommand(
|
||||
opts?: { pluginName?: string; pluginRoot?: string },
|
||||
): CommandRegistrationResult {
|
||||
// Prevent registration while commands are being processed
|
||||
if (registryState.registryLocked) {
|
||||
if (registryLocked) {
|
||||
return { ok: false, error: "Cannot register commands while processing is in progress" };
|
||||
}
|
||||
|
||||
@@ -469,7 +451,7 @@ export async function executePluginCommand(params: {
|
||||
};
|
||||
|
||||
// Lock registry during execution to prevent concurrent modifications
|
||||
registryState.registryLocked = true;
|
||||
registryLocked = true;
|
||||
try {
|
||||
const result = await command.handler(ctx);
|
||||
logVerbose(
|
||||
@@ -482,7 +464,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 {
|
||||
registryState.registryLocked = false;
|
||||
registryLocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
29
src/security/scan-paths.test.ts
Normal file
29
src/security/scan-paths.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { isPathInside as isBoundaryPathInside } from "../infra/path-guards.js";
|
||||
|
||||
export function isPathInside(basePath: string, candidatePath: string): boolean {
|
||||
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));
|
||||
return isBoundaryPathInside(basePath, candidatePath);
|
||||
}
|
||||
|
||||
function safeRealpathSync(filePath: string): string | null {
|
||||
|
||||
8
test/helpers/extensions/matrix-route-test.ts
Normal file
8
test/helpers/extensions/matrix-route-test.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
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";
|
||||
Reference in New Issue
Block a user