Merge branch 'main' into docs/delegation-invariants-draft

This commit is contained in:
Wicky Zhang 2026-03-19 22:57:05 +08:00 committed by GitHub
commit 2a617a34d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
145 changed files with 4617 additions and 1964 deletions

View File

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

1
.gitignore vendored
View File

@ -31,6 +31,7 @@ apps/android/.gradle/
apps/android/app/build/
apps/android/.cxx/
apps/android/.kotlin/
apps/android/benchmark/results/
# Bun build artifacts
*.bun-build

View File

@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev.
- Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman.
- Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97.
- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat.
### Fixes
@ -52,6 +53,7 @@ Docs: https://docs.openclaw.ai
- Plugins/imports: fix stale googlechat runtime-api import paths and signal SDK circular re-exports broken by recent plugin-sdk refactors. Thanks @BunsDev.
- Google auth/Node 25: patch `gaxios` to use native fetch without injecting `globalThis.window`, while translating proxy and mTLS transport settings so Google Vertex and Google Chat auth keep working on Node 25. (#47914) Thanks @pdd-cli.
- Gateway/startup: load bundled channel plugins from compiled `dist/extensions` entries in built installs, so gateway boot no longer recompiles bundled extension TypeScript on every startup and WhatsApp-class cold starts drop back to seconds instead of tens of seconds or worse. (#47560) Thanks @ngutman.
- Agents/openai-responses: strip `prompt_cache_key` and `prompt_cache_retention` for non-OpenAI-compatible Responses endpoints while keeping them on direct OpenAI and Azure OpenAI paths, so third-party OpenAI-compatible providers no longer reject those requests with HTTP 400. (#49877) Thanks @ShaunTsai.
- Plugins/context engines: enforce owner-aware context-engine registration on both loader and public SDK paths so plugins cannot spoof privileged ownership, claim the core `legacy` engine id, or overwrite an existing engine id through direct SDK imports. (#47595) Thanks @vincentkoc.
- Browser/remote CDP: honor strict browser SSRF policy during remote CDP reachability and `/json/version` discovery checks, redact sensitive `cdpUrl` tokens from status output, and warn when remote CDP targets private/internal hosts.
- Gateway/plugins: pin runtime webhook routes to the gateway startup registry so channel webhooks keep working across plugin-registry churn, and make plugin auth + dispatch resolve routes from the same live HTTP-route registry. (#47902) Fixes #46924 and #47041. Thanks @steipete.
@ -158,7 +160,9 @@ Docs: https://docs.openclaw.ai
- Google Chat/runtime API: thin the private runtime barrel onto the curated public SDK surface while keeping public Google Chat exports intact. (#49504) Thanks @scoootscooob.
- WhatsApp: stabilize inbound monitor and setup tests (#50007) Thanks @joshavant.
- Matrix: make onboarding status runtime-safe (#49995) Thanks @joshavant.
- Channels: stabilize lane harness and monitor tests (#50167) Thanks @joshavant.
- WhatsApp/active-listener: pin the active listener registry to a `globalThis` singleton so split WhatsApp bundle chunks share one listener map and outbound sends stop missing the registered session. (#47433) Thanks @clawdia67.
- Plugins/WhatsApp: share split-load singleton state for plugin command registration and active WhatsApp listeners so duplicate module graphs no longer lose native plugin commands or outbound listener state. (#50418) Thanks @huntharo.
### Breaking

View File

@ -129,7 +129,13 @@ class MainViewModel(app: Application) : AndroidViewModel(app) {
fun setForeground(value: Boolean) {
foreground = value
runtimeRef.value?.setForeground(value)
val runtime =
if (value && prefs.onboardingCompleted.value) {
ensureRuntime()
} else {
runtimeRef.value
}
runtime?.setForeground(value)
}
fun setDisplayName(value: String) {

View File

@ -568,43 +568,8 @@ class NodeRuntime(
scope.launch(Dispatchers.Default) {
gateways.collect { list ->
if (list.isNotEmpty()) {
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
// UX parity with iOS: only set once when unset.
if (lastDiscoveredStableId.value.trim().isEmpty()) {
prefs.setLastDiscoveredStableId(list.first().stableId)
}
}
if (didAutoConnect) return@collect
if (_isConnected.value) return@collect
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isNotEmpty() && port in 1..65535) {
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
if (!manualTls.value) return@collect
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(GatewayEndpoint.manual(host = host, port = port))
}
return@collect
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return@collect
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return@collect
didAutoConnect = true
connect(target)
seedLastDiscoveredGateway(list)
autoConnectIfNeeded()
}
}
@ -629,11 +594,53 @@ class NodeRuntime(
fun setForeground(value: Boolean) {
_isForeground.value = value
if (!value) {
if (value) {
reconnectPreferredGatewayOnForeground()
} else {
stopActiveVoiceSession()
}
}
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
if (list.isEmpty()) return
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
prefs.setLastDiscoveredStableId(list.first().stableId)
}
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
if (manualEnabled.value) {
val host = manualHost.value.trim()
val port = manualPort.value
if (host.isEmpty() || port !in 1..65535) return null
return GatewayEndpoint.manual(host = host, port = port)
}
val targetStableId = lastDiscoveredStableId.value.trim()
if (targetStableId.isEmpty()) return null
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
if (storedFingerprint.isEmpty()) return null
return endpoint
}
private fun autoConnectIfNeeded() {
if (didAutoConnect) return
if (_isConnected.value) return
val endpoint = resolvePreferredGatewayEndpoint() ?: return
didAutoConnect = true
connect(endpoint)
}
private fun reconnectPreferredGatewayOnForeground() {
if (_isConnected.value) return
if (_pendingGatewayTrust.value != null) return
if (connectedEndpoint != null) {
refreshGatewayConnection()
return
}
resolvePreferredGatewayEndpoint()?.let(::connect)
}
fun setDisplayName(value: String) {
prefs.setDisplayName(value)
}

View File

@ -0,0 +1,430 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
PACKAGE="ai.openclaw.app"
ACTIVITY=".MainActivity"
DEVICE_SERIAL=""
INSTALL_APP="1"
LAUNCH_RUNS="4"
SCREEN_LOOPS="6"
CHAT_LOOPS="8"
POLL_ATTEMPTS="40"
POLL_INTERVAL_SECONDS="0.3"
SCREEN_MODE="transition"
CHAT_MODE="session-switch"
usage() {
cat <<'EOF'
Usage:
./scripts/perf-online-benchmark.sh [options]
Measures the fully-online Android app path on a connected device/emulator.
Assumes the app can reach a live gateway and will show "Connected" in the UI.
Options:
--device <serial> adb device serial
--package <pkg> package name (default: ai.openclaw.app)
--activity <activity> launch activity (default: .MainActivity)
--skip-install skip :app:installDebug
--launch-runs <n> launch-to-connected runs (default: 4)
--screen-loops <n> screen benchmark loops (default: 6)
--chat-loops <n> chat benchmark loops (default: 8)
--screen-mode <mode> transition | scroll (default: transition)
--chat-mode <mode> session-switch | scroll (default: session-switch)
-h, --help show help
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--device)
DEVICE_SERIAL="${2:-}"
shift 2
;;
--package)
PACKAGE="${2:-}"
shift 2
;;
--activity)
ACTIVITY="${2:-}"
shift 2
;;
--skip-install)
INSTALL_APP="0"
shift
;;
--launch-runs)
LAUNCH_RUNS="${2:-}"
shift 2
;;
--screen-loops)
SCREEN_LOOPS="${2:-}"
shift 2
;;
--chat-loops)
CHAT_LOOPS="${2:-}"
shift 2
;;
--screen-mode)
SCREEN_MODE="${2:-}"
shift 2
;;
--chat-mode)
CHAT_MODE="${2:-}"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "Unknown arg: $1" >&2
usage >&2
exit 2
;;
esac
done
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "$1 required but missing." >&2
exit 1
fi
}
require_cmd adb
require_cmd awk
require_cmd rg
require_cmd node
adb_cmd() {
if [[ -n "$DEVICE_SERIAL" ]]; then
adb -s "$DEVICE_SERIAL" "$@"
else
adb "$@"
fi
}
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
echo "No connected Android device (adb state=device)." >&2
exit 1
fi
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
echo "Multiple adb devices found. Pass --device <serial>." >&2
adb devices -l >&2
exit 1
fi
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
exit 2
fi
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
exit 2
fi
mkdir -p "$RESULTS_DIR"
timestamp="$(date +%Y%m%d-%H%M%S)"
run_dir="$RESULTS_DIR/online-$timestamp"
mkdir -p "$run_dir"
cleanup() {
rm -f "$run_dir"/ui-*.xml
}
trap cleanup EXIT
if [[ "$INSTALL_APP" == "1" ]]; then
(
cd "$ANDROID_DIR"
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
)
fi
read -r display_width display_height <<<"$(
adb_cmd shell wm size \
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
)"
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
echo "Failed to read device display size." >&2
exit 1
fi
pct_of() {
local total="$1"
local pct="$2"
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
}
tab_connect_x="$(pct_of "$display_width" "0.11")"
tab_chat_x="$(pct_of "$display_width" "0.31")"
tab_screen_x="$(pct_of "$display_width" "0.69")"
tab_y="$(pct_of "$display_height" "0.93")"
chat_session_y="$(pct_of "$display_height" "0.13")"
chat_session_left_x="$(pct_of "$display_width" "0.16")"
chat_session_right_x="$(pct_of "$display_width" "0.85")"
center_x="$(pct_of "$display_width" "0.50")"
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
dump_ui() {
local name="$1"
local file="$run_dir/ui-$name.xml"
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
printf '%s\n' "$file"
}
ui_has() {
local pattern="$1"
local name="$2"
local file
file="$(dump_ui "$name")"
rg -q "$pattern" "$file"
}
wait_for_pattern() {
local pattern="$1"
local prefix="$2"
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
if ui_has "$pattern" "$prefix-$attempt"; then
return 0
fi
sleep "$POLL_INTERVAL_SECONDS"
done
return 1
}
ensure_connected() {
if ! wait_for_pattern 'text="Connected"' "connected"; then
echo "App never reached visible Connected state." >&2
exit 1
fi
}
ensure_screen_online() {
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 2
if ! ui_has 'android\.webkit\.WebView' "screen"; then
echo "Screen benchmark expected a live WebView." >&2
exit 1
fi
}
ensure_chat_online() {
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
sleep 2
if ! ui_has 'Type a message' "chat"; then
echo "Chat benchmark expected the live chat composer." >&2
exit 1
fi
}
capture_mem() {
local file="$1"
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
}
start_cpu_sampler() {
local file="$1"
local samples="$2"
: >"$file"
(
for _ in $(seq 1 "$samples"); do
adb_cmd shell top -b -n 1 \
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
sleep 0.5
done
) &
CPU_SAMPLER_PID="$!"
}
summarize_cpu() {
local file="$1"
local prefix="$2"
local avg max median count
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
max="$(sort -n "$file" | tail -n 1)"
median="$(
sort -n "$file" \
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
)"
count="$(wc -l <"$file" | tr -d ' ')"
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
}
summarize_mem() {
local file="$1"
local prefix="$2"
awk -v prefix="$prefix" '
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
' "$file" >>"$run_dir/summary.txt"
}
summarize_gfx() {
local file="$1"
local prefix="$2"
awk -v prefix="$prefix" '
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
/Janky frames:/ && $4 ~ /\(/ {
pct=$4
gsub(/[()%]/, "", pct)
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
}
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
' "$file" >>"$run_dir/summary.txt"
}
measure_launch() {
: >"$run_dir/launch-runs.txt"
for run in $(seq 1 "$LAUNCH_RUNS"); do
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
sleep 1
start_ms="$(node -e 'console.log(Date.now())')"
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
connected_ms="timeout"
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
if ui_has 'text="Connected"' "launch-run-$run"; then
now_ms="$(node -e 'console.log(Date.now())')"
connected_ms="$((now_ms - start_ms))"
break
fi
sleep "$POLL_INTERVAL_SECONDS"
done
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
| tee -a "$run_dir/launch-runs.txt"
done
awk -F'[ =]' '
/total_time_ms=[0-9]+/ {
value=$4
sum+=value
count+=1
if (min==0 || value<min) min=value
if (value>max) max=value
}
END {
if (count==0) exit
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
}
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
awk -F'[ =]' '
/connected_ms=[0-9]+/ {
value=$6
sum+=value
count+=1
if (min==0 || value<min) min=value
if (value>max) max=value
}
END {
if (count==0) exit
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
}
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
}
run_screen_benchmark() {
ensure_screen_online
capture_mem "$run_dir/screen-mem-before.txt"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
if [[ "$SCREEN_MODE" == "transition" ]]; then
for _ in $(seq 1 "$SCREEN_LOOPS"); do
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 1.0
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
sleep 0.8
done
else
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
sleep 1.5
for _ in $(seq 1 "$SCREEN_LOOPS"); do
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
sleep 0.35
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
sleep 0.35
done
fi
wait "$CPU_SAMPLER_PID"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
capture_mem "$run_dir/screen-mem-after.txt"
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
}
run_chat_benchmark() {
ensure_chat_online
capture_mem "$run_dir/chat-mem-before.txt"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
if [[ "$CHAT_MODE" == "session-switch" ]]; then
for _ in $(seq 1 "$CHAT_LOOPS"); do
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
sleep 0.8
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
sleep 0.8
done
else
for _ in $(seq 1 "$CHAT_LOOPS"); do
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
sleep 0.35
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
sleep 0.35
done
fi
wait "$CPU_SAMPLER_PID"
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
capture_mem "$run_dir/chat-mem-after.txt"
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
}
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
ensure_connected
measure_launch
ensure_connected
run_screen_benchmark
ensure_connected
run_chat_benchmark
printf 'results_dir=%s\n' "$run_dir"
cat "$run_dir/summary.txt"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
`- Failed creating a Matrix migration snapshot before repair: ...`
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...`
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.

View File

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

View File

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

View File

@ -1 +1,6 @@
export * from "openclaw/plugin-sdk/copilot-proxy";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type {
OpenClawPluginApi,
ProviderAuthContext,
ProviderAuthResult,
} from "openclaw/plugin-sdk/core";

View File

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

View File

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

View File

@ -3,58 +3,21 @@ import { vi } from "vitest";
export const sendMock: MockFn = vi.fn();
export const reactMock: MockFn = vi.fn();
export const recordInboundSessionMock: MockFn = vi.fn();
export const updateLastRouteMock: MockFn = vi.fn();
export const dispatchMock: MockFn = vi.fn();
export const readAllowFromStoreMock: MockFn = vi.fn();
export const upsertPairingRequestMock: MockFn = vi.fn();
vi.mock("./send.js", () => ({
addRoleDiscord: vi.fn(),
banMemberDiscord: vi.fn(),
createChannelDiscord: vi.fn(),
createScheduledEventDiscord: vi.fn(),
createThreadDiscord: vi.fn(),
deleteChannelDiscord: vi.fn(),
deleteMessageDiscord: vi.fn(),
editChannelDiscord: vi.fn(),
editMessageDiscord: vi.fn(),
fetchChannelInfoDiscord: vi.fn(),
fetchChannelPermissionsDiscord: vi.fn(),
fetchMemberInfoDiscord: vi.fn(),
fetchMessageDiscord: vi.fn(),
fetchReactionsDiscord: vi.fn(),
fetchRoleInfoDiscord: vi.fn(),
fetchVoiceStatusDiscord: vi.fn(),
hasAnyGuildPermissionDiscord: vi.fn(),
kickMemberDiscord: vi.fn(),
listGuildChannelsDiscord: vi.fn(),
listGuildEmojisDiscord: vi.fn(),
listPinsDiscord: vi.fn(),
listScheduledEventsDiscord: vi.fn(),
listThreadsDiscord: vi.fn(),
moveChannelDiscord: vi.fn(),
pinMessageDiscord: vi.fn(),
reactMessageDiscord: async (...args: unknown[]) => {
reactMock(...args);
},
readMessagesDiscord: vi.fn(),
removeChannelPermissionDiscord: vi.fn(),
removeOwnReactionsDiscord: vi.fn(),
removeReactionDiscord: vi.fn(),
removeRoleDiscord: vi.fn(),
searchMessagesDiscord: vi.fn(),
sendDiscordComponentMessage: vi.fn(),
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
sendPollDiscord: vi.fn(),
sendStickerDiscord: vi.fn(),
sendVoiceMessageDiscord: vi.fn(),
setChannelPermissionDiscord: vi.fn(),
timeoutMemberDiscord: vi.fn(),
unpinMessageDiscord: vi.fn(),
uploadEmojiDiscord: vi.fn(),
uploadStickerDiscord: vi.fn(),
}));
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
reactMessageDiscord: async (...args: unknown[]) => {
reactMock(...args);
},
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
@ -85,19 +48,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
};
});
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: vi.fn(() => undefined),
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
resolveSessionKey: vi.fn(),

View File

@ -58,28 +58,29 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
let lastDispatchCtx: Record<string, unknown> | undefined;
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
readStoreAllowFromForDmPolicy: async (params: {
provider: string;
accountId: string;
dmPolicy?: string | null;
shouldRead?: boolean | null;
}) => {
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
return [];
}
return await readAllowFromStoreMock(params.provider, params.accountId);
},
};
});
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../src/plugins/conversation-binding.js")>();
return {
...actual,
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
resolvePluginConversationBindingApprovalMock(...args),
buildPluginBindingResolvedText: (...args: unknown[]) =>
@ -87,14 +88,24 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
};
});
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
return {
...actual,
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
return {
...actual,
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
};
});
// agent-components.ts can bind the core dispatcher via reply-runtime re-exports,
// so keep this direct mock to avoid hitting real embedded-agent dispatch in tests.
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
const actual =
await importOriginal<
@ -106,16 +117,16 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import
};
});
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
return {
...actual,
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
};
});
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
@ -123,8 +134,8 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
};
});
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
return {
...actual,
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
@ -189,13 +200,13 @@ describe("agent components", () => {
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
expect(reply).toHaveBeenCalledTimes(1);
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
expect(pairingText).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code).toBeDefined();
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
it("blocks DM interactions in allowlist mode when sender is not in configured allowFrom", async () => {
@ -229,11 +240,7 @@ describe("agent components", () => {
expect(reply).toHaveBeenCalledWith({ content: "✓" });
expect(enqueueSystemEventMock).toHaveBeenCalled();
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
provider: "discord",
accountId: "default",
dmPolicy: "pairing",
});
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
});
it("allows DM component interactions in open mode without reading pairing store", async () => {
@ -831,10 +838,9 @@ describe("discord component interactions", () => {
await button.run(interaction, { cid: "btn_1" } as ComponentData);
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1);
expect(update).toHaveBeenCalledWith({ components: [] });
expect(followUp).toHaveBeenCalledWith({
content: "Binding approved.",
content: expect.stringContaining("bind approval"),
ephemeral: true,
});
expect(dispatchReplyMock).not.toHaveBeenCalled();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +1,2 @@
export * from "openclaw/plugin-sdk/open-prose";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";

View File

@ -1 +1,7 @@
export * from "openclaw/plugin-sdk/phone-control";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type {
OpenClawPluginApi,
OpenClawPluginCommandDefinition,
OpenClawPluginService,
PluginCommandContext,
} from "openclaw/plugin-sdk/core";

View File

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

View File

@ -1,5 +1,3 @@
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime";
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
import type { MockFn } from "openclaw/plugin-sdk/testing";
import { beforeEach, vi } from "vitest";
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
@ -73,6 +71,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
return {
...actual,
loadConfig: () => config,
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
};
});
@ -81,28 +83,51 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
return {
...actual,
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
dispatchInboundMessage: async (params: {
ctx: unknown;
cfg: unknown;
dispatcher: {
sendFinalReply: (payload: { text: string }) => boolean;
markComplete?: () => void;
waitForIdle?: () => Promise<void>;
};
}) => {
const resolved = await replyMock(params.ctx, {}, params.cfg);
const text = typeof resolved?.text === "string" ? resolved.text.trim() : "";
if (text) {
params.dispatcher.sendFinalReply({ text });
}
params.dispatcher.markComplete?.();
await params.dispatcher.waitForIdle?.();
return { queuedFinal: Boolean(text) };
},
};
});
vi.mock("./send.js", () => ({
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
}));
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
readSessionUpdatedAt: vi.fn(() => undefined),
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
sendTypingSignal: vi.fn().mockResolvedValue(true),
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
return {
...actual,
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
return {
...actual,
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
};
});
@ -129,7 +154,11 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
});
export function installSignalToolResultTestHooks() {
beforeEach(() => {
beforeEach(async () => {
const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([
import("openclaw/plugin-sdk/reply-runtime"),
import("openclaw/plugin-sdk/infra-runtime"),
]);
resetInboundDedupe();
config = {
messages: { responsePrefix: "PFX" },
@ -142,7 +171,7 @@ export function installSignalToolResultTestHooks() {
replyMock.mockReset();
updateLastRouteMock.mockReset();
streamMock.mockReset();
signalCheckMock.mockReset().mockResolvedValue({});
signalCheckMock.mockReset().mockResolvedValue({ ok: true });
signalRpcRequestMock.mockReset().mockResolvedValue({});
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
readAllowFromStoreMock.mockReset().mockResolvedValue([]);

View File

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

View File

@ -1,23 +1,6 @@
import type { WebClient } from "@slack/web-api";
import { vi } from "vitest";
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => ({}),
};
});
vi.mock("./accounts.js", () => ({
resolveSlackAccount: () => ({
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
}),
}));
export type SlackEditTestClient = WebClient & {
chat: {
update: ReturnType<typeof vi.fn>;
@ -33,8 +16,35 @@ export type SlackSendTestClient = WebClient & {
};
};
const slackBlockTestState = vi.hoisted(() => ({
account: {
accountId: "default",
botToken: "xoxb-test",
botTokenSource: "config",
config: {},
},
config: {},
}));
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => slackBlockTestState.config,
};
});
vi.mock("./accounts.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./accounts.js")>();
return {
...actual,
resolveSlackAccount: () => slackBlockTestState.account,
};
});
// Kept for compatibility with existing tests; mocks install at module evaluation.
export function installSlackBlockTestMocks() {
// Backward compatible no-op. Mocks are hoisted at module scope.
return;
}
export function createSlackEditTestClient(): SlackEditTestClient {

View File

@ -202,37 +202,30 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
const replyResolver: typeof actual.getReplyFromConfig = (...args) =>
slackTestState.replyMock(...args) as ReturnType<typeof actual.getReplyFromConfig>;
return {
...actual,
dispatchInboundMessage: async (params: {
ctx: unknown;
replyOptions?: {
onReplyStart?: () => Promise<void> | void;
onAssistantMessageStart?: () => Promise<void> | void;
};
dispatcher: {
sendFinalReply: (payload: unknown) => boolean;
waitForIdle: () => Promise<void>;
markComplete: () => void;
};
}) => {
const reply = await slackTestState.replyMock(params.ctx, {
...params.replyOptions,
onReplyStart:
params.replyOptions?.onReplyStart ?? params.replyOptions?.onAssistantMessageStart,
});
const queuedFinal = reply ? params.dispatcher.sendFinalReply(reply) : false;
params.dispatcher.markComplete();
await params.dispatcher.waitForIdle();
return {
queuedFinal,
counts: {
tool: 0,
block: 0,
final: queuedFinal ? 1 : 0,
},
};
},
getReplyFromConfig: replyResolver,
dispatchInboundMessage: (params: Parameters<typeof actual.dispatchInboundMessage>[0]) =>
actual.dispatchInboundMessage({
...params,
replyResolver,
}),
dispatchInboundMessageWithBufferedDispatcher: (
params: Parameters<typeof actual.dispatchInboundMessageWithBufferedDispatcher>[0],
) =>
actual.dispatchInboundMessageWithBufferedDispatcher({
...params,
replyResolver,
}),
dispatchInboundMessageWithDispatcher: (
params: Parameters<typeof actual.dispatchInboundMessageWithDispatcher>[0],
) =>
actual.dispatchInboundMessageWithDispatcher({
...params,
replyResolver,
}),
};
});
@ -246,9 +239,13 @@ vi.mock("./resolve-users.js", () => ({
entries.map((input) => ({ input, resolved: false })),
}));
vi.mock("./send.js", () => ({
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
}));
vi.mock("./send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./send.js")>();
return {
...actual,
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
@ -265,20 +262,12 @@ vi.mock("@slack/bolt", () => {
const { handlers, client: slackClient } = ensureSlackTestRuntime();
class App {
client = slackClient;
receiver = {
client: {
on: vi.fn(),
off: vi.fn(),
},
};
event(name: string, handler: SlackHandler) {
handlers.set(name, handler);
}
command = vi.fn();
action = vi.fn();
options = vi.fn();
view = vi.fn();
shortcut = vi.fn();
command() {
/* no-op */
}
start = vi.fn().mockResolvedValue(undefined);
stop = vi.fn().mockResolvedValue(undefined);
}

View File

@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
resolveAgentRouteMock: vi.fn(),
finalizeInboundContextMock: vi.fn(),
resolveConversationLabelMock: vi.fn(),
createChannelReplyPipelineMock: vi.fn(),
createReplyPrefixOptionsMock: vi.fn(),
recordSessionMetaFromInboundMock: vi.fn(),
resolveStorePathMock: vi.fn(),
}));
@ -43,27 +43,16 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
return {
...actual,
resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args),
createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args),
recordInboundSessionMetaSafe: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>();
return {
...actual,
createChannelReplyPipeline: (...args: unknown[]) =>
mocks.createChannelReplyPipelineMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
recordSessionMetaFromInbound: (...args: unknown[]) =>
mocks.recordSessionMetaFromInboundMock(...args),
resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args),
};
});
@ -75,7 +64,7 @@ type SlashHarnessMocks = {
resolveAgentRouteMock: ReturnType<typeof vi.fn>;
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
createChannelReplyPipelineMock: ReturnType<typeof vi.fn>;
createReplyPrefixOptionsMock: ReturnType<typeof vi.fn>;
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
resolveStorePathMock: ReturnType<typeof vi.fn>;
};
@ -95,7 +84,7 @@ export function resetSlackSlashMocks() {
});
mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx);
mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined);
mocks.createChannelReplyPipelineMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined);
mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
}

View File

@ -1,7 +1,7 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
vi.mock("./slash-commands.runtime.js", () => {
const usageCommand = { key: "usage", nativeName: "usage" };
const reportCommand = { key: "report", nativeName: "report" };
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
@ -180,21 +180,26 @@ vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
});
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
let registerSlackMonitorSlashCommands: RegisterFn;
let registerSlackMonitorSlashCommandsPromise: Promise<RegisterFn> | undefined;
async function loadRegisterSlackMonitorSlashCommands(): Promise<RegisterFn> {
registerSlackMonitorSlashCommandsPromise ??= import("./slash.js").then((module) => {
const typed = module as unknown as {
registerSlackMonitorSlashCommands: RegisterFn;
};
return typed.registerSlackMonitorSlashCommands;
});
return await registerSlackMonitorSlashCommandsPromise;
}
const { dispatchMock } = getSlackSlashMocks();
beforeAll(async () => {
({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as {
registerSlackMonitorSlashCommands: RegisterFn;
});
});
beforeEach(() => {
resetSlackSlashMocks();
});
async function registerCommands(ctx: unknown, account: unknown) {
const registerSlackMonitorSlashCommands = await loadRegisterSlackMonitorSlashCommands();
await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
}

View File

@ -1 +1,2 @@
export * from "openclaw/plugin-sdk/talk-voice";
export { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
export type { OpenClawPluginApi } from "openclaw/plugin-sdk/core";

View File

@ -59,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = {
textFragmentGapMs: 30,
} as const;
async function withIsolatedStateDirAsync<T>(fn: () => Promise<T>): Promise<T> {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-"));
return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
try {
return await fn();
} finally {
fs.rmSync(stateDir, { recursive: true, force: true });
}
});
}
async function withConfigPathAsync<T>(cfg: unknown, fn: () => Promise<T>): Promise<T> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-"));
const configPath = path.join(dir, "openclaw.json");
fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8");
return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
try {
return await fn();
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
}
describe("createTelegramBot", () => {
beforeAll(() => {
process.env.TZ = "UTC";
@ -250,107 +274,115 @@ describe("createTelegramBot", () => {
const cases = [
{
name: "new unknown sender",
upsertResults: [{ code: "PAIRME12", created: true }],
messages: ["hello"],
expectedSendCount: 1,
expectPairingText: true,
pairingUpsertResults: [{ code: "PAIRCODE", created: true }],
},
{
name: "already pending request",
upsertResults: [
{ code: "PAIRME12", created: true },
{ code: "PAIRME12", created: false },
],
messages: ["hello", "hello again"],
expectedSendCount: 1,
expectPairingText: false,
pairingUpsertResults: [
{ code: "PAIRCODE", created: true },
{ code: "PAIRCODE", created: false },
],
},
] as const;
for (const testCase of cases) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
await withIsolatedStateDirAsync(async () => {
for (const [index, testCase] of cases.entries()) {
onSpy.mockClear();
sendMessageSpy.mockClear();
replySpy.mockClear();
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
let pairingUpsertCall = 0;
upsertChannelPairingRequest.mockImplementation(async () => {
const result =
testCase.pairingUpsertResults[
Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1)
];
pairingUpsertCall += 1;
return result;
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
const senderId = Number(`${Date.now()}${index}`.slice(-9));
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
date: 1736380800,
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`);
expect(pairingText, testCase.name).toContain("Pairing code:");
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
expect(code, testCase.name).toBeDefined();
expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`);
expect(pairingText, testCase.name).not.toContain("<code>");
}
});
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockClear();
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true });
for (const result of testCase.upsertResults) {
upsertChannelPairingRequest.mockResolvedValueOnce(result);
}
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}01`.slice(-9));
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
for (const text of testCase.messages) {
await handler({
message: {
chat: { id: 1234, type: "private" },
text,
message_id: 410,
date: 1736380800,
from: { id: 999, username: "random" },
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
getFile: getFileSpy,
});
}
expect(replySpy, testCase.name).not.toHaveBeenCalled();
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
if (testCase.expectPairingText) {
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
expect(pairingText, testCase.name).toContain("Your Telegram user id: 999");
expect(pairingText, testCase.name).toContain("Pairing code:");
expect(pairingText, testCase.name).toContain("PAIRME12");
expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12");
expect(pairingText, testCase.name).not.toContain("<code>");
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
}
});
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 410,
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
loadConfig.mockReturnValue({
@ -393,48 +425,51 @@ describe("createTelegramBot", () => {
}
});
it("blocks unauthorized DM media groups before any photo download", async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: 999, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
await withIsolatedStateDirAsync(async () => {
loadConfig.mockReturnValue({
channels: { telegram: { dmPolicy: "pairing" } },
});
readChannelAllowFromStore.mockResolvedValue([]);
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
sendMessageSpy.mockClear();
replySpy.mockClear();
const senderId = Number(`${Date.now()}02`.slice(-9));
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
async () =>
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
status: 200,
headers: { "content-type": "image/jpeg" },
}),
);
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
try {
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 1234, type: "private" },
message_id: 412,
media_group_id: "dm-album-1",
date: 1736380800,
photo: [{ file_id: "p1" }],
from: { id: senderId, username: "random" },
},
me: { username: "openclaw_bot" },
getFile: getFileSpy,
});
expect(getFileSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
expect(replySpy).not.toHaveBeenCalled();
} finally {
fetchSpy.mockRestore();
}
});
});
it("triggers typing cue via onReplyStart", async () => {
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
@ -851,13 +886,15 @@ describe("createTelegramBot", () => {
});
it("routes DMs by telegram accountId binding", async () => {
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
allowFrom: ["*"],
accounts: {
opie: {
botToken: "tok-opie",
dmPolicy: "open",
allowFrom: ["*"],
},
},
},
@ -868,27 +905,30 @@ describe("createTelegramBot", () => {
match: { channel: "telegram", accountId: "opie" },
},
],
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
createTelegramBot({ token: "tok", accountId: "opie" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "private" },
from: { id: 999, username: "testuser" },
text: "hello",
date: 1736380800,
message_id: 42,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.AccountId).toBe("opie");
expect(payload.SessionKey).toBe("agent:opie:main");
});
it("reloads DM routing bindings between messages without recreating the bot", async () => {
@ -1192,26 +1232,28 @@ describe("createTelegramBot", () => {
];
for (const testCase of cases) {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
await withConfigPathAsync(testCase.config, async () => {
resetHarnessSpies();
loadConfig.mockReturnValue(testCase.config);
await dispatchMessage({
message: {
chat: {
id: -1001234567890,
type: "supergroup",
title: "Forum Group",
is_forum: true,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
from: { id: 999, username: "testuser" },
text: testCase.text,
date: 1736380800,
message_id: 42,
message_thread_id: 99,
},
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
});
expect(replySpy).toHaveBeenCalledTimes(1);
const payload = replySpy.mock.calls[0][0];
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
}
});
@ -1907,7 +1949,7 @@ describe("createTelegramBot", () => {
}),
"utf-8",
);
loadConfig.mockReturnValue({
const config = {
channels: {
telegram: {
groupPolicy: "open",
@ -1924,23 +1966,26 @@ describe("createTelegramBot", () => {
},
],
session: { store: storePath },
};
loadConfig.mockReturnValue(config);
await withConfigPathAsync(config, async () => {
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
createTelegramBot({ token: "tok" });
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
await handler({
message: {
chat: { id: 123, type: "group", title: "Routing" },
from: { id: 999, username: "ops" },
text: "hello",
date: 1736380800,
},
me: { username: "openclaw_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).toHaveBeenCalledTimes(1);
});
it("applies topic skill filters and system prompts", async () => {

View File

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

View File

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

View File

@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { tagTelegramNetworkError } from "./network-errors.js";
type MonitorTelegramOpts = import("./monitor.js").MonitorTelegramOpts;
@ -110,7 +109,8 @@ function makeRecoverableFetchError() {
});
}
function makeTaggedPollingFetchError() {
async function makeTaggedPollingFetchError() {
const { tagTelegramNetworkError } = await import("./network-errors.js");
const err = makeRecoverableFetchError();
tagTelegramNetworkError(err, {
method: "getUpdates",
@ -180,24 +180,41 @@ async function runMonitorAndCaptureStartupOrder(params?: { persistedOffset?: num
function mockRunOnceWithStalledPollingRunner(): {
stop: ReturnType<typeof vi.fn<() => void | Promise<void>>>;
waitForTaskStart: () => Promise<void>;
} {
let running = true;
let releaseTask: (() => void) | undefined;
let releaseBeforeTaskStart = false;
let signalTaskStarted: (() => void) | undefined;
const taskStarted = new Promise<void>((resolve) => {
signalTaskStarted = resolve;
});
const stop = vi.fn(async () => {
running = false;
releaseTask?.();
if (releaseTask) {
releaseTask();
return;
}
releaseBeforeTaskStart = true;
});
runSpy.mockImplementationOnce(() =>
makeRunnerStub({
task: () =>
new Promise<void>((resolve) => {
signalTaskStarted?.();
releaseTask = resolve;
if (releaseBeforeTaskStart) {
resolve();
}
}),
stop,
isRunning: () => running,
}),
);
return { stop };
return {
stop,
waitForTaskStart: () => taskStarted,
};
}
function expectRecoverableRetryState(
@ -533,16 +550,17 @@ describe("monitorTelegramProvider (grammY)", () => {
it("force-restarts polling when unhandled network rejection stalls runner", async () => {
const { monitorTelegramProvider } = await import("./monitor.js");
const abort = new AbortController();
const { stop } = mockRunOnceWithStalledPollingRunner();
mockRunOnceAndAbort(abort);
const firstCycle = mockRunOnceWithStalledPollingRunner();
mockRunOnceWithStalledPollingRunner();
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
emitUnhandledRejection(makeTaggedPollingFetchError());
expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true);
expect(firstCycle.stop).toHaveBeenCalledTimes(1);
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(2));
abort.abort();
await monitor;
expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1);
expectRecoverableRetryState(2);
});
@ -578,16 +596,17 @@ describe("monitorTelegramProvider (grammY)", () => {
it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => {
const { monitorTelegramProvider } = await import("./monitor.js");
const abort = new AbortController();
const { stop } = mockRunOnceWithStalledPollingRunner();
const { stop, waitForTaskStart } = mockRunOnceWithStalledPollingRunner();
mockRunOnceAndAbort(abort);
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1));
await waitForTaskStart();
const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal;
expect(firstSignal).toBeInstanceOf(AbortSignal);
expect((firstSignal as AbortSignal).aborted).toBe(false);
emitUnhandledRejection(makeTaggedPollingFetchError());
emitUnhandledRejection(await makeTaggedPollingFetchError());
await monitor;
expect((firstSignal as AbortSignal).aborted).toBe(true);

View File

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

View File

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

View File

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

View File

@ -71,12 +71,23 @@ vi.mock("../../../../src/infra/heartbeat-events.js", () => ({
resolveIndicatorType: (status: string) => `indicator:${status}`,
}));
vi.mock("../../../../src/logging.js", () => ({
getChildLogger: () => ({
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
}),
}));
vi.mock("../../../../src/logging.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../../../src/logging.js")>();
const createStubLogger = () => ({
info: () => undefined,
warn: () => undefined,
error: () => undefined,
child: createStubLogger,
});
return {
...actual,
getChildLogger: () => ({
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
}),
createSubsystemLogger: () => createStubLogger(),
};
});
vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/state-paths")>();
@ -125,10 +136,14 @@ vi.mock("../reconnect.js", () => ({
newConnectionId: () => "run-1",
}));
vi.mock("../send.js", () => ({
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
}));
vi.mock("../send.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../send.js")>();
return {
...actual,
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
sendReactionWhatsApp: vi.fn(async () => undefined),
};
});
vi.mock("../session.js", () => ({
formatError: (err: unknown) => `ERR:${String(err)}`,

View File

@ -34,15 +34,21 @@ export function resetLoadConfigMock() {
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => {
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "loadConfig", {
configurable: true,
enumerable: true,
writable: true,
value: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
});
Object.assign(mockModule, {
updateLastRoute: async (params: {
storePath: string;
sessionKey: string;
@ -68,7 +74,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
},
recordSessionMetaFromInbound: async () => undefined,
resolveStorePath: actual.resolveStorePath,
};
});
return mockModule;
});
// Some web modules live under `src/web/auto-reply/*` and import config via a different
@ -79,16 +86,21 @@ vi.mock("../../config/config.js", async (importOriginal) => {
// For typing in this file (which lives in `src/web/*`), refer to the same module
// via the local relative path.
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadConfig: () => {
const mockModule = Object.create(null) as Record<string, unknown>;
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
Object.defineProperty(mockModule, "loadConfig", {
configurable: true,
enumerable: true,
writable: true,
value: () => {
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
if (typeof getter === "function") {
return getter();
}
return DEFAULT_CONFIG;
},
};
});
return mockModule;
});
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {

View File

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

View File

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

View File

@ -1,5 +1,6 @@
#!/usr/bin/env node
import { access } from "node:fs/promises";
import module from "node:module";
import { fileURLToPath } from "node:url";
@ -59,7 +60,11 @@ const isDirectModuleNotFoundError = (err, specifier) => {
}
const message = "message" in err && typeof err.message === "string" ? err.message : "";
return message.includes(fileURLToPath(expectedUrl));
const expectedPath = fileURLToPath(expectedUrl);
return (
message.includes(`Cannot find module '${expectedPath}'`) ||
message.includes(`Cannot find module "${expectedPath}"`)
);
};
const installProcessWarningFilter = async () => {
@ -95,10 +100,36 @@ const tryImport = async (specifier) => {
}
};
const exists = async (specifier) => {
try {
await access(new URL(specifier, import.meta.url));
return true;
} catch {
return false;
}
};
const buildMissingEntryErrorMessage = async () => {
const lines = ["openclaw: missing dist/entry.(m)js (build output)."];
if (!(await exists("./src/entry.ts"))) {
return lines.join("\n");
}
lines.push("This install looks like an unbuilt source tree or GitHub source archive.");
lines.push(
"Build locally with `pnpm install && pnpm build`, or install a built package instead.",
);
lines.push(
"For pinned GitHub installs, use `npm install -g github:openclaw/openclaw#<ref>` instead of a raw `/archive/<ref>.tar.gz` URL.",
);
lines.push("For releases, use `npm install -g openclaw@latest`.");
return lines.join("\n");
};
if (await tryImport("./dist/entry.js")) {
// OK
} else if (await tryImport("./dist/entry.mjs")) {
// OK
} else {
throw new Error("openclaw: missing dist/entry.(m)js (build output).");
throw new Error(await buildMissingEntryErrorMessage());
}

View File

@ -185,10 +185,6 @@
"types": "./dist/plugin-sdk/discord-core.d.ts",
"default": "./dist/plugin-sdk/discord-core.js"
},
"./plugin-sdk/copilot-proxy": {
"types": "./dist/plugin-sdk/copilot-proxy.d.ts",
"default": "./dist/plugin-sdk/copilot-proxy.js"
},
"./plugin-sdk/feishu": {
"types": "./dist/plugin-sdk/feishu.d.ts",
"default": "./dist/plugin-sdk/feishu.js"
@ -245,18 +241,6 @@
"types": "./dist/plugin-sdk/imessage-core.d.ts",
"default": "./dist/plugin-sdk/imessage-core.js"
},
"./plugin-sdk/open-prose": {
"types": "./dist/plugin-sdk/open-prose.d.ts",
"default": "./dist/plugin-sdk/open-prose.js"
},
"./plugin-sdk/phone-control": {
"types": "./dist/plugin-sdk/phone-control.d.ts",
"default": "./dist/plugin-sdk/phone-control.js"
},
"./plugin-sdk/qwen-portal-auth": {
"types": "./dist/plugin-sdk/qwen-portal-auth.d.ts",
"default": "./dist/plugin-sdk/qwen-portal-auth.js"
},
"./plugin-sdk/signal": {
"types": "./dist/plugin-sdk/signal.d.ts",
"default": "./dist/plugin-sdk/signal.js"
@ -461,6 +445,10 @@
"types": "./dist/plugin-sdk/request-url.d.ts",
"default": "./dist/plugin-sdk/request-url.js"
},
"./plugin-sdk/qwen-portal-auth": {
"types": "./dist/plugin-sdk/qwen-portal-auth.d.ts",
"default": "./dist/plugin-sdk/qwen-portal-auth.js"
},
"./plugin-sdk/webhook-ingress": {
"types": "./dist/plugin-sdk/webhook-ingress.d.ts",
"default": "./dist/plugin-sdk/webhook-ingress.js"
@ -485,10 +473,6 @@
"types": "./dist/plugin-sdk/synology-chat.d.ts",
"default": "./dist/plugin-sdk/synology-chat.js"
},
"./plugin-sdk/talk-voice": {
"types": "./dist/plugin-sdk/talk-voice.d.ts",
"default": "./dist/plugin-sdk/talk-voice.js"
},
"./plugin-sdk/thread-ownership": {
"types": "./dist/plugin-sdk/thread-ownership.d.ts",
"default": "./dist/plugin-sdk/thread-ownership.js"

1069
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -36,7 +36,6 @@
"telegram-core",
"discord",
"discord-core",
"copilot-proxy",
"feishu",
"google",
"googlechat",
@ -51,9 +50,6 @@
"slack-core",
"imessage",
"imessage-core",
"open-prose",
"phone-control",
"qwen-portal-auth",
"signal",
"whatsapp",
"whatsapp-shared",
@ -105,13 +101,13 @@
"secret-input-runtime",
"secret-input-schema",
"request-url",
"qwen-portal-auth",
"webhook-ingress",
"webhook-path",
"runtime-store",
"secret-input",
"signal-core",
"synology-chat",
"talk-voice",
"thread-ownership",
"tlon",
"twitch",

View File

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

View File

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

View File

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

View File

@ -2291,4 +2291,83 @@ describe("applyExtraParamsToAgent", () => {
expect(run().store).toBe(false);
},
);
it("strips prompt cache fields for non-OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "custom-proxy",
applyModelId: "some-model",
model: {
api: "openai-responses",
provider: "custom-proxy",
id: "some-model",
baseUrl: "https://my-proxy.example.com/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-xyz",
prompt_cache_retention: "24h",
},
});
expect(payload).not.toHaveProperty("prompt_cache_key");
expect(payload).not.toHaveProperty("prompt_cache_retention");
});
it("keeps prompt cache fields for direct OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
baseUrl: "https://api.openai.com/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-123",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-123");
expect(payload.prompt_cache_retention).toBe("24h");
});
it("keeps prompt cache fields for direct Azure OpenAI openai-responses endpoints", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "azure-openai-responses",
applyModelId: "gpt-4o",
model: {
api: "openai-responses",
provider: "azure-openai-responses",
id: "gpt-4o",
baseUrl: "https://example.openai.azure.com/openai/v1",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-azure",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-azure");
expect(payload.prompt_cache_retention).toBe("24h");
});
it("keeps prompt cache fields when openai-responses baseUrl is omitted", () => {
const payload = runResponsesPayloadMutationCase({
applyProvider: "openai",
applyModelId: "gpt-5",
model: {
api: "openai-responses",
provider: "openai",
id: "gpt-5",
} as unknown as Model<"openai-responses">,
payload: {
store: false,
prompt_cache_key: "session-default",
prompt_cache_retention: "24h",
},
});
expect(payload.prompt_cache_key).toBe("session-default");
expect(payload.prompt_cache_retention).toBe("24h");
});
});

View File

@ -154,10 +154,23 @@ function shouldStripResponsesStore(
return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false;
}
function shouldStripResponsesPromptCache(model: { api?: unknown; baseUrl?: unknown }): boolean {
if (typeof model.api !== "string" || !OPENAI_RESPONSES_APIS.has(model.api)) {
return false;
}
// Missing baseUrl means pi-ai will use the default OpenAI endpoint, so keep
// prompt cache fields for that direct path.
if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) {
return false;
}
return !isDirectOpenAIBaseUrl(model.baseUrl);
}
function applyOpenAIResponsesPayloadOverrides(params: {
payloadObj: Record<string, unknown>;
forceStore: boolean;
stripStore: boolean;
stripPromptCache: boolean;
useServerCompaction: boolean;
compactThreshold: number;
}): void {
@ -167,6 +180,10 @@ function applyOpenAIResponsesPayloadOverrides(params: {
if (params.stripStore) {
delete params.payloadObj.store;
}
if (params.stripPromptCache) {
delete params.payloadObj.prompt_cache_key;
delete params.payloadObj.prompt_cache_retention;
}
if (params.useServerCompaction && params.payloadObj.context_management === undefined) {
params.payloadObj.context_management = [
{
@ -297,7 +314,8 @@ export function createOpenAIResponsesContextManagementWrapper(
const forceStore = shouldForceResponsesStore(model);
const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams);
const stripStore = shouldStripResponsesStore(model, forceStore);
if (!forceStore && !useServerCompaction && !stripStore) {
const stripPromptCache = shouldStripResponsesPromptCache(model);
if (!forceStore && !useServerCompaction && !stripStore && !stripPromptCache) {
return underlying(model, context, options);
}
@ -313,6 +331,7 @@ export function createOpenAIResponsesContextManagementWrapper(
payloadObj: payload as Record<string, unknown>,
forceStore,
stripStore,
stripPromptCache,
useServerCompaction,
compactThreshold,
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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