diff --git a/.github/workflows/install-smoke.yml b/.github/workflows/install-smoke.yml index a8115f1644a..8baa84ca67b 100644 --- a/.github/workflows/install-smoke.yml +++ b/.github/workflows/install-smoke.yml @@ -62,9 +62,9 @@ jobs: run: | docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version' - # This smoke validates that the build-arg path preinstalls selected - # extension deps and that matrix plugin discovery stays healthy in the - # final runtime image. + # This smoke validates that the build-arg path preinstalls the matrix + # runtime deps declared by the plugin and that matrix discovery stays + # healthy in the final runtime image. - name: Build extension Dockerfile smoke image uses: useblacksmith/build-push-action@v2 with: @@ -84,9 +84,17 @@ jobs: openclaw --version && node -e " const Module = require(\"node:module\"); + const matrixPackage = require(\"/app/extensions/matrix/package.json\"); const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\"); - requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\"); - requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\"); + const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {}); + if (runtimeDeps.length === 0) { + throw new Error( + \"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\", + ); + } + for (const dep of runtimeDeps) { + requireFromMatrix.resolve(dep); + } const { spawnSync } = require(\"node:child_process\"); const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" }); if (run.status !== 0) { diff --git a/.gitignore b/.gitignore index 82bf37a8164..0e1812f0a1f 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 64a463eb8ac..a26a8e80b25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Docs: https://docs.openclaw.ai - Control UI/chat: add an expand-to-canvas button on assistant chat bubbles and in-app session navigation from Sessions and Cron views. Thanks @BunsDev. - Plugins/context engines: expose `delegateCompactionToRuntime(...)` on the public plugin SDK, refactor the legacy engine to use the shared helper, and clarify `ownsCompaction` delegation semantics for non-owning engines. (#49061) Thanks @jalehman. - Plugins/MiniMax: add MiniMax-M2.7 and MiniMax-M2.7-highspeed models and update the default model from M2.5 to M2.7. (#49691) Thanks @liyuan97. +- Contracts/Matrix: validate Matrix session binding coverage through the real manager, expose the manager on the Matrix runtime API, and let tests pass an explicit state directory for isolated contract setup. (#50369) thanks @ChroniCat. ### Fixes @@ -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 diff --git a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt index 82fe643314c..0add840cf30 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/MainViewModel.kt @@ -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) { diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 3b37c5b01e2..6dd1b83d3bb 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -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) { + 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) } diff --git a/apps/android/scripts/perf-online-benchmark.sh b/apps/android/scripts/perf-online-benchmark.sh new file mode 100755 index 00000000000..159afe84088 --- /dev/null +++ b/apps/android/scripts/perf-online-benchmark.sh @@ -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 adb device serial + --package package name (default: ai.openclaw.app) + --activity launch activity (default: .MainActivity) + --skip-install skip :app:installDebug + --launch-runs launch-to-connected runs (default: 4) + --screen-loops screen benchmark loops (default: 6) + --chat-loops chat benchmark loops (default: 8) + --screen-mode transition | scroll (default: transition) + --chat-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 ." >&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 || valuemax) 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 || valuemax) 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" diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift index a36e58db1d8..e39db84534f 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalEvaluation.swift @@ -9,6 +9,7 @@ struct ExecApprovalEvaluation { let env: [String: String] let resolution: ExecCommandResolution? let allowlistResolutions: [ExecCommandResolution] + let allowAlwaysPatterns: [String] let allowlistMatches: [ExecAllowlistEntry] let allowlistSatisfied: Bool let allowlistMatch: ExecAllowlistEntry? @@ -31,9 +32,16 @@ enum ExecApprovalEvaluator { let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper) let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand) + let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand( + command: command, + rawCommand: rawCommand) let allowlistResolutions = ExecCommandResolution.resolveForAllowlist( command: command, - rawCommand: rawCommand, + rawCommand: allowlistRawCommand, + cwd: cwd, + env: env) + let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: command, cwd: cwd, env: env) let allowlistMatches = security == .allowlist @@ -60,6 +68,7 @@ enum ExecApprovalEvaluator { env: env, resolution: allowlistResolutions.first, allowlistResolutions: allowlistResolutions, + allowAlwaysPatterns: allowAlwaysPatterns, allowlistMatches: allowlistMatches, allowlistSatisfied: allowlistSatisfied, allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil, diff --git a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift index 19336f4f7b1..1187d3d09a4 100644 --- a/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift +++ b/apps/macos/Sources/OpenClaw/ExecApprovalsSocket.swift @@ -378,7 +378,7 @@ private enum ExecHostExecutor { let context = await self.buildContext( request: request, command: validatedRequest.command, - rawCommand: validatedRequest.displayCommand) + rawCommand: validatedRequest.evaluationRawCommand) switch ExecHostRequestEvaluator.evaluate( context: context, @@ -476,13 +476,7 @@ private enum ExecHostExecutor { { guard decision == .allowAlways, context.security == .allowlist else { return } var seenPatterns = Set() - for candidate in context.allowlistResolutions { - guard let pattern = ExecApprovalHelpers.allowlistPattern( - command: context.command, - resolution: candidate) - else { - continue - } + for pattern in context.allowAlwaysPatterns { if seenPatterns.insert(pattern).inserted { ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern) } diff --git a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift index f89293a81aa..131868bb23e 100644 --- a/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift +++ b/apps/macos/Sources/OpenClaw/ExecCommandResolution.swift @@ -52,6 +52,23 @@ struct ExecCommandResolution { return [resolution] } + static func resolveAllowAlwaysPatterns( + command: [String], + cwd: String?, + env: [String: String]?) -> [String] + { + var patterns: [String] = [] + var seen = Set() + self.collectAllowAlwaysPatterns( + command: command, + cwd: cwd, + env: env, + depth: 0, + patterns: &patterns, + seen: &seen) + return patterns + } + static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? { let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command) guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { @@ -101,6 +118,115 @@ struct ExecCommandResolution { return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env) } + private static func collectAllowAlwaysPatterns( + command: [String], + cwd: String?, + env: [String: String]?, + depth: Int, + patterns: inout [String], + seen: inout Set) + { + guard depth < 3, !command.isEmpty else { + return + } + + if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), + ExecCommandToken.basenameLower(token0) == "env", + let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command), + !envUnwrapped.isEmpty + { + self.collectAllowAlwaysPatterns( + command: envUnwrapped, + cwd: cwd, + env: env, + depth: depth + 1, + patterns: &patterns, + seen: &seen) + return + } + + if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) { + self.collectAllowAlwaysPatterns( + command: shellMultiplexer, + cwd: cwd, + env: env, + depth: depth + 1, + patterns: &patterns, + seen: &seen) + return + } + + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + if shell.isWrapper { + guard let shellCommand = shell.command, + let segments = self.splitShellCommandChain(shellCommand) + else { + return + } + for segment in segments { + let tokens = self.tokenizeShellWords(segment) + guard !tokens.isEmpty else { + continue + } + self.collectAllowAlwaysPatterns( + command: tokens, + cwd: cwd, + env: env, + depth: depth + 1, + patterns: &patterns, + seen: &seen) + } + return + } + + guard let resolution = self.resolve(command: command, cwd: cwd, env: env), + let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution), + seen.insert(pattern).inserted + else { + return + } + patterns.append(pattern) + } + + private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? { + guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else { + return nil + } + let wrapper = ExecCommandToken.basenameLower(token0) + guard wrapper == "busybox" || wrapper == "toybox" else { + return nil + } + + var appletIndex = 1 + if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" { + appletIndex += 1 + } + guard appletIndex < argv.count else { + return nil + } + let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) + guard !applet.isEmpty else { + return nil + } + + let normalizedApplet = ExecCommandToken.basenameLower(applet) + let shellWrappers = Set([ + "ash", + "bash", + "dash", + "fish", + "ksh", + "powershell", + "pwsh", + "sh", + "zsh", + ]) + guard shellWrappers.contains(normalizedApplet) else { + return nil + } + return Array(argv[appletIndex...]) + } + private static func parseFirstToken(_ command: String) -> String? { let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } diff --git a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift index 19161858571..35423182b6e 100644 --- a/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift +++ b/apps/macos/Sources/OpenClaw/ExecEnvInvocationUnwrapper.swift @@ -12,14 +12,24 @@ enum ExecCommandToken { enum ExecEnvInvocationUnwrapper { static let maxWrapperDepth = 4 + struct UnwrapResult { + let command: [String] + let usesModifiers: Bool + } + private static func isEnvAssignment(_ token: String) -> Bool { let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"# return token.range(of: pattern, options: .regularExpression) != nil } static func unwrap(_ command: [String]) -> [String]? { + self.unwrapWithMetadata(command)?.command + } + + static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? { var idx = 1 var expectsOptionValue = false + var usesModifiers = false while idx < command.count { let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines) if token.isEmpty { @@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper { } if expectsOptionValue { expectsOptionValue = false + usesModifiers = true idx += 1 continue } @@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper { break } if self.isEnvAssignment(token) { + usesModifiers = true idx += 1 continue } @@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper { let lower = token.lowercased() let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower if ExecEnvOptions.flagOnly.contains(flag) { + usesModifiers = true idx += 1 continue } if ExecEnvOptions.withValue.contains(flag) { + usesModifiers = true if !lower.contains("=") { expectsOptionValue = true } @@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper { lower.hasPrefix("--ignore-signal=") || lower.hasPrefix("--block-signal=") { + usesModifiers = true idx += 1 continue } @@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper { } break } - guard idx < command.count else { return nil } - return Array(command[idx...]) + guard !expectsOptionValue, idx < command.count else { return nil } + return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers) } static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] { @@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper { guard ExecCommandToken.basenameLower(token) == "env" else { break } - guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else { + guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else { break } - current = unwrapped + if unwrapped.usesModifiers { + break + } + current = unwrapped.command depth += 1 } return current diff --git a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift index 4e0ff4173de..5a95bd7949d 100644 --- a/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift +++ b/apps/macos/Sources/OpenClaw/ExecHostRequestEvaluator.swift @@ -3,6 +3,7 @@ import Foundation struct ExecHostValidatedRequest { let command: [String] let displayCommand: String + let evaluationRawCommand: String? } enum ExecHostPolicyDecision { @@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator { rawCommand: request.rawCommand) switch validatedCommand { case let .ok(resolved): - return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand)) + return .success(ExecHostValidatedRequest( + command: command, + displayCommand: resolved.displayCommand, + evaluationRawCommand: resolved.evaluationRawCommand)) case let .invalid(message): return .failure( ExecHostError( diff --git a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift index f8ff84155e1..d73724db5bd 100644 --- a/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift +++ b/apps/macos/Sources/OpenClaw/ExecSystemRunCommandValidator.swift @@ -3,6 +3,7 @@ import Foundation enum ExecSystemRunCommandValidator { struct ResolvedCommand { let displayCommand: String + let evaluationRawCommand: String? } enum ValidationResult { @@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator { let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command) let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv - - let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv { + let formattedArgv = ExecCommandFormatter.displayString(for: command) + let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv { shellCommand } else { - ExecCommandFormatter.displayString(for: command) + nil } - if let raw = normalizedRaw, raw != inferred { + if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand { return .invalid(message: "INVALID_REQUEST: rawCommand does not match command") } - return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred)) + return .ok(ResolvedCommand( + displayCommand: formattedArgv, + evaluationRawCommand: self.allowlistEvaluationRawCommand( + normalizedRaw: normalizedRaw, + shellIsWrapper: shell.isWrapper, + previewCommand: previewCommand))) + } + + static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? { + let normalizedRaw = self.normalizeRaw(rawCommand) + let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil) + let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil + + let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command) + let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command) + let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv + let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv { + shellCommand + } else { + nil + } + + return self.allowlistEvaluationRawCommand( + normalizedRaw: normalizedRaw, + shellIsWrapper: shell.isWrapper, + previewCommand: previewCommand) } private static func normalizeRaw(_ rawCommand: String?) -> String? { @@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator { return trimmed.isEmpty ? nil : trimmed } + private static func allowlistEvaluationRawCommand( + normalizedRaw: String?, + shellIsWrapper: Bool, + previewCommand: String?) -> String? + { + guard shellIsWrapper else { + return normalizedRaw + } + guard let normalizedRaw else { + return nil + } + return normalizedRaw == previewCommand ? normalizedRaw : nil + } + private static func normalizeExecutableToken(_ token: String) -> String { let base = ExecCommandToken.basenameLower(token) if base.hasSuffix(".exe") { diff --git a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift index 6782913bd23..c24f5d0f1b8 100644 --- a/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/OpenClaw/NodeMode/MacNodeRuntime.swift @@ -507,8 +507,7 @@ actor MacNodeRuntime { persistAllowlist: persistAllowlist, security: evaluation.security, agentId: evaluation.agentId, - command: command, - allowlistResolutions: evaluation.allowlistResolutions) + allowAlwaysPatterns: evaluation.allowAlwaysPatterns) if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk { await self.emitExecEvent( @@ -795,15 +794,11 @@ extension MacNodeRuntime { persistAllowlist: Bool, security: ExecSecurity, agentId: String?, - command: [String], - allowlistResolutions: [ExecCommandResolution]) + allowAlwaysPatterns: [String]) { guard persistAllowlist, security == .allowlist else { return } var seenPatterns = Set() - for candidate in allowlistResolutions { - guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else { - continue - } + for pattern in allowAlwaysPatterns { if seenPatterns.insert(pattern).inserted { ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern) } diff --git a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift index 969a8ea1a51..5e8e68f52e6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/CommandResolverTests.swift @@ -45,7 +45,7 @@ import Testing let nodePath = tmp.appendingPathComponent("node_modules/.bin/node") let scriptPath = tmp.appendingPathComponent("bin/openclaw.js") try makeExecutableForTests(at: nodePath) - try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8) + try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8) try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path) try makeExecutableForTests(at: scriptPath) diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift index fa92cc81ef5..dc2ab9c42d7 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecAllowlistTests.swift @@ -240,7 +240,7 @@ struct ExecAllowlistTests { #expect(resolutions[0].executableName == "touch") } - @Test func `resolve for allowlist unwraps env assignments inside shell segments`() { + @Test func `resolve for allowlist preserves env assignments inside shell segments`() { let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -248,11 +248,11 @@ struct ExecAllowlistTests { cwd: nil, env: ["PATH": "/usr/bin:/bin"]) #expect(resolutions.count == 1) - #expect(resolutions[0].resolvedPath == "/usr/bin/touch") - #expect(resolutions[0].executableName == "touch") + #expect(resolutions[0].resolvedPath == "/usr/bin/env") + #expect(resolutions[0].executableName == "env") } - @Test func `resolve for allowlist unwraps env to effective direct executable`() { + @Test func `resolve for allowlist preserves env wrapper with modifiers`() { let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"] let resolutions = ExecCommandResolution.resolveForAllowlist( command: command, @@ -260,8 +260,33 @@ struct ExecAllowlistTests { cwd: nil, env: ["PATH": "/usr/bin:/bin"]) #expect(resolutions.count == 1) - #expect(resolutions[0].resolvedPath == "/usr/bin/printf") - #expect(resolutions[0].executableName == "printf") + #expect(resolutions[0].resolvedPath == "/usr/bin/env") + #expect(resolutions[0].executableName == "env") + } + + @Test func `approval evaluator resolves shell payload from canonical wrapper text`() async { + let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"] + let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\"" + let evaluation = await ExecApprovalEvaluator.evaluate( + command: command, + rawCommand: rawCommand, + cwd: nil, + envOverrides: ["PATH": "/usr/bin:/bin"], + agentId: nil) + + #expect(evaluation.displayCommand == rawCommand) + #expect(evaluation.allowlistResolutions.count == 1) + #expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf") + #expect(evaluation.allowlistResolutions[0].executableName == "printf") + } + + @Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() { + let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns( + command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"], + cwd: nil, + env: ["PATH": "/usr/bin:/bin"]) + + #expect(patterns == ["/usr/bin/printf"]) } @Test func `match all requires every segment to match`() { diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift index 480b4cd9194..cd270d00fd2 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecApprovalsStoreRefactorTests.swift @@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests { try await self.withTempStateDir { _ in _ = ExecApprovalsStore.ensureFile() let url = ExecApprovalsStore.fileURL() - let firstWriteDate = try Self.modificationDate(at: url) + let firstIdentity = try Self.fileIdentity(at: url) - try await Task.sleep(nanoseconds: 1_100_000_000) _ = ExecApprovalsStore.ensureFile() - let secondWriteDate = try Self.modificationDate(at: url) + let secondIdentity = try Self.fileIdentity(at: url) - #expect(firstWriteDate == secondWriteDate) + #expect(firstIdentity == secondIdentity) } } @@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests { } } - private static func modificationDate(at url: URL) throws -> Date { + private static func fileIdentity(at url: URL) throws -> Int { let attributes = try FileManager().attributesOfItem(atPath: url.path) - guard let date = attributes[.modificationDate] as? Date else { - struct MissingDateError: Error {} - throw MissingDateError() + guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else { + struct MissingIdentifierError: Error {} + throw MissingIdentifierError() } - return date + return identifier } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift index c9772a5d512..ee2177e1440 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecHostRequestEvaluatorTests.swift @@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests { env: [:], resolution: nil, allowlistResolutions: [], + allowAlwaysPatterns: [], allowlistMatches: [], allowlistSatisfied: allowlistSatisfied, allowlistMatch: nil, diff --git a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift index 64dbb335807..2b07d928ccf 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ExecSystemRunCommandValidatorTests.swift @@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests { } } + @Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() { + let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"] + let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\"" + let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand) + + switch result { + case let .ok(resolved): + #expect(resolved.displayCommand == rawCommand) + #expect(resolved.evaluationRawCommand == nil) + case let .invalid(message): + Issue.record("unexpected invalid result: \(message)") + } + } + private static func loadContractCases() throws -> [SystemRunCommandContractCase] { let fixtureURL = try self.findContractFixtureURL() let data = try Data(contentsOf: fixtureURL) diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 4d9d0fa0e4f..d6ec40ff4db 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -372,7 +372,7 @@ Planned improvement: ## Automatic verification notices -Matrix now posts verification lifecycle notices directly into the Matrix room as `m.notice` messages. +Matrix now posts verification lifecycle notices directly into the strict DM verification room as `m.notice` messages. That includes: - verification request notices @@ -381,7 +381,8 @@ That includes: - SAS details (emoji and decimal) when available Incoming verification requests from another Matrix client are tracked and auto-accepted by OpenClaw. -When SAS emoji verification becomes available, OpenClaw starts that SAS flow automatically for inbound requests and confirms its own side. +For self-verification flows, OpenClaw also starts the SAS flow automatically when emoji verification becomes available and confirms its own side. +For verification requests from another Matrix user/device, OpenClaw auto-accepts the request and then waits for the SAS flow to proceed normally. You still need to compare the emoji or decimal SAS in your Matrix client and confirm "They match" there to complete the verification. OpenClaw does not auto-accept self-initiated duplicate flows blindly. Startup skips creating a new request when a self-verification request is already pending. diff --git a/docs/docs.json b/docs/docs.json index 1e5cf45d4d5..e80697ac63d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -767,6 +767,14 @@ "source": "/gcp", "destination": "/install/gcp" }, + { + "source": "/azure", + "destination": "/install/azure" + }, + { + "source": "/install/azure/azure", + "destination": "/install/azure" + }, { "source": "/platforms/fly", "destination": "/install/fly" @@ -779,6 +787,10 @@ "source": "/platforms/gcp", "destination": "/install/gcp" }, + { + "source": "/platforms/azure", + "destination": "/install/azure" + }, { "source": "/platforms/macos-vm", "destination": "/install/macos-vm" @@ -872,6 +884,7 @@ "install/fly", "install/hetzner", "install/gcp", + "install/azure", "install/macos-vm", "install/exe-dev", "install/railway", diff --git a/docs/install/azure.md b/docs/install/azure.md new file mode 100644 index 00000000000..a257059f75d --- /dev/null +++ b/docs/install/azure.md @@ -0,0 +1,169 @@ +--- +summary: "Run OpenClaw Gateway 24/7 on an Azure Linux VM with durable state" +read_when: + - You want OpenClaw running 24/7 on Azure with Network Security Group hardening + - You want a production-grade, always-on OpenClaw Gateway on your own Azure Linux VM + - You want secure administration with Azure Bastion SSH + - You want repeatable deployments with Azure Resource Manager templates +title: "Azure" +--- + +# OpenClaw on Azure Linux VM + +This guide sets up an Azure Linux VM, applies Network Security Group (NSG) hardening, configures Azure Bastion (managed Azure SSH entry point), and installs OpenClaw. + +## What you’ll do + +- Deploy Azure compute and network resources with Azure Resource Manager (ARM) templates +- Apply Azure Network Security Group (NSG) rules so VM SSH is allowed only from Azure Bastion +- Use Azure Bastion for SSH access +- Install OpenClaw with the installer script +- Verify the Gateway + +## Before you start + +You’ll need: + +- An Azure subscription with permission to create compute and network resources +- Azure CLI installed (see [Azure CLI install steps](https://learn.microsoft.com/cli/azure/install-azure-cli) if needed) + +## 1) Sign in to Azure CLI + +```bash +az login # Sign in and select your Azure subscription +az extension add -n ssh # Extension required for Azure Bastion SSH management +``` + +## 2) Register required resource providers (one-time) + +```bash +az provider register --namespace Microsoft.Compute +az provider register --namespace Microsoft.Network +``` + +Verify Azure resource provider registration. Wait until both show `Registered`. + +```bash +az provider show --namespace Microsoft.Compute --query registrationState -o tsv +az provider show --namespace Microsoft.Network --query registrationState -o tsv +``` + +## 3) Set deployment variables + +```bash +RG="rg-openclaw" +LOCATION="westus2" +TEMPLATE_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.json" +PARAMS_URI="https://raw.githubusercontent.com/openclaw/openclaw/main/infra/azure/templates/azuredeploy.parameters.json" +``` + +## 4) Select SSH key + +Use your existing public key if you have one: + +```bash +SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)" +``` + +If you don’t have an SSH key yet, run the following: + +```bash +ssh-keygen -t ed25519 -a 100 -f ~/.ssh/id_ed25519 -C "you@example.com" +SSH_PUB_KEY="$(cat ~/.ssh/id_ed25519.pub)" +``` + +## 5) Select VM size and OS disk size + +Set VM and disk sizing variables: + +```bash +VM_SIZE="Standard_B2as_v2" +OS_DISK_SIZE_GB=64 +``` + +Choose a VM size and OS disk size that are available in your Azure subscription/region and matches your workload: + +- Start smaller for light usage and scale up later +- Use more vCPU/RAM/OS disk size for heavier automation, more channels, or larger model/tool workloads +- If a VM size is unavailable in your region or subscription quota, pick the closest available SKU + +List VM sizes available in your target region: + +```bash +az vm list-skus --location "${LOCATION}" --resource-type virtualMachines -o table +``` + +Check your current VM vCPU and OS disk size usage/quota: + +```bash +az vm list-usage --location "${LOCATION}" -o table +``` + +## 6) Create the resource group + +```bash +az group create -n "${RG}" -l "${LOCATION}" +``` + +## 7) Deploy resources + +This command applies your selected SSH key, VM size, and OS disk size. + +```bash +az deployment group create \ + -g "${RG}" \ + --template-uri "${TEMPLATE_URI}" \ + --parameters "${PARAMS_URI}" \ + --parameters location="${LOCATION}" \ + --parameters vmSize="${VM_SIZE}" \ + --parameters osDiskSizeGb="${OS_DISK_SIZE_GB}" \ + --parameters sshPublicKey="${SSH_PUB_KEY}" +``` + +## 8) SSH into the VM through Azure Bastion + +```bash +RG="rg-openclaw" +VM_NAME="vm-openclaw" +BASTION_NAME="bas-openclaw" +ADMIN_USERNAME="openclaw" +VM_ID="$(az vm show -g "${RG}" -n "${VM_NAME}" --query id -o tsv)" + +az network bastion ssh \ + --name "${BASTION_NAME}" \ + --resource-group "${RG}" \ + --target-resource-id "${VM_ID}" \ + --auth-type ssh-key \ + --username "${ADMIN_USERNAME}" \ + --ssh-key ~/.ssh/id_ed25519 +``` + +## 9) Install OpenClaw (in the VM shell) + +```bash +curl -fsSL https://openclaw.ai/install.sh -o /tmp/openclaw-install.sh +bash /tmp/openclaw-install.sh +rm -f /tmp/openclaw-install.sh +openclaw --version +``` + +The installer script handles Node detection/installation and runs onboarding by default. + +## 10) Verify the Gateway + +After onboarding completes: + +```bash +openclaw gateway status +``` + +Most enterprise Azure teams already have GitHub Copilot licenses. If that is your case, we recommend choosing the GitHub Copilot provider in the OpenClaw onboarding wizard. See [GitHub Copilot provider](/providers/github-copilot). + +The included ARM template uses Ubuntu image `version: "latest"` for convenience. If you need reproducible builds, pin a specific image version in `infra/azure/templates/azuredeploy.json` (you can list versions with `az vm image list --publisher Canonical --offer ubuntu-24_04-lts --sku server --all -o table`). + +## Next steps + +- Set up messaging channels: [Channels](/channels) +- Pair local devices as nodes: [Nodes](/nodes) +- Configure the Gateway: [Gateway configuration](/gateway/configuration) +- For more details on OpenClaw Azure deployment with the GitHub Copilot model provider: [OpenClaw on Azure with GitHub Copilot](https://github.com/johnsonshi/openclaw-azure-github-copilot) diff --git a/docs/install/migrating-matrix.md b/docs/install/migrating-matrix.md index d1e85c5ecd1..bd8772e29f6 100644 --- a/docs/install/migrating-matrix.md +++ b/docs/install/migrating-matrix.md @@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it. - What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway. -`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...` +`- Failed creating a Matrix migration snapshot before repair: ...` + +`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".` - Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first. - What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway. @@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins - Meaning: backup exists, but OpenClaw could not recover the recovery key automatically. - What to do: run `openclaw matrix verify backup restore --recovery-key ""`. -`Failed inspecting legacy Matrix encrypted state for account "...": ...` +`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...` - Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery. - What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key ""`. diff --git a/docs/platforms/index.md b/docs/platforms/index.md index ec2663aefe4..37a0a47a6fb 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -29,6 +29,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v - Fly.io: [Fly.io](/install/fly) - Hetzner (Docker): [Hetzner](/install/hetzner) - GCP (Compute Engine): [GCP](/install/gcp) +- Azure (Linux VM): [Azure](/install/azure) - exe.dev (VM + HTTPS proxy): [exe.dev](/install/exe-dev) ## Common links diff --git a/docs/vps.md b/docs/vps.md index 66c2fdaf93f..9847f88e98d 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -1,5 +1,5 @@ --- -summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/exe.dev)" +summary: "VPS hosting hub for OpenClaw (Oracle/Fly/Hetzner/GCP/Azure/exe.dev)" read_when: - You want to run the Gateway in the cloud - You need a quick map of VPS/hosting guides @@ -19,6 +19,7 @@ deployments work at a high level. - **Fly.io**: [Fly.io](/install/fly) - **Hetzner (Docker)**: [Hetzner](/install/hetzner) - **GCP (Compute Engine)**: [GCP](/install/gcp) +- **Azure (Linux VM)**: [Azure](/install/azure) - **exe.dev** (VM + HTTPS proxy): [exe.dev](/install/exe-dev) - **AWS (EC2/Lightsail/free tier)**: works well too. Video guide: [https://x.com/techfrenAJ/status/2014934471095812547](https://x.com/techfrenAJ/status/2014934471095812547) diff --git a/extensions/copilot-proxy/runtime-api.ts b/extensions/copilot-proxy/runtime-api.ts index 849136c6efb..04c4c25f7d0 100644 --- a/extensions/copilot-proxy/runtime-api.ts +++ b/extensions/copilot-proxy/runtime-api.ts @@ -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"; diff --git a/extensions/discord/src/account-inspect.ts b/extensions/discord/src/account-inspect.ts index 0b3bd3f8fc8..9f13b612dab 100644 --- a/extensions/discord/src/account-inspect.ts +++ b/extensions/discord/src/account-inspect.ts @@ -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"; diff --git a/extensions/discord/src/accounts.ts b/extensions/discord/src/accounts.ts index 49193f5fabf..ab014f4bc4a 100644 --- a/extensions/discord/src/accounts.ts +++ b/extensions/discord/src/accounts.ts @@ -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 = { diff --git a/extensions/discord/src/monitor.tool-result.test-harness.ts b/extensions/discord/src/monitor.tool-result.test-harness.ts index 1d4bb1d0522..8ce7e8b8309 100644 --- a/extensions/discord/src/monitor.tool-result.test-harness.ts +++ b/extensions/discord/src/monitor.tool-result.test-harness.ts @@ -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(); + 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(); @@ -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(); - return { - ...actual, - recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), - }; -}); - vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - readSessionUpdatedAt: vi.fn(() => undefined), resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"), updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args), resolveSessionKey: vi.fn(), diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index 7f0dae736d7..158336d2435 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -58,28 +58,29 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn()); const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; -vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => { - const actual = - await importOriginal(); +vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => { + const actual = await importOriginal(); 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(); +vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args), - }; -}); - -vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => { - const actual = - await importOriginal(); - 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(); +vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), }; }); +vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => { + const actual = await importOriginal(); + 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(); +vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args), }; }); -vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { + const actual = await importOriginal(); 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(); +vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => { + const actual = await importOriginal(); 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(); diff --git a/extensions/discord/src/runtime-api.ts b/extensions/discord/src/runtime-api.ts index 2357a477e76..0d355ab506f 100644 --- a/extensions/discord/src/runtime-api.ts +++ b/extensions/discord/src/runtime-api.ts @@ -15,6 +15,9 @@ export { resolvePollMaxSelections, type ActionGate, type ChannelPlugin, + type DiscordAccountConfig, + type DiscordActionConfig, + type DiscordConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/discord-core"; export { DiscordConfigSchema } from "openclaw/plugin-sdk/discord-core"; @@ -42,8 +45,6 @@ export type { ChannelMessageActionAdapter, ChannelMessageActionName, } from "openclaw/plugin-sdk/channel-runtime"; -export type { DiscordConfig } from "openclaw/plugin-sdk/discord"; -export type { DiscordAccountConfig, DiscordActionConfig } from "openclaw/plugin-sdk/discord"; export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, diff --git a/extensions/matrix/api.ts b/extensions/matrix/api.ts index 620864b9a90..4a3e03f0a31 100644 --- a/extensions/matrix/api.ts +++ b/extensions/matrix/api.ts @@ -1,3 +1,8 @@ export * from "./src/setup-core.js"; export * from "./src/setup-surface.js"; +export { + createMatrixThreadBindingManager, + getMatrixThreadBindingManager, + resetMatrixThreadBindingsForTests, +} from "./src/matrix/thread-bindings.js"; export { matrixOnboardingAdapter as matrixSetupWizard } from "./src/onboarding.js"; diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index 647f841487b..ecdd6619595 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -1,3 +1,5 @@ +import path from "node:path"; +import { createJiti } from "jiti"; import { beforeEach, describe, expect, it, vi } from "vitest"; const setMatrixRuntimeMock = vi.hoisted(() => vi.fn()); @@ -14,6 +16,20 @@ describe("matrix plugin registration", () => { vi.clearAllMocks(); }); + it("loads the matrix runtime api through Jiti", () => { + const jiti = createJiti(import.meta.url, { + interopDefault: true, + tryNative: false, + extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"], + }); + const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts"); + + expect(jiti(runtimeApiPath)).toMatchObject({ + requiresExplicitMatrixDefaultAccount: expect.any(Function), + resolveMatrixDefaultOrOnlyAccountId: expect.any(Function), + }); + }); + it("registers the channel without bootstrapping crypto runtime", () => { const runtime = {} as never; matrixPlugin.register({ diff --git a/extensions/matrix/runtime-api.ts b/extensions/matrix/runtime-api.ts index 9d427c4ac8c..52df80f9843 100644 --- a/extensions/matrix/runtime-api.ts +++ b/extensions/matrix/runtime-api.ts @@ -1,3 +1,14 @@ export * from "openclaw/plugin-sdk/matrix"; export * from "./src/auth-precedence.js"; -export * from "./helper-api.js"; +export { + findMatrixAccountEntry, + hashMatrixAccessToken, + listMatrixEnvAccountIds, + resolveConfiguredMatrixAccountIds, + resolveMatrixChannelConfig, + resolveMatrixCredentialsFilename, + resolveMatrixEnvAccountToken, + resolveMatrixHomeserverKey, + resolveMatrixLegacyFlatStoreRoot, + sanitizeMatrixPathSegment, +} from "./helper-api.js"; diff --git a/extensions/matrix/src/actions.test.ts b/extensions/matrix/src/actions.test.ts index f9da97881ac..df34411b806 100644 --- a/extensions/matrix/src/actions.test.ts +++ b/extensions/matrix/src/actions.test.ts @@ -59,7 +59,7 @@ describe("matrixMessageActions", () => { const discovery = describeMessageTool!({ cfg: createConfiguredMatrixConfig(), - } as never); + } as never) ?? { actions: [] }; const actions = discovery.actions; expect(actions).toContain("poll"); @@ -74,7 +74,7 @@ describe("matrixMessageActions", () => { const discovery = describeMessageTool!({ cfg: createConfiguredMatrixConfig(), - } as never); + } as never) ?? { actions: [], schema: null }; const actions = discovery.actions; const properties = (discovery.schema as { properties?: Record } | null)?.properties ?? {}; @@ -87,64 +87,66 @@ describe("matrixMessageActions", () => { }); it("hides gated actions when the default Matrix account disables them", () => { - const actions = matrixMessageActions.describeMessageTool!({ - cfg: { - channels: { - matrix: { - defaultAccount: "assistant", - actions: { - messages: true, - reactions: true, - pins: true, - profile: true, - memberInfo: true, - channelInfo: true, - verification: true, - }, - accounts: { - assistant: { - homeserver: "https://matrix.example.org", - userId: "@bot:example.org", - accessToken: "token", - encryption: true, - actions: { - messages: false, - reactions: false, - pins: false, - profile: false, - memberInfo: false, - channelInfo: false, - verification: false, + const actions = + matrixMessageActions.describeMessageTool!({ + cfg: { + channels: { + matrix: { + defaultAccount: "assistant", + actions: { + messages: true, + reactions: true, + pins: true, + profile: true, + memberInfo: true, + channelInfo: true, + verification: true, + }, + accounts: { + assistant: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + encryption: true, + actions: { + messages: false, + reactions: false, + pins: false, + profile: false, + memberInfo: false, + channelInfo: false, + verification: false, + }, }, }, }, }, - }, - } as CoreConfig, - } as never).actions; + } as CoreConfig, + } as never)?.actions ?? []; expect(actions).toEqual(["poll", "poll-vote"]); }); it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => { - const actions = matrixMessageActions.describeMessageTool!({ - cfg: { - channels: { - matrix: { - accounts: { - assistant: { - homeserver: "https://matrix.example.org", - accessToken: "assistant-token", - }, - ops: { - homeserver: "https://matrix.example.org", - accessToken: "ops-token", + const actions = + matrixMessageActions.describeMessageTool!({ + cfg: { + channels: { + matrix: { + accounts: { + assistant: { + homeserver: "https://matrix.example.org", + accessToken: "assistant-token", + }, + ops: { + homeserver: "https://matrix.example.org", + accessToken: "ops-token", + }, }, }, }, - }, - } as CoreConfig, - } as never).actions; + } as CoreConfig, + } as never)?.actions ?? []; expect(actions).toEqual([]); }); diff --git a/extensions/matrix/src/channel.runtime.ts b/extensions/matrix/src/channel.runtime.ts index e75d06f1875..e3d8c9d05c5 100644 --- a/extensions/matrix/src/channel.runtime.ts +++ b/extensions/matrix/src/channel.runtime.ts @@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d import { resolveMatrixAuth } from "./matrix/client.js"; import { probeMatrix } from "./matrix/probe.js"; import { sendMessageMatrix } from "./matrix/send.js"; +import { matrixOutbound } from "./outbound.js"; import { resolveMatrixTargets } from "./resolve-targets.js"; export const matrixChannelRuntime = { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive, + matrixOutbound, probeMatrix, resolveMatrixAuth, resolveMatrixTargets, diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index cf251450fd2..cfc4ccdddf1 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -15,8 +15,8 @@ import { createTextPairingAdapter, listResolvedDirectoryEntriesFromSources, } from "openclaw/plugin-sdk/channel-runtime"; +import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js"; import { buildChannelConfigSchema, buildProbeChannelStatusSummary, @@ -47,7 +47,6 @@ import { import { getMatrixRuntime } from "./runtime.js"; import { resolveMatrixOutboundSessionRoute } from "./session-route.js"; import { matrixSetupAdapter } from "./setup-core.js"; -import { matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: { export const matrixPlugin: ChannelPlugin = { id: "matrix", meta, - setupWizard: matrixSetupWizard, pairing: createTextPairingAdapter({ idLabel: "matrixUserId", message: PAIRING_APPROVED_MESSAGE, diff --git a/extensions/matrix/src/cli.test.ts b/extensions/matrix/src/cli.test.ts index a97c083ebce..008fd46795d 100644 --- a/extensions/matrix/src/cli.test.ts +++ b/extensions/matrix/src/cli.test.ts @@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => { expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled(); expect(process.exitCode).toBeUndefined(); - const jsonOutput = console.log.mock.calls.at(-1)?.[0]; + const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at( + -1, + )?.[0]; expect(typeof jsonOutput).toBe("string"); expect(JSON.parse(String(jsonOutput))).toEqual( expect.objectContaining({ diff --git a/extensions/matrix/src/matrix/accounts.test.ts b/extensions/matrix/src/matrix/accounts.test.ts index 45db29362ce..8480ef0e94b 100644 --- a/extensions/matrix/src/matrix/accounts.test.ts +++ b/extensions/matrix/src/matrix/accounts.test.ts @@ -7,7 +7,7 @@ import { resolveMatrixAccount, } from "./accounts.js"; -vi.mock("./credentials.js", () => ({ +vi.mock("./credentials-read.js", () => ({ loadMatrixCredentials: () => null, credentialsMatchConfig: () => false, })); diff --git a/extensions/matrix/src/matrix/accounts.ts b/extensions/matrix/src/matrix/accounts.ts index d0039664ac8..13e33a259a6 100644 --- a/extensions/matrix/src/matrix/accounts.ts +++ b/extensions/matrix/src/matrix/accounts.ts @@ -10,7 +10,7 @@ import { import type { CoreConfig, MatrixConfig } from "../types.js"; import { findMatrixAccountConfig, resolveMatrixBaseConfig } from "./account-config.js"; import { resolveMatrixConfigForAccount } from "./client.js"; -import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials.js"; +import { credentialsMatchConfig, loadMatrixCredentials } from "./credentials-read.js"; /** Merge account config with top-level defaults, preserving nested objects. */ function mergeAccountConfig(base: MatrixConfig, account: MatrixConfig): MatrixConfig { diff --git a/extensions/matrix/src/matrix/client.test.ts b/extensions/matrix/src/matrix/client.test.ts index fc89a4944e7..663e5715daf 100644 --- a/extensions/matrix/src/matrix/client.test.ts +++ b/extensions/matrix/src/matrix/client.test.ts @@ -9,16 +9,20 @@ import { resolveMatrixAuthContext, validateMatrixHomeserverUrl, } from "./client/config.js"; -import * as credentialsModule from "./credentials.js"; +import * as credentialsReadModule from "./credentials-read.js"; import * as sdkModule from "./sdk.js"; const saveMatrixCredentialsMock = vi.hoisted(() => vi.fn()); +const touchMatrixCredentialsMock = vi.hoisted(() => vi.fn()); -vi.mock("./credentials.js", () => ({ +vi.mock("./credentials-read.js", () => ({ loadMatrixCredentials: vi.fn(() => null), - saveMatrixCredentials: saveMatrixCredentialsMock, credentialsMatchConfig: vi.fn(() => false), - touchMatrixCredentials: vi.fn(), +})); + +vi.mock("./credentials-write.runtime.js", () => ({ + saveMatrixCredentials: saveMatrixCredentialsMock, + touchMatrixCredentials: touchMatrixCredentialsMock, })); describe("resolveMatrixConfig", () => { @@ -414,14 +418,14 @@ describe("resolveMatrixAuth", () => { }); it("uses cached matching credentials when access token is not configured", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({ homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "cached-token", deviceId: "CACHEDDEVICE", createdAt: "2026-01-01T00:00:00.000Z", }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true); const cfg = { channels: { @@ -464,13 +468,13 @@ describe("resolveMatrixAuth", () => { }); it("falls back to config deviceId when cached credentials are missing it", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({ homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", createdAt: "2026-01-01T00:00:00.000Z", }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true); const cfg = { channels: { @@ -533,8 +537,8 @@ describe("resolveMatrixAuth", () => { }); it("uses named-account password auth instead of inheriting the base access token", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue(null); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(false); + vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue(null); + vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(false); const doRequestSpy = vi.spyOn(sdkModule.MatrixClient.prototype, "doRequest").mockResolvedValue({ access_token: "ops-token", user_id: "@ops:example.org", @@ -615,13 +619,13 @@ describe("resolveMatrixAuth", () => { }); it("uses config deviceId with cached credentials when token is loaded from cache", async () => { - vi.mocked(credentialsModule.loadMatrixCredentials).mockReturnValue({ + vi.mocked(credentialsReadModule.loadMatrixCredentials).mockReturnValue({ homeserver: "https://matrix.example.org", userId: "@bot:example.org", accessToken: "tok-123", createdAt: "2026-01-01T00:00:00.000Z", }); - vi.mocked(credentialsModule.credentialsMatchConfig).mockReturnValue(true); + vi.mocked(credentialsReadModule.credentialsMatchConfig).mockReturnValue(true); const cfg = { channels: { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 6d137677657..e4be059ccc5 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -19,6 +19,7 @@ import { listNormalizedMatrixAccountIds, } from "../account-config.js"; import { resolveMatrixConfigFieldPath } from "../config-update.js"; +import { credentialsMatchConfig, loadMatrixCredentials } from "../credentials-read.js"; import { MatrixClient } from "../sdk.js"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import type { MatrixAuth, MatrixResolvedConfig } from "./types.js"; @@ -338,13 +339,11 @@ export async function resolveMatrixAuth(params?: { }): Promise { const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params); const homeserver = validateMatrixHomeserverUrl(resolved.homeserver); - - const { - loadMatrixCredentials, - saveMatrixCredentials, - credentialsMatchConfig, - touchMatrixCredentials, - } = await import("../credentials.js"); + let credentialsWriter: typeof import("../credentials-write.runtime.js") | undefined; + const loadCredentialsWriter = async () => { + credentialsWriter ??= await import("../credentials-write.runtime.js"); + return credentialsWriter; + }; const cached = loadMatrixCredentials(env, accountId); const cachedCredentials = @@ -391,6 +390,7 @@ export async function resolveMatrixAuth(params?: { cachedCredentials.userId !== userId || (cachedCredentials.deviceId || undefined) !== knownDeviceId; if (shouldRefreshCachedCredentials) { + const { saveMatrixCredentials } = await loadCredentialsWriter(); await saveMatrixCredentials( { homeserver, @@ -402,6 +402,7 @@ export async function resolveMatrixAuth(params?: { accountId, ); } else if (hasMatchingCachedToken) { + const { touchMatrixCredentials } = await loadCredentialsWriter(); await touchMatrixCredentials(env, accountId); } return { @@ -418,6 +419,7 @@ export async function resolveMatrixAuth(params?: { } if (cachedCredentials) { + const { touchMatrixCredentials } = await loadCredentialsWriter(); await touchMatrixCredentials(env, accountId); return { accountId, @@ -474,6 +476,7 @@ export async function resolveMatrixAuth(params?: { encryption: resolved.encryption, }; + const { saveMatrixCredentials } = await loadCredentialsWriter(); await saveMatrixCredentials( { homeserver: auth.homeserver, diff --git a/extensions/matrix/src/matrix/client/file-sync-store.test.ts b/extensions/matrix/src/matrix/client/file-sync-store.test.ts index 85d61580a17..632ec309210 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.test.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.test.ts @@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse { rooms: { join: { "!room:example.org": { - summary: {}, + summary: { "m.heroes": [] }, state: { events: [] }, timeline: { events: [ @@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse { unread_notifications: {}, }, }, + invite: {}, + leave: {}, + knock: {}, }, account_data: { events: [ @@ -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 () => { diff --git a/extensions/matrix/src/matrix/client/file-sync-store.ts b/extensions/matrix/src/matrix/client/file-sync-store.ts index 9f1d0599569..cbb71e09727 100644 --- a/extensions/matrix/src/matrix/client/file-sync-store.ts +++ b/extensions/matrix/src/matrix/client/file-sync-store.ts @@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = { version: number; savedSync: ISyncData | null; clientOptions?: IStoredClientOpts; + cleanShutdown?: boolean; }; function createAsyncLock() { @@ -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 | null = null; @@ -128,11 +134,13 @@ export class FileBackedMatrixSyncStore extends MemoryStore { let restoredSavedSync: ISyncData | null = null; let restoredClientOptions: IStoredClientOpts | undefined; + let restoredCleanShutdown = false; try { const raw = readFileSync(this.storagePath, "utf8"); const persisted = readPersistedStore(raw); restoredSavedSync = persisted?.savedSync ?? null; restoredClientOptions = persisted?.clientOptions; + restoredCleanShutdown = persisted?.cleanShutdown === true; } catch { // Missing or unreadable sync cache should not block startup. } @@ -140,6 +148,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore { this.savedSync = restoredSavedSync; this.savedClientOptions = restoredClientOptions; this.hadSavedSyncOnLoad = restoredSavedSync !== null; + this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown; + this.cleanShutdown = this.hadCleanShutdownOnLoad; if (this.savedSync) { this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true); @@ -154,6 +164,10 @@ export class FileBackedMatrixSyncStore extends MemoryStore { return this.hadSavedSyncOnLoad; } + hasSavedSyncFromCleanShutdown(): boolean { + return this.hadCleanShutdownOnLoad; + } + override getSavedSync(): Promise { return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null); } @@ -205,9 +219,15 @@ export class FileBackedMatrixSyncStore extends MemoryStore { await super.deleteAllData(); this.savedSync = null; this.savedClientOptions = undefined; + this.cleanShutdown = false; await fs.rm(this.storagePath, { force: true }).catch(() => undefined); } + markCleanShutdown(): void { + this.cleanShutdown = true; + this.dirty = true; + } + async flush(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); @@ -224,6 +244,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore { } private markDirtyAndSchedulePersist(): void { + this.cleanShutdown = false; this.dirty = true; if (this.persistTimer) { return; @@ -242,6 +263,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore { const payload: PersistedMatrixSyncStore = { version: STORE_VERSION, savedSync: this.savedSync ? cloneJson(this.savedSync) : null, + cleanShutdown: this.cleanShutdown === true, ...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}), }; try { diff --git a/extensions/matrix/src/matrix/credentials-read.ts b/extensions/matrix/src/matrix/credentials-read.ts new file mode 100644 index 00000000000..e297072fea4 --- /dev/null +++ b/extensions/matrix/src/matrix/credentials-read.ts @@ -0,0 +1,150 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import { + requiresExplicitMatrixDefaultAccount, + resolveMatrixDefaultOrOnlyAccountId, +} from "../account-selection.js"; +import { getMatrixRuntime } from "../runtime.js"; +import { + resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir, + resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, +} from "../storage-paths.js"; + +export type MatrixStoredCredentials = { + homeserver: string; + userId: string; + accessToken: string; + deviceId?: string; + createdAt: string; + lastUsedAt?: string; +}; + +function resolveStateDir(env: NodeJS.ProcessEnv): string { + return getMatrixRuntime().state.resolveStateDir(env, os.homedir); +} + +function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null { + return path.join(resolveMatrixCredentialsDir(env), "credentials.json"); +} + +function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean { + const normalizedAccountId = normalizeAccountId(accountId); + const cfg = getMatrixRuntime().config.loadConfig(); + if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") { + return normalizedAccountId === DEFAULT_ACCOUNT_ID; + } + if (requiresExplicitMatrixDefaultAccount(cfg)) { + return false; + } + return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId; +} + +function resolveLegacyMigrationSourcePath( + env: NodeJS.ProcessEnv, + accountId?: string | null, +): string | null { + if (!shouldReadLegacyCredentialsForAccount(accountId)) { + return null; + } + const legacyPath = resolveLegacyMatrixCredentialsPath(env); + return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath; +} + +function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null { + const raw = fs.readFileSync(filePath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + if ( + typeof parsed.homeserver !== "string" || + typeof parsed.userId !== "string" || + typeof parsed.accessToken !== "string" + ) { + return null; + } + return parsed as MatrixStoredCredentials; +} + +export function resolveMatrixCredentialsDir( + env: NodeJS.ProcessEnv = process.env, + stateDir?: string, +): string { + const resolvedStateDir = stateDir ?? resolveStateDir(env); + return resolveSharedMatrixCredentialsDir(resolvedStateDir); +} + +export function resolveMatrixCredentialsPath( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): string { + const resolvedStateDir = resolveStateDir(env); + return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId }); +} + +export function loadMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): MatrixStoredCredentials | null { + const credPath = resolveMatrixCredentialsPath(env, accountId); + try { + if (fs.existsSync(credPath)) { + return parseMatrixCredentialsFile(credPath); + } + + const legacyPath = resolveLegacyMigrationSourcePath(env, accountId); + if (!legacyPath || !fs.existsSync(legacyPath)) { + return null; + } + + const parsed = parseMatrixCredentialsFile(legacyPath); + if (!parsed) { + return null; + } + + try { + fs.mkdirSync(path.dirname(credPath), { recursive: true }); + fs.renameSync(legacyPath, credPath); + } catch { + // Keep returning the legacy credentials even if migration fails. + } + + return parsed; + } catch { + return null; + } +} + +export function clearMatrixCredentials( + env: NodeJS.ProcessEnv = process.env, + accountId?: string | null, +): void { + const paths = [ + resolveMatrixCredentialsPath(env, accountId), + resolveLegacyMigrationSourcePath(env, accountId), + ]; + for (const filePath of paths) { + if (!filePath) { + continue; + } + try { + if (fs.existsSync(filePath)) { + fs.unlinkSync(filePath); + } + } catch { + // ignore + } + } +} + +export function credentialsMatchConfig( + stored: MatrixStoredCredentials, + config: { homeserver: string; userId: string; accessToken?: string }, +): boolean { + if (!config.userId) { + if (!config.accessToken) { + return false; + } + return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken; + } + return stored.homeserver === config.homeserver && stored.userId === config.userId; +} diff --git a/extensions/matrix/src/matrix/credentials-write.runtime.ts b/extensions/matrix/src/matrix/credentials-write.runtime.ts new file mode 100644 index 00000000000..5e773861e42 --- /dev/null +++ b/extensions/matrix/src/matrix/credentials-write.runtime.ts @@ -0,0 +1,18 @@ +import type { + saveMatrixCredentials as saveMatrixCredentialsType, + touchMatrixCredentials as touchMatrixCredentialsType, +} from "./credentials.js"; + +export async function saveMatrixCredentials( + ...args: Parameters +): ReturnType { + const runtime = await import("./credentials.js"); + return runtime.saveMatrixCredentials(...args); +} + +export async function touchMatrixCredentials( + ...args: Parameters +): ReturnType { + const runtime = await import("./credentials.js"); + return runtime.touchMatrixCredentials(...args); +} diff --git a/extensions/matrix/src/matrix/credentials.ts b/extensions/matrix/src/matrix/credentials.ts index eaccd0ed487..7fb71715ddf 100644 --- a/extensions/matrix/src/matrix/credentials.ts +++ b/extensions/matrix/src/matrix/credentials.ts @@ -1,119 +1,15 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id"; -import { - requiresExplicitMatrixDefaultAccount, - resolveMatrixDefaultOrOnlyAccountId, -} from "../account-selection.js"; import { writeJsonFileAtomically } from "../runtime-api.js"; -import { getMatrixRuntime } from "../runtime.js"; -import { - resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir, - resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath, -} from "../storage-paths.js"; +import { loadMatrixCredentials, resolveMatrixCredentialsPath } from "./credentials-read.js"; +import type { MatrixStoredCredentials } from "./credentials-read.js"; -export type MatrixStoredCredentials = { - homeserver: string; - userId: string; - accessToken: string; - deviceId?: string; - createdAt: string; - lastUsedAt?: string; -}; - -function resolveStateDir(env: NodeJS.ProcessEnv): string { - return getMatrixRuntime().state.resolveStateDir(env, os.homedir); -} - -function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null { - return path.join(resolveMatrixCredentialsDir(env), "credentials.json"); -} - -function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean { - const normalizedAccountId = normalizeAccountId(accountId); - const cfg = getMatrixRuntime().config.loadConfig(); - if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") { - return normalizedAccountId === DEFAULT_ACCOUNT_ID; - } - if (requiresExplicitMatrixDefaultAccount(cfg)) { - return false; - } - return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId; -} - -function resolveLegacyMigrationSourcePath( - env: NodeJS.ProcessEnv, - accountId?: string | null, -): string | null { - if (!shouldReadLegacyCredentialsForAccount(accountId)) { - return null; - } - const legacyPath = resolveLegacyMatrixCredentialsPath(env); - return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath; -} - -function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null { - const raw = fs.readFileSync(filePath, "utf-8"); - const parsed = JSON.parse(raw) as Partial; - if ( - typeof parsed.homeserver !== "string" || - typeof parsed.userId !== "string" || - typeof parsed.accessToken !== "string" - ) { - return null; - } - return parsed as MatrixStoredCredentials; -} - -export function resolveMatrixCredentialsDir( - env: NodeJS.ProcessEnv = process.env, - stateDir?: string, -): string { - const resolvedStateDir = stateDir ?? resolveStateDir(env); - return resolveSharedMatrixCredentialsDir(resolvedStateDir); -} - -export function resolveMatrixCredentialsPath( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): string { - const resolvedStateDir = resolveStateDir(env); - return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId }); -} - -export function loadMatrixCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): MatrixStoredCredentials | null { - const credPath = resolveMatrixCredentialsPath(env, accountId); - try { - if (fs.existsSync(credPath)) { - return parseMatrixCredentialsFile(credPath); - } - - const legacyPath = resolveLegacyMigrationSourcePath(env, accountId); - if (!legacyPath || !fs.existsSync(legacyPath)) { - return null; - } - - const parsed = parseMatrixCredentialsFile(legacyPath); - if (!parsed) { - return null; - } - - try { - fs.mkdirSync(path.dirname(credPath), { recursive: true }); - fs.renameSync(legacyPath, credPath); - } catch { - // Keep returning the legacy credentials even if migration fails. - } - - return parsed; - } catch { - return null; - } -} +export { + clearMatrixCredentials, + credentialsMatchConfig, + loadMatrixCredentials, + resolveMatrixCredentialsDir, + resolveMatrixCredentialsPath, +} from "./credentials-read.js"; +export type { MatrixStoredCredentials } from "./credentials-read.js"; export async function saveMatrixCredentials( credentials: Omit, @@ -147,38 +43,3 @@ export async function touchMatrixCredentials( const credPath = resolveMatrixCredentialsPath(env, accountId); await writeJsonFileAtomically(credPath, existing); } - -export function clearMatrixCredentials( - env: NodeJS.ProcessEnv = process.env, - accountId?: string | null, -): void { - const paths = [ - resolveMatrixCredentialsPath(env, accountId), - resolveLegacyMigrationSourcePath(env, accountId), - ]; - for (const filePath of paths) { - if (!filePath) { - continue; - } - try { - if (fs.existsSync(filePath)) { - fs.unlinkSync(filePath); - } - } catch { - // ignore - } - } -} - -export function credentialsMatchConfig( - stored: MatrixStoredCredentials, - config: { homeserver: string; userId: string; accessToken?: string }, -): boolean { - if (!config.userId) { - if (!config.accessToken) { - return false; - } - return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken; - } - return stored.homeserver === config.homeserver && stored.userId === config.userId; -} diff --git a/extensions/matrix/src/matrix/index.ts b/extensions/matrix/src/matrix/index.ts new file mode 100644 index 00000000000..9795b10c1a6 --- /dev/null +++ b/extensions/matrix/src/matrix/index.ts @@ -0,0 +1 @@ +export { monitorMatrixProvider } from "./monitor/index.js"; diff --git a/extensions/matrix/src/matrix/monitor/events.test.ts b/extensions/matrix/src/matrix/monitor/events.test.ts index 0f8480424b5..5d4642bdb5e 100644 --- a/extensions/matrix/src/matrix/monitor/events.test.ts +++ b/extensions/matrix/src/matrix/monitor/events.test.ts @@ -62,7 +62,7 @@ function createHarness(params?: { const ensureVerificationDmTracked = vi.fn( params?.ensureVerificationDmTracked ?? (async () => null), ); - const sendMessage = vi.fn(async () => "$notice"); + const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice"); const invalidateRoom = vi.fn(); const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() }; const formatNativeDependencyHint = vi.fn(() => "install hint"); diff --git a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts index e1fc7e969ca..25f17cb0254 100644 --- a/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.media-failure.test.ts @@ -100,6 +100,7 @@ function createHandlerHarness() { mediaMaxBytes: 5 * 1024 * 1024, startupMs: Date.now() - 120_000, startupGraceMs: 60_000, + dropPreStartupMessages: false, directTracker: { isDirectMessage: vi.fn().mockResolvedValue(true), }, diff --git a/extensions/matrix/src/matrix/monitor/handler.test.ts b/extensions/matrix/src/matrix/monitor/handler.test.ts index 2a627c0fc0e..e28afdff33d 100644 --- a/extensions/matrix/src/matrix/monitor/handler.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.test.ts @@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => { mediaMaxBytes: 10_000_000, startupMs: 0, startupGraceMs: 0, + dropPreStartupMessages: false, directTracker: { isDirectMessage: async () => false, }, getRoomInfo: async () => ({ altAliases: [] }), getMemberDisplayName: async () => "sender", + needsRoomAliasesForConfig: false, }); await handler( diff --git a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts index 7dfbcebe401..c08452cd76b 100644 --- a/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts +++ b/extensions/matrix/src/matrix/monitor/handler.thread-root-media.test.ts @@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => { mediaMaxBytes: 5 * 1024 * 1024, startupMs: Date.now() - 120_000, startupGraceMs: 60_000, + dropPreStartupMessages: false, directTracker: { isDirectMessage: vi.fn().mockResolvedValue(true), }, diff --git a/extensions/matrix/src/matrix/monitor/index.test.ts b/extensions/matrix/src/matrix/monitor/index.test.ts index 30d7a6d4890..34538ed5b80 100644 --- a/extensions/matrix/src/matrix/monitor/index.test.ts +++ b/extensions/matrix/src/matrix/monitor/index.test.ts @@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => { hasPersistedSyncState: vi.fn(() => false), }; const createMatrixRoomMessageHandler = vi.fn(() => vi.fn()); - let startClientError: Error | null = null; const resolveTextChunkLimit = vi.fn< (cfg: unknown, channel: unknown, accountId?: unknown) => number >(() => 4000); @@ -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(); diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index cb0b22734be..957d629440c 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -17,8 +17,8 @@ import { resolveMatrixAuth, resolveMatrixAuthContext, resolveSharedMatrixClient, - stopSharedClientInstance, } from "../client.js"; +import { releaseSharedClientInstance } from "../client/shared.js"; import { createMatrixThreadBindingManager } from "../thread-bindings.js"; import { registerMatrixAutoJoin } from "./auto-join.js"; import { resolveMatrixMonitorConfig } from "./config.js"; @@ -131,7 +131,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi setActiveMatrixClient(client, auth.accountId); let cleanedUp = false; let threadBindingManager: { accountId: string; stop: () => void } | null = null; - const cleanup = () => { + const cleanup = async () => { if (cleanedUp) { return; } @@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi try { threadBindingManager?.stop(); } finally { - stopSharedClientInstance(client); + await releaseSharedClientInstance(client, "persist"); setActiveMatrixClient(null, auth.accountId); } }; @@ -273,19 +273,32 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); await new Promise((resolve) => { - const onAbort = () => { - logVerboseMessage("matrix: stopping client"); - cleanup(); - resolve(); + const stopAndResolve = async () => { + try { + logVerboseMessage("matrix: stopping client"); + await cleanup(); + } catch (err) { + logger.warn("matrix: failed during monitor shutdown cleanup", { + error: String(err), + }); + } finally { + resolve(); + } }; if (opts.abortSignal?.aborted) { - onAbort(); + void stopAndResolve(); return; } - opts.abortSignal?.addEventListener("abort", onAbort, { once: true }); + opts.abortSignal?.addEventListener( + "abort", + () => { + void stopAndResolve(); + }, + { once: true }, + ); }); } catch (err) { - cleanup(); + await cleanup(); throw err; } } diff --git a/extensions/matrix/src/matrix/monitor/route.test.ts b/extensions/matrix/src/matrix/monitor/route.test.ts index 3b64f3e4491..5846d45dd9c 100644 --- a/extensions/matrix/src/matrix/monitor/route.test.ts +++ b/extensions/matrix/src/matrix/monitor/route.test.ts @@ -1,12 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { OpenClawConfig } from "../../../../../src/config/config.js"; import { - __testing as sessionBindingTesting, + createTestRegistry, + type OpenClawConfig, + resolveAgentRoute, registerSessionBindingAdapter, -} from "../../../../../src/infra/outbound/session-binding-service.js"; -import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js"; -import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js"; -import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js"; + sessionBindingTesting, + setActivePluginRegistry, +} from "../../../../../test/helpers/extensions/matrix-route-test.js"; import { matrixPlugin } from "../../channel.js"; import { resolveMatrixInboundRoute } from "./route.js"; diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index 3467f12711c..e25d215af05 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => { it("prefers authenticated client media downloads", async () => { const payload = Buffer.from([1, 2, 3, 4]); - const fetchMock = vi.fn(async () => new Response(payload, { status: 200 })); + const fetchMock = vi.fn( + async (_input: RequestInfo | URL, _init?: RequestInit) => + new Response(payload, { status: 200 }), + ); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); const client = new MatrixClient("https://matrix.example.org", "token"); diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 94ac1990096..5b56e07d5d8 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -4,6 +4,7 @@ import { EventEmitter } from "node:events"; import { ClientEvent, MatrixEventEvent, + Preset, createClient as createMatrixJsClient, type MatrixClient as MatrixJsClient, type MatrixEvent, @@ -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 { @@ -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; diff --git a/extensions/matrix/src/matrix/thread-bindings.ts b/extensions/matrix/src/matrix/thread-bindings.ts index d69e477a20a..fe3116f3691 100644 --- a/extensions/matrix/src/matrix/thread-bindings.ts +++ b/extensions/matrix/src/matrix/thread-bindings.ts @@ -173,6 +173,7 @@ function resolveBindingsPath(params: { auth: MatrixAuth; accountId: string; env?: NodeJS.ProcessEnv; + stateDir?: string; }): string { const storagePaths = resolveMatrixStoragePaths({ homeserver: params.auth.homeserver, @@ -181,6 +182,7 @@ function resolveBindingsPath(params: { accountId: params.accountId, deviceId: params.auth.deviceId, env: params.env, + stateDir: params.stateDir, }); return path.join(storagePaths.rootDir, "thread-bindings.json"); } @@ -341,6 +343,7 @@ export async function createMatrixThreadBindingManager(params: { auth: MatrixAuth; client: MatrixClient; env?: NodeJS.ProcessEnv; + stateDir?: string; idleTimeoutMs: number; maxAgeMs: number; enableSweeper?: boolean; @@ -360,6 +363,7 @@ export async function createMatrixThreadBindingManager(params: { auth: params.auth, accountId: params.accountId, env: params.env, + stateDir: params.stateDir, }); const loaded = await loadBindingsFromDisk(filePath, params.accountId); for (const record of loaded) { @@ -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); }, diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 62fe0613524..01e60ba53eb 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -1,8 +1,5 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { - type ChannelSetupDmPolicy, - type ChannelSetupWizardAdapter, -} from "openclaw/plugin-sdk/setup"; +import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { @@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +type MatrixOnboardingStatus = { + channel: typeof channel; + configured: boolean; + statusLines: string[]; + selectionHint?: string; + quickstartScore?: number; +}; + +type MatrixAccountOverrides = Partial>; + +type MatrixOnboardingConfigureContext = { + cfg: CoreConfig; + runtime: RuntimeEnv; + prompter: WizardPrompter; + options?: unknown; + forceAllowFrom: boolean; + accountOverrides: MatrixAccountOverrides; + shouldPromptAccountIds: boolean; +}; + +type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & { + configured: boolean; + label?: string; +}; + +type MatrixOnboardingAdapter = { + channel: typeof channel; + getStatus: (ctx: { + cfg: CoreConfig; + options?: unknown; + accountOverrides: MatrixAccountOverrides; + }) => Promise; + configure: ( + ctx: MatrixOnboardingConfigureContext, + ) => Promise<{ cfg: CoreConfig; accountId?: string }>; + configureInteractive?: ( + ctx: MatrixOnboardingInteractiveContext, + ) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">; + afterConfigWritten?: (ctx: { + previousCfg: CoreConfig; + cfg: CoreConfig; + accountId: string; + runtime: RuntimeEnv; + }) => Promise | void; + dmPolicy?: ChannelSetupDmPolicy; + disable?: (cfg: CoreConfig) => CoreConfig; +}; + function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string { return normalizeAccountId( accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID, @@ -473,7 +518,7 @@ async function runMatrixConfigure(params: { return { cfg: next, accountId }; } -export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = { +export const matrixOnboardingAdapter: MatrixOnboardingAdapter = { channel, getStatus: async ({ cfg, accountOverrides }) => { const resolvedCfg = cfg as CoreConfig; diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index d917f33090f..341569d6beb 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -119,6 +119,25 @@ describe("handleMatrixAction pollVote", () => { ).rejects.toThrow("pollId required"); }); + it("accepts messageId as a pollId alias for poll votes", async () => { + const cfg = {} as CoreConfig; + await handleMatrixAction( + { + action: "pollVote", + roomId: "!room:example", + messageId: "$poll", + pollOptionIndex: 1, + }, + cfg, + ); + + expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", { + cfg, + optionIds: [], + optionIndexes: [1], + }); + }); + it("passes account-scoped opts to add reactions", async () => { const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; await handleMatrixAction( diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index 4e2bd5aff4a..3798818c0d9 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -97,6 +97,27 @@ function readRawParam(params: Record, key: string): unknown { return undefined; } +function readStringAliasParam( + params: Record, + keys: string[], + options: { required?: boolean } = {}, +): string | undefined { + for (const key of keys) { + const raw = readRawParam(params, key); + if (typeof raw !== "string") { + continue; + } + const trimmed = raw.trim(); + if (trimmed) { + return trimmed; + } + } + if (options.required) { + throw new Error(`${keys[0]} required`); + } + return undefined; +} + function readNumericArrayParam( params: Record, key: string, @@ -169,7 +190,10 @@ export async function handleMatrixAction( if (pollActions.has(action)) { const roomId = readRoomId(params); - const pollId = readStringParam(params, "pollId", { required: true }); + const pollId = readStringAliasParam(params, ["pollId", "messageId"], { required: true }); + if (!pollId) { + throw new Error("pollId required"); + } const optionId = readStringParam(params, "pollOptionId"); const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true }); const optionIds = [ diff --git a/extensions/open-prose/runtime-api.ts b/extensions/open-prose/runtime-api.ts index 1601f81be1f..f2aa0034a22 100644 --- a/extensions/open-prose/runtime-api.ts +++ b/extensions/open-prose/runtime-api.ts @@ -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"; diff --git a/extensions/phone-control/runtime-api.ts b/extensions/phone-control/runtime-api.ts index 2e9e0adeba2..7db40d08280 100644 --- a/extensions/phone-control/runtime-api.ts +++ b/extensions/phone-control/runtime-api.ts @@ -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"; diff --git a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts index ccefd20b064..e8ee7403e38 100644 --- a/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts +++ b/extensions/signal/src/monitor.tool-result.sends-tool-summaries-responseprefix.test.ts @@ -1,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, }); } diff --git a/extensions/signal/src/monitor.tool-result.test-harness.ts b/extensions/signal/src/monitor.tool-result.test-harness.ts index 6995e71320e..ad81a4d6da2 100644 --- a/extensions/signal/src/monitor.tool-result.test-harness.ts +++ b/extensions/signal/src/monitor.tool-result.test-harness.ts @@ -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; + }; + }) => { + 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(); +vi.mock("./send.js", async (importOriginal) => { + const actual = await importOriginal(); 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(); + 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(); + 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([]); diff --git a/extensions/signal/src/monitor.ts b/extensions/signal/src/monitor.ts index 20f0c943823..b0e601fc01e 100644 --- a/extensions/signal/src/monitor.ts +++ b/extensions/signal/src/monitor.ts @@ -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; mediaMaxMb?: number; reconnectPolicy?: Partial; + waitForTransportReady?: typeof waitForTransportReady; }; function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv { @@ -217,8 +218,10 @@ async function waitForSignalDaemonReady(params: { logAfterMs: number; logIntervalMs?: number; runtime: RuntimeEnv; + waitForTransportReadyFn?: typeof waitForTransportReady; }): Promise { - await waitForTransportReady({ + const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady; + await waitForTransportReadyFn({ label: "signal daemon", timeoutMs: params.timeoutMs, logAfterMs: params.logAfterMs, @@ -374,6 +377,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024; const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false; const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts); + const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady; const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl; const startupTimeoutMs = Math.min( @@ -416,6 +420,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi logAfterMs: 10_000, logIntervalMs: 10_000, runtime, + waitForTransportReadyFn, }); const daemonExitError = daemonLifecycle.getExitError(); if (daemonExitError) { diff --git a/extensions/slack/src/blocks.test-helpers.ts b/extensions/slack/src/blocks.test-helpers.ts index ce628d73449..ae5c92818d1 100644 --- a/extensions/slack/src/blocks.test-helpers.ts +++ b/extensions/slack/src/blocks.test-helpers.ts @@ -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(); - return { - ...actual, - loadConfig: () => ({}), - }; -}); - -vi.mock("./accounts.js", () => ({ - resolveSlackAccount: () => ({ - accountId: "default", - botToken: "xoxb-test", - botTokenSource: "config", - config: {}, - }), -})); - export type SlackEditTestClient = WebClient & { chat: { update: ReturnType; @@ -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(); + return { + ...actual, + loadConfig: () => slackBlockTestState.config, + }; +}); + +vi.mock("./accounts.js", async (importOriginal) => { + const actual = await importOriginal(); + 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 { diff --git a/extensions/slack/src/monitor.test-helpers.ts b/extensions/slack/src/monitor.test-helpers.ts index 87443e5332c..9980c34e29b 100644 --- a/extensions/slack/src/monitor.test-helpers.ts +++ b/extensions/slack/src/monitor.test-helpers.ts @@ -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(); + const replyResolver: typeof actual.getReplyFromConfig = (...args) => + slackTestState.replyMock(...args) as ReturnType; return { ...actual, - dispatchInboundMessage: async (params: { - ctx: unknown; - replyOptions?: { - onReplyStart?: () => Promise | void; - onAssistantMessageStart?: () => Promise | void; - }; - dispatcher: { - sendFinalReply: (payload: unknown) => boolean; - waitForIdle: () => Promise; - 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[0]) => + actual.dispatchInboundMessage({ + ...params, + replyResolver, + }), + dispatchInboundMessageWithBufferedDispatcher: ( + params: Parameters[0], + ) => + actual.dispatchInboundMessageWithBufferedDispatcher({ + ...params, + replyResolver, + }), + dispatchInboundMessageWithDispatcher: ( + params: Parameters[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(); + return { + ...actual, + sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args), + }; +}); vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => { const actual = await importOriginal(); @@ -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); } diff --git a/extensions/slack/src/monitor/slash.test-harness.ts b/extensions/slack/src/monitor/slash.test-harness.ts index d8f09d74cda..48a11cf3460 100644 --- a/extensions/slack/src/monitor/slash.test-harness.ts +++ b/extensions/slack/src/monitor/slash.test-harness.ts @@ -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(); - return { - ...actual, - createChannelReplyPipeline: (...args: unknown[]) => - mocks.createChannelReplyPipelineMock(...args), - }; -}); - vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, - recordSessionMetaFromInbound: (...args: unknown[]) => - mocks.recordSessionMetaFromInboundMock(...args), resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args), }; }); @@ -75,7 +64,7 @@ type SlashHarnessMocks = { resolveAgentRouteMock: ReturnType; finalizeInboundContextMock: ReturnType; resolveConversationLabelMock: ReturnType; - createChannelReplyPipelineMock: ReturnType; + createReplyPrefixOptionsMock: ReturnType; recordSessionMetaFromInboundMock: ReturnType; resolveStorePathMock: ReturnType; }; @@ -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"); } diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts index f4cc507c59e..a1f537ffc32 100644 --- a/extensions/slack/src/monitor/slash.test.ts +++ b/extensions/slack/src/monitor/slash.test.ts @@ -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; -let registerSlackMonitorSlashCommands: RegisterFn; +let registerSlackMonitorSlashCommandsPromise: Promise | undefined; + +async function loadRegisterSlackMonitorSlashCommands(): Promise { + 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 }); } diff --git a/extensions/talk-voice/api.ts b/extensions/talk-voice/api.ts index a5ae821e944..f2aa0034a22 100644 --- a/extensions/talk-voice/api.ts +++ b/extensions/talk-voice/api.ts @@ -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"; diff --git a/extensions/telegram/src/bot.create-telegram-bot.test.ts b/extensions/telegram/src/bot.create-telegram-bot.test.ts index 43689ae6b82..5384c93a54f 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test.ts @@ -59,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = { textFragmentGapMs: 30, } as const; +async function withIsolatedStateDirAsync(fn: () => Promise): Promise { + 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(cfg: unknown, fn: () => Promise): Promise { + 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) => Promise; + 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(""); + } + }); + }); + 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) => Promise; - createTelegramBot({ token: "tok" }); - const handler = getOnHandler("message") as (ctx: Record) => Promise; - 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(""); + 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) => Promise; - - 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) => Promise; - - 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) => Promise; + + 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) => Promise; + + 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) => Promise; - - 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) => Promise; + + 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) => Promise; - - 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 () => { diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 41dec78c70d..e1f464c52a5 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -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; linkPreview?: boolean; silent?: boolean; @@ -252,7 +253,7 @@ async function deliverMediaReply(params: { let pendingFollowUpText: string | undefined; for (const mediaUrl of params.mediaList) { const isFirstMedia = first; - const media = await loadWebMedia( + const media = await params.mediaLoader( mediaUrl, buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }), ); @@ -569,12 +570,15 @@ export async function deliverReplies(params: { silent?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; + /** Override media loader (tests). */ + mediaLoader?: typeof loadWebMedia; }): Promise<{ delivered: boolean }> { const progress: DeliveryProgress = { hasReplied: false, hasDelivered: false, deliveredCount: 0, }; + const mediaLoader = params.mediaLoader ?? loadWebMedia; const hookRunner = getGlobalHookRunner(); const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false; const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false; @@ -663,6 +667,7 @@ export async function deliverReplies(params: { tableMode: params.tableMode, mediaLocalRoots: params.mediaLocalRoots, chunkText, + mediaLoader, onVoiceRecording: params.onVoiceRecording, linkPreview: params.linkPreview, silent: params.silent, diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 20642a225ea..d22c97802cd 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1,9 +1,10 @@ import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../../../../src/runtime.js"; -import { deliverReplies } from "./delivery.js"; -const loadWebMedia = vi.fn(); +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {})); const messageHookRunner = vi.hoisted(() => ({ hasHooks: vi.fn<(name: string) => boolean>(() => false), @@ -21,12 +22,15 @@ type DeliverWithParams = Omit< DeliverRepliesParams, "chatId" | "token" | "replyToMode" | "textLimit" > & - Partial>; + Partial>; type RuntimeStub = Pick; vi.mock("openclaw/plugin-sdk/web-media", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); +vi.mock("openclaw/plugin-sdk/web-media.js", () => ({ + loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), +})); vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, @@ -42,6 +46,9 @@ vi.mock("../../../../src/hooks/internal-hooks.js", async () => { }; }); +vi.resetModules(); +const { deliverReplies } = await import("./delivery.js"); + vi.mock("grammy", () => ({ InputFile: class { constructor( @@ -70,6 +77,7 @@ async function deliverWith(params: DeliverWithParams) { await deliverReplies({ ...baseDeliveryParams, ...params, + mediaLoader: params.mediaLoader ?? loadWebMedia, }); } diff --git a/extensions/telegram/src/monitor.test.ts b/extensions/telegram/src/monitor.test.ts index 515f9f55b71..d53cf4cffb2 100644 --- a/extensions/telegram/src/monitor.test.ts +++ b/extensions/telegram/src/monitor.test.ts @@ -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 void | Promise>>; + waitForTaskStart: () => Promise; } { let running = true; let releaseTask: (() => void) | undefined; + let releaseBeforeTaskStart = false; + let signalTaskStarted: (() => void) | undefined; + const taskStarted = new Promise((resolve) => { + signalTaskStarted = resolve; + }); const stop = vi.fn(async () => { running = false; - releaseTask?.(); + if (releaseTask) { + releaseTask(); + return; + } + releaseBeforeTaskStart = true; }); runSpy.mockImplementationOnce(() => makeRunnerStub({ task: () => new Promise((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); diff --git a/extensions/tlon/package.json b/extensions/tlon/package.json index 386e41c74a3..a7fdf99b2c4 100644 --- a/extensions/tlon/package.json +++ b/extensions/tlon/package.json @@ -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" diff --git a/extensions/whatsapp/src/active-listener.test.ts b/extensions/whatsapp/src/active-listener.test.ts new file mode 100644 index 00000000000..a1d037f788a --- /dev/null +++ b/extensions/whatsapp/src/active-listener.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +type ActiveListenerModule = typeof import("./active-listener.js"); + +const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href; + +async function importActiveListenerModule(cacheBust: string): Promise { + return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule; +} + +afterEach(async () => { + const mod = await importActiveListenerModule(`cleanup-${Date.now()}`); + mod.setActiveWebListener(null); + mod.setActiveWebListener("work", null); +}); + +describe("active WhatsApp listener singleton", () => { + it("shares listeners across duplicate module instances", async () => { + const first = await importActiveListenerModule(`first-${Date.now()}`); + const second = await importActiveListenerModule(`second-${Date.now()}`); + const listener = { + sendMessage: vi.fn(async () => ({ messageId: "msg-1" })), + sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), + sendReaction: vi.fn(async () => {}), + sendComposingTo: vi.fn(async () => {}), + }; + + first.setActiveWebListener("work", listener); + + expect(second.getActiveWebListener("work")).toBe(listener); + expect(second.requireActiveWebListener("work")).toEqual({ + accountId: "work", + listener, + }); + }); +}); diff --git a/extensions/whatsapp/src/active-listener.ts b/extensions/whatsapp/src/active-listener.ts index 3315a5775ec..8b62d15ff1f 100644 --- a/extensions/whatsapp/src/active-listener.ts +++ b/extensions/whatsapp/src/active-listener.ts @@ -28,27 +28,22 @@ export type ActiveWebListener = { close?: () => Promise; }; -// Use a process-level singleton to survive bundler code-splitting. -// Rolldown duplicates this module across multiple output chunks, each with its -// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's -// Map via setActiveWebListener(), but the outbound send path reads from a -// different chunk's Map via requireActiveWebListener() — so the listener is -// never found. Pinning the Map to globalThis ensures all chunks share one -// instance. See: https://github.com/openclaw/openclaw/issues/14406 -const GLOBAL_KEY = "__openclaw_wa_listeners" as const; -const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const; +// Use process-global symbol keys to survive bundler code-splitting and loader +// cache splits without depending on fragile string property names. +const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners"); +const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener"); type GlobalWithListeners = typeof globalThis & { - [GLOBAL_KEY]?: Map; + [GLOBAL_LISTENERS_KEY]?: Map; [GLOBAL_CURRENT_KEY]?: ActiveWebListener | null; }; const _global = globalThis as GlobalWithListeners; -_global[GLOBAL_KEY] ??= new Map(); +_global[GLOBAL_LISTENERS_KEY] ??= new Map(); _global[GLOBAL_CURRENT_KEY] ??= null; -const listeners = _global[GLOBAL_KEY]; +const listeners = _global[GLOBAL_LISTENERS_KEY]; function getCurrentListener(): ActiveWebListener | null { return _global[GLOBAL_CURRENT_KEY] ?? null; diff --git a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts index 651074db852..234b4dddfd5 100644 --- a/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts +++ b/extensions/whatsapp/src/auto-reply/heartbeat-runner.test.ts @@ -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(); + 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(); @@ -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(); + return { + ...actual, + sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })), + sendReactionWhatsApp: vi.fn(async () => undefined), + }; +}); vi.mock("../session.js", () => ({ formatError: (err: unknown) => `ERR:${String(err)}`, diff --git a/extensions/whatsapp/src/test-helpers.ts b/extensions/whatsapp/src/test-helpers.ts index 6ce9a3e3f1c..74c5f8c3584 100644 --- a/extensions/whatsapp/src/test-helpers.ts +++ b/extensions/whatsapp/src/test-helpers.ts @@ -34,15 +34,21 @@ export function resetLoadConfigMock() { vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { const actual = await importOriginal(); - return { - ...actual, - loadConfig: () => { + const mockModule = Object.create(null) as Record; + Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "loadConfig", { + configurable: true, + enumerable: true, + writable: true, + value: () => { const getter = (globalThis as Record)[CONFIG_KEY]; if (typeof getter === "function") { return getter(); } return DEFAULT_CONFIG; }, + }); + Object.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(); - return { - ...actual, - loadConfig: () => { + const mockModule = Object.create(null) as Record; + Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual)); + Object.defineProperty(mockModule, "loadConfig", { + configurable: true, + enumerable: true, + writable: true, + value: () => { const getter = (globalThis as Record)[CONFIG_KEY]; if (typeof getter === "function") { return getter(); } return DEFAULT_CONFIG; }, - }; + }); + return mockModule; }); vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { diff --git a/infra/azure/templates/azuredeploy.json b/infra/azure/templates/azuredeploy.json new file mode 100644 index 00000000000..41157feec46 --- /dev/null +++ b/infra/azure/templates/azuredeploy.json @@ -0,0 +1,340 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "type": "string", + "defaultValue": "westus2", + "metadata": { + "description": "Azure region for all resources. Any valid Azure region is allowed (no allowedValues restriction)." + } + }, + "vmName": { + "type": "string", + "defaultValue": "vm-openclaw", + "metadata": { + "description": "OpenClaw VM name." + } + }, + "vmSize": { + "type": "string", + "defaultValue": "Standard_B2as_v2", + "metadata": { + "description": "Azure VM size for OpenClaw host." + } + }, + "adminUsername": { + "type": "string", + "defaultValue": "openclaw", + "minLength": 1, + "maxLength": 32, + "metadata": { + "description": "Linux admin username." + } + }, + "sshPublicKey": { + "type": "string", + "metadata": { + "description": "SSH public key content (for example ssh-ed25519 ...)." + } + }, + "vnetName": { + "type": "string", + "defaultValue": "vnet-openclaw", + "metadata": { + "description": "Virtual network name." + } + }, + "vnetAddressPrefix": { + "type": "string", + "defaultValue": "10.40.0.0/16", + "metadata": { + "description": "Address space for the virtual network." + } + }, + "vmSubnetName": { + "type": "string", + "defaultValue": "snet-openclaw-vm", + "metadata": { + "description": "Subnet name for OpenClaw VM." + } + }, + "vmSubnetPrefix": { + "type": "string", + "defaultValue": "10.40.2.0/24", + "metadata": { + "description": "Address prefix for VM subnet." + } + }, + "bastionSubnetPrefix": { + "type": "string", + "defaultValue": "10.40.1.0/26", + "metadata": { + "description": "Address prefix for AzureBastionSubnet (must be /26 or larger)." + } + }, + "nsgName": { + "type": "string", + "defaultValue": "nsg-openclaw-vm", + "metadata": { + "description": "Network security group for VM subnet." + } + }, + "nicName": { + "type": "string", + "defaultValue": "nic-openclaw-vm", + "metadata": { + "description": "NIC for OpenClaw VM." + } + }, + "bastionName": { + "type": "string", + "defaultValue": "bas-openclaw", + "metadata": { + "description": "Azure Bastion host name." + } + }, + "bastionPublicIpName": { + "type": "string", + "defaultValue": "pip-openclaw-bastion", + "metadata": { + "description": "Public IP used by Bastion." + } + }, + "osDiskSizeGb": { + "type": "int", + "defaultValue": 64, + "minValue": 30, + "maxValue": 1024, + "metadata": { + "description": "OS disk size in GiB." + } + } + }, + "variables": { + "bastionSubnetName": "AzureBastionSubnet" + }, + "resources": [ + { + "type": "Microsoft.Network/networkSecurityGroups", + "apiVersion": "2023-11-01", + "name": "[parameters('nsgName')]", + "location": "[parameters('location')]", + "properties": { + "securityRules": [ + { + "name": "AllowSshFromAzureBastionSubnet", + "properties": { + "priority": 100, + "access": "Allow", + "direction": "Inbound", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "22", + "sourceAddressPrefix": "[parameters('bastionSubnetPrefix')]", + "destinationAddressPrefix": "*" + } + }, + { + "name": "DenyInternetSsh", + "properties": { + "priority": 110, + "access": "Deny", + "direction": "Inbound", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "22", + "sourceAddressPrefix": "Internet", + "destinationAddressPrefix": "*" + } + }, + { + "name": "DenyVnetSsh", + "properties": { + "priority": 120, + "access": "Deny", + "direction": "Inbound", + "protocol": "Tcp", + "sourcePortRange": "*", + "destinationPortRange": "22", + "sourceAddressPrefix": "VirtualNetwork", + "destinationAddressPrefix": "*" + } + } + ] + } + }, + { + "type": "Microsoft.Network/virtualNetworks", + "apiVersion": "2023-11-01", + "name": "[parameters('vnetName')]", + "location": "[parameters('location')]", + "properties": { + "addressSpace": { + "addressPrefixes": ["[parameters('vnetAddressPrefix')]"] + }, + "subnets": [ + { + "name": "[variables('bastionSubnetName')]", + "properties": { + "addressPrefix": "[parameters('bastionSubnetPrefix')]" + } + }, + { + "name": "[parameters('vmSubnetName')]", + "properties": { + "addressPrefix": "[parameters('vmSubnetPrefix')]", + "networkSecurityGroup": { + "id": "[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]" + } + } + } + ] + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]" + ] + }, + { + "type": "Microsoft.Network/publicIPAddresses", + "apiVersion": "2023-11-01", + "name": "[parameters('bastionPublicIpName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard" + }, + "properties": { + "publicIPAllocationMethod": "Static" + } + }, + { + "type": "Microsoft.Network/bastionHosts", + "apiVersion": "2023-11-01", + "name": "[parameters('bastionName')]", + "location": "[parameters('location')]", + "sku": { + "name": "Standard" + }, + "dependsOn": [ + "[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]", + "[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]" + ], + "properties": { + "enableTunneling": true, + "ipConfigurations": [ + { + "name": "bastionIpConfig", + "properties": { + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), variables('bastionSubnetName'))]" + }, + "publicIPAddress": { + "id": "[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]" + } + } + } + ] + } + }, + { + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2023-11-01", + "name": "[parameters('nicName')]", + "location": "[parameters('location')]", + "dependsOn": ["[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]"], + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "privateIPAllocationMethod": "Dynamic", + "subnet": { + "id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('vmSubnetName'))]" + } + } + } + ] + } + }, + { + "type": "Microsoft.Compute/virtualMachines", + "apiVersion": "2023-09-01", + "name": "[parameters('vmName')]", + "location": "[parameters('location')]", + "dependsOn": ["[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"], + "properties": { + "hardwareProfile": { + "vmSize": "[parameters('vmSize')]" + }, + "osProfile": { + "computerName": "[parameters('vmName')]", + "adminUsername": "[parameters('adminUsername')]", + "linuxConfiguration": { + "disablePasswordAuthentication": true, + "ssh": { + "publicKeys": [ + { + "path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]", + "keyData": "[parameters('sshPublicKey')]" + } + ] + } + } + }, + "storageProfile": { + "imageReference": { + "publisher": "Canonical", + "offer": "ubuntu-24_04-lts", + "sku": "server", + "version": "latest" + }, + "osDisk": { + "createOption": "FromImage", + "diskSizeGB": "[parameters('osDiskSizeGb')]", + "managedDisk": { + "storageAccountType": "StandardSSD_LRS" + } + } + }, + "networkProfile": { + "networkInterfaces": [ + { + "id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]" + } + ] + }, + "diagnosticsProfile": { + "bootDiagnostics": { + "enabled": true + } + } + } + } + ], + "outputs": { + "vmName": { + "type": "string", + "value": "[parameters('vmName')]" + }, + "vmPrivateIp": { + "type": "string", + "value": "[reference(resourceId('Microsoft.Network/networkInterfaces', parameters('nicName')), '2023-11-01').ipConfigurations[0].properties.privateIPAddress]" + }, + "vnetName": { + "type": "string", + "value": "[parameters('vnetName')]" + }, + "vmSubnetName": { + "type": "string", + "value": "[parameters('vmSubnetName')]" + }, + "bastionName": { + "type": "string", + "value": "[parameters('bastionName')]" + }, + "bastionResourceId": { + "type": "string", + "value": "[resourceId('Microsoft.Network/bastionHosts', parameters('bastionName'))]" + } + } +} diff --git a/infra/azure/templates/azuredeploy.parameters.json b/infra/azure/templates/azuredeploy.parameters.json new file mode 100644 index 00000000000..dead2e5dd3f --- /dev/null +++ b/infra/azure/templates/azuredeploy.parameters.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "location": { + "value": "westus2" + }, + "vmName": { + "value": "vm-openclaw" + }, + "vmSize": { + "value": "Standard_B2as_v2" + }, + "adminUsername": { + "value": "openclaw" + }, + "vnetName": { + "value": "vnet-openclaw" + }, + "vnetAddressPrefix": { + "value": "10.40.0.0/16" + }, + "vmSubnetName": { + "value": "snet-openclaw-vm" + }, + "vmSubnetPrefix": { + "value": "10.40.2.0/24" + }, + "bastionSubnetPrefix": { + "value": "10.40.1.0/26" + }, + "nsgName": { + "value": "nsg-openclaw-vm" + }, + "nicName": { + "value": "nic-openclaw-vm" + }, + "bastionName": { + "value": "bas-openclaw" + }, + "bastionPublicIpName": { + "value": "pip-openclaw-bastion" + }, + "osDiskSizeGb": { + "value": 64 + } + } +} diff --git a/openclaw.mjs b/openclaw.mjs index 099c7f6a406..432ee961fb0 100755 --- a/openclaw.mjs +++ b/openclaw.mjs @@ -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#` instead of a raw `/archive/.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()); } diff --git a/package.json b/package.json index 797142fc574..e70c7dc3061 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e381cdf6d34..4063b3951be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -536,8 +536,8 @@ importers: extensions/tlon: dependencies: '@tloncorp/api': - specifier: github:tloncorp/api-beta#7eede1c1a756977b09f96aa14a92e2b06318ae87 - version: git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87 + specifier: git+https://github.com/tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87 + version: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87 '@tloncorp/tlon-skill': specifier: 0.2.2 version: 0.2.2 @@ -739,10 +739,6 @@ packages: resolution: {integrity: sha512-7kPy33qNGq3NfwHC0412T6LDK1bp4+eiPzetX0sVd9cpTSXuQDKpoOFnB0Njj6uZjJDcLS3n2OeyarwwgkQ0Ow==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.15': - resolution: {integrity: sha512-AlC0oQ1/mdJ8vCIqu524j5RB7M8i8E24bbkZmya1CuiQxkY7SdIZAyw7NDNMGaNINQFq/8oGRMX0HeOfCVsl/A==} - engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.20': resolution: {integrity: sha512-i3GuX+lowD892F3IuJf8o6AbyDupMTdyTxQrCJGcn71ni5hTZ82L4nQhcdumxZ7XPJRJJVHS/CR3uYOIIs0PVA==} engines: {node: '>=20.0.0'} @@ -751,66 +747,34 @@ packages: resolution: {integrity: sha512-UExeK+EFiq5LAcbHm96CQLSia+5pvpUVSAsVApscBzayb7/6dJBJKwV4/onsk4VbWSmqxDMcfuTD+pC4RxgZHg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.13': - resolution: {integrity: sha512-6ljXKIQ22WFKyIs1jbORIkGanySBHaPPTOI4OxACP5WXgbcR0nDYfqNJfXEGwCK7IzHdNbCSFsNKKs0qCexR8Q==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.18': resolution: {integrity: sha512-X0B8AlQY507i5DwjLByeU2Af4ARsl9Vr84koDcXCbAkplmU+1xBFWxEPrWRAoh56waBne/yJqEloSwvRf4x6XA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.15': - resolution: {integrity: sha512-dJuSTreu/T8f24SHDNTjd7eQ4rabr0TzPh2UTCwYexQtzG3nTDKm1e5eIdhiroTMDkPEJeY+WPkA6F9wod/20A==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.20': resolution: {integrity: sha512-ey9Lelj001+oOfrbKmS6R2CJAiXX7QKY4Vj9VJv6L2eE6/VjD8DocHIoYqztTm70xDLR4E1jYPTKfIui+eRNDA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.13': - resolution: {integrity: sha512-JKSoGb7XeabZLBJptpqoZIFbROUIS65NuQnEHGOpuT9GuuZwag2qciKANiDLFiYk4u8nSrJC9JIOnWKVvPVjeA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.20': resolution: {integrity: sha512-5flXSnKHMloObNF+9N0cupKegnH1Z37cdVlpETVgx8/rAhCe+VNlkcZH3HDg2SDn9bI765S+rhNPXGDJJPfbtA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.13': - resolution: {integrity: sha512-RtYcrxdnJHKY8MFQGLltCURcjuMjnaQpAxPE6+/QEdDHHItMKZgabRe/KScX737F9vJMQsmJy9EmMOkCnoC1JQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.20': resolution: {integrity: sha512-gEWo54nfqp2jABMu6HNsjVC4hDLpg9HC8IKSJnp0kqWtxIJYHTmiLSsIfI4ScQjxEwpB+jOOH8dOLax1+hy/Hw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.14': - resolution: {integrity: sha512-WqoC2aliIjQM/L3oFf6j+op/enT2i9Cc4UTxxMEKrJNECkq4/PlKE5BOjSYFcq6G9mz65EFbXJh7zOU4CvjSKQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.21': resolution: {integrity: sha512-hah8if3/B/Q+LBYN5FukyQ1Mym6PLPDsBOBsIgNEYD6wLyZg0UmUF/OKIVC3nX9XH8TfTPuITK+7N/jenVACWA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.13': - resolution: {integrity: sha512-rsRG0LQA4VR+jnDyuqtXi2CePYSmfm5GNL9KxiW8DSe25YwJSr06W8TdUfONAC+rjsTI+aIH2rBGG5FjMeANrw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.18': resolution: {integrity: sha512-Tpl7SRaPoOLT32jbTWchPsn52hYYgJ0kpiFgnwk8pxTANQdUymVSZkzFvv1+oOgZm1CrbQUP9MBeoMZ9IzLZjA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.13': - resolution: {integrity: sha512-fr0UU1wx8kNHDhTQBXioc/YviSW8iXuAxHvnH7eQUtn8F8o/FU3uu6EUMvAQgyvn7Ne5QFnC0Cj0BFlwCk+RFw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.20': resolution: {integrity: sha512-p+R+PYR5Z7Gjqf/6pvbCnzEHcqPCpLzR7Yf127HjJ6EAb4hUcD+qsNRnuww1sB/RmSeCLxyay8FMyqREw4p1RA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.13': - resolution: {integrity: sha512-a6iFMh1pgUH0TdcouBppLJUfPM7Yd3R9S1xFodPtCRoLqCz2RQFA3qjA8x4112PVYXEd4/pHX2eihapq39w0rA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.20': resolution: {integrity: sha512-rWCmh8o7QY4CsUj63qopzMzkDq/yPpkrpb+CnjBEFSOg/02T/we7sSTVg4QsDiVS9uwZ8VyONhq98qt+pIh3KA==} engines: {node: '>=20.0.0'} @@ -843,10 +807,6 @@ packages: resolution: {integrity: sha512-QLXsxsI6VW8LuGK+/yx699wzqP/NMCGk/hSGP+qtB+Lcff+23UlbahyouLlk+nfT7Iu021SkXBhnAuVd6IZcPw==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.6': - resolution: {integrity: sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.8': resolution: {integrity: sha512-wAr2REfKsqoKQ+OkNqvOShnBoh+nkPurDKW7uAeVSu6kUECnWlSJiPvnoqxGlfousEY/v9LfS9sNc46hjSYDIQ==} engines: {node: '>=20.0.0'} @@ -855,18 +815,10 @@ packages: resolution: {integrity: sha512-XdZ2TLwyj3Am6kvUc67vquQvs6+D8npXvXgyEUJAdkUDx5oMFJKOqpK+UpJhVDsEL068WAJl2NEGzbSik7dGJQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.6': - resolution: {integrity: sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.8': resolution: {integrity: sha512-CWl5UCM57WUFaFi5kB7IBY1UmOeLvNZAZ2/OZ5l20ldiJ3TiIz1pC65gYj8X0BCPWkeR1E32mpsCk1L1I4n+lA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.6': - resolution: {integrity: sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.8': resolution: {integrity: sha512-BnnvYs2ZEpdlmZ2PNlV2ZyQ8j8AEkMTjN79y/YA475ER1ByFYrkVR85qmhni8oeTaJcDqbx364wDpitDAA/wCA==} engines: {node: '>=20.0.0'} @@ -879,10 +831,6 @@ packages: resolution: {integrity: sha512-acvMUX9jF4I2Ew+Z/EA6gfaFaz9ehci5wxBmXCZeulLuv8m+iGf6pY9uKz8TPjg39bdAz3hxoE0eLP8Qz+IYlA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.15': - resolution: {integrity: sha512-ABlFVcIMmuRAwBT+8q5abAxOr7WmaINirDJBnqGY5b5jSDo00UMlg/G4a0xoAgwm6oAECeJcwkvDlxDwKf58fQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-user-agent@3.972.21': resolution: {integrity: sha512-62XRl1GDYPpkt7cx1AX1SPy9wgNE9Iw/NPuurJu4lmhCWS7sGKO+kS53TQ8eRmIxy3skmvNInnk0ZbWrU5Dpyg==} engines: {node: '>=20.0.0'} @@ -899,14 +847,6 @@ packages: resolution: {integrity: sha512-SlDol5Z+C7Ivnc2rKGqiqfSUmUZzY1qHfVs9myt/nxVwswgfpjdKahyTzLTx802Zfq0NFRs7AejwKzzzl5Co2w==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.996.3': - resolution: {integrity: sha512-AU5TY1V29xqwg/MxmA2odwysTez+ccFAhmfRJk+QZT5HNv90UTA9qKd1J9THlsQkvmH7HWTEV1lDNxkQO5PzNw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.6': - resolution: {integrity: sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==} - engines: {node: '>=20.0.0'} - '@aws-sdk/region-config-resolver@3.972.8': resolution: {integrity: sha512-1eD4uhTDeambO/PNIDVG19A6+v4NdD7xzwLHDutHsUqz0B+i661MwQB2eYO4/crcCvCiQG4SRm1k81k54FEIvw==} engines: {node: '>=20.0.0'} @@ -931,18 +871,6 @@ packages: resolution: {integrity: sha512-WSfBVDQ9uyh1GCR+DxxgHEvAKv+beMIlSeJ2pMAG1HTci340+xbtz1VFwnTJ5qCxrMi+E4dyDMiSAhDvHnq73A==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.999.0': - resolution: {integrity: sha512-cx0hHUlgXULfykx4rdu/ciNAJaa3AL5xz3rieCz7NKJ68MJwlj3664Y8WR5MGgxfyYJBdamnkjNSx5Kekuc0cg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.4': - resolution: {integrity: sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.5': - resolution: {integrity: sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.973.6': resolution: {integrity: sha512-Atfcy4E++beKtwJHiDln2Nby8W/mam64opFPTiHEqgsthqeydFS1pY+OUlN1ouNOmf8ArPU/6cDS65anOP3KQw==} engines: {node: '>=20.0.0'} @@ -951,49 +879,21 @@ packages: resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.996.3': - resolution: {integrity: sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==} - engines: {node: '>=20.0.0'} - '@aws-sdk/util-endpoints@3.996.5': resolution: {integrity: sha512-Uh93L5sXFNbyR5sEPMzUU8tJ++Ku97EY4udmC01nB8Zu+xfBPwpIwJ6F7snqQeq8h2pf+8SGN5/NoytfKgYPIw==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-format-url@3.972.6': - resolution: {integrity: sha512-0YNVNgFyziCejXJx0rzxPiD2rkxTWco4c9wiMF6n37Tb9aQvIF8+t7GyEyIFCwQHZ0VMQaAl+nCZHOYz5I5EKw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-format-url@3.972.7': - resolution: {integrity: sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA==} - engines: {node: '>=20.0.0'} - '@aws-sdk/util-format-url@3.972.8': resolution: {integrity: sha512-J6DS9oocrgxM8xlUTTmQOuwRF6rnAGEujAN9SAzllcrQmwn5iJ58ogxy3SEhD0Q7JZvlA5jvIXBkpQRqEqlE9A==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.4': - resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} - engines: {node: '>=20.0.0'} - '@aws-sdk/util-locate-window@3.965.5': resolution: {integrity: sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/util-user-agent-browser@3.972.6': - resolution: {integrity: sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==} - '@aws-sdk/util-user-agent-browser@3.972.8': resolution: {integrity: sha512-B3KGXJviV2u6Cdw2SDY2aDhoJkVfY/Q/Trwk2CMSkikE1Oi6gRzxhvhIfiRpHfmIsAhV4EA54TVEX8K6CbHbkA==} - '@aws-sdk/util-user-agent-node@3.973.0': - resolution: {integrity: sha512-A9J2G4Nf236e9GpaC1JnA8wRn6u6GjnOXiTwBLA6NUJhlBTIGfrTy+K1IazmF8y+4OFdW3O5TZlhyspJMqiqjA==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - '@aws-sdk/util-user-agent-node@3.973.7': resolution: {integrity: sha512-Hz6EZMUAEzqUd7e+vZ9LE7mn+5gMbxltXy18v+YSFY+9LBJz15wkNZvw5JqfX3z0FS9n3bgUtz3L5rAsfh4YlA==} engines: {node: '>=20.0.0'} @@ -1007,14 +907,6 @@ packages: resolution: {integrity: sha512-iitV/gZKQMvY9d7ovmyFnFuTHbBAtrmLnvaSb/3X8vOKyevwtpmEtyc8AdhVWZe0pI/1GsHxlEvQeOePFzy7KQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/xml-builder@3.972.8': - resolution: {integrity: sha512-Ql8elcUdYCha83Ol7NznBsgN5GVZnv3vUd86fEc6waU6oUdY0T1O9NODkEEOS/Uaogr87avDrUC6DSeM4oXjZg==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} - engines: {node: '>=18.0.0'} - '@aws/lambda-invoke-store@0.2.4': resolution: {integrity: sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==} engines: {node: '>=18.0.0'} @@ -2966,10 +2858,6 @@ packages: resolution: {integrity: sha512-va7zYIt3QHG1x9M/jqXXRPFMoOVlVSSRHC5YH+DzKYsrz5xUKOA3lR4THsu/Zxha9N1jOndbKFKLtr0WOPW1Vw==} engines: {node: '>= 18', npm: '>= 8.6.0'} - '@smithy/abort-controller@4.2.10': - resolution: {integrity: sha512-qocxM/X4XGATqQtUkbE9SPUB6wekBi+FyJOMbPj0AhvyvFGYEmOlz6VB22iMePCQsFmMIvFSeViDvA7mZJG47g==} - engines: {node: '>=18.0.0'} - '@smithy/abort-controller@4.2.12': resolution: {integrity: sha512-xolrFw6b+2iYGl6EcOL7IJY71vvyZ0DJ3mcKtpykqPe2uscwtzDZJa1uVQXyP7w9Dd+kGwYnPbMsJrGISKiY/Q==} engines: {node: '>=18.0.0'} @@ -2986,10 +2874,6 @@ packages: resolution: {integrity: sha512-YxFiiG4YDAtX7WMN7RuhHZLeTmRRAOyCbr+zB8e3AQzHPnUhS8zXjB1+cniPVQI3xbWsQPM0X2aaIkO/ME0ymw==} engines: {node: '>=18.0.0'} - '@smithy/config-resolver@4.4.9': - resolution: {integrity: sha512-ejQvXqlcU30h7liR9fXtj7PIAau1t/sFbJpgWPfiYDs7zd16jpH0IsSXKcba2jF6ChTXvIjACs27kNMc5xxE2Q==} - engines: {node: '>=18.0.0'} - '@smithy/core@3.23.11': resolution: {integrity: sha512-952rGf7hBRnhUIaeLp6q4MptKW8sPFe5VvkoZ5qIzFAtx6c/QZ/54FS3yootsyUSf9gJX/NBqEBNdNR7jMIlpQ==} engines: {node: '>=18.0.0'} @@ -2998,22 +2882,10 @@ packages: resolution: {integrity: sha512-o9VycsYNtgC+Dy3I0yrwCqv9CWicDnke0L7EVOrZtJpjb2t0EjaEofmMrYc0T1Kn3yk32zm6cspxF9u9Bj7e5w==} engines: {node: '>=18.0.0'} - '@smithy/core@3.23.6': - resolution: {integrity: sha512-4xE+0L2NrsFKpEVFlFELkIHQddBvMbQ41LRIP74dGCXnY1zQ9DgksrBcRBDJT+iOzGy4VEJIeU3hkUK5mn06kg==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.2.10': - resolution: {integrity: sha512-3bsMLJJLTZGZqVGGeBVFfLzuRulVsGTj12BzRKODTHqUABpIr0jMN1vN3+u6r2OfyhAQ2pXaMZWX/swBK5I6PQ==} - engines: {node: '>=18.0.0'} - '@smithy/credential-provider-imds@4.2.12': resolution: {integrity: sha512-cr2lR792vNZcYMriSIj+Um3x9KWrjcu98kn234xA6reOAFMmbRpQMOv8KPgEmLLtx3eldU6c5wALKFqNOhugmg==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.10': - resolution: {integrity: sha512-A4ynrsFFfSXUHicfTcRehytppFBcY3HQxEGYiyGktPIOye3Ot7fxpiy4VR42WmtGI4Wfo6OXt/c1Ky1nUFxYYQ==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-codec@4.2.11': resolution: {integrity: sha512-Sf39Ml0iVX+ba/bgMPxaXWAAFmHqYLTmbjAPfLPLY8CrYkRDEqZdUsKC1OwVMCdJXfAt0v4j49GIJ8DoSYAe6w==} engines: {node: '>=18.0.0'} @@ -3022,10 +2894,6 @@ packages: resolution: {integrity: sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.10': - resolution: {integrity: sha512-0xupsu9yj9oDVuQ50YCTS9nuSYhGlrwqdaKQel9y2Fz7LU9fNErVlw9N0o4pm4qqvWEGbSTI4HKc6XJfB30MVw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-browser@4.2.11': resolution: {integrity: sha512-3rEpo3G6f/nRS7fQDsZmxw/ius6rnlIpz4UX6FlALEzz8JoSxFmdBt0SZnthis+km7sQo6q5/3e+UJcuQivoXA==} engines: {node: '>=18.0.0'} @@ -3034,10 +2902,6 @@ packages: resolution: {integrity: sha512-XUSuMxlTxV5pp4VpqZf6Sa3vT/Q75FVkLSpSSE3KkWBvAQWeuWt1msTv8fJfgA4/jcJhrbrbMzN1AC/hvPmm5A==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.10': - resolution: {integrity: sha512-8kn6sinrduk0yaYHMJDsNuiFpXwQwibR7n/4CDUqn4UgaG+SeBHu5jHGFdU9BLFAM7Q4/gvr9RYxBHz9/jKrhA==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-config-resolver@4.3.11': resolution: {integrity: sha512-XeNIA8tcP/GDWnnKkO7qEm/bg0B/bP9lvIXZBXcGZwZ+VYM8h8k9wuDvUODtdQ2Wcp2RcBkPTCSMmaniVHrMlA==} engines: {node: '>=18.0.0'} @@ -3046,10 +2910,6 @@ packages: resolution: {integrity: sha512-7epsAZ3QvfHkngz6RXQYseyZYHlmWXSTPOfPmXkiS+zA6TBNo1awUaMFL9vxyXlGdoELmCZyZe1nQE+imbmV+Q==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.10': - resolution: {integrity: sha512-uUrxPGgIffnYfvIOUmBM5i+USdEBRTdh7mLPttjphgtooxQ8CtdO1p6K5+Q4BBAZvKlvtJ9jWyrWpBJYzBKsyQ==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-node@4.2.11': resolution: {integrity: sha512-fzbCh18rscBDTQSCrsp1fGcclLNF//nJyhjldsEl/5wCYmgpHblv5JSppQAyQI24lClsFT0wV06N1Porn0IsEw==} engines: {node: '>=18.0.0'} @@ -3058,10 +2918,6 @@ packages: resolution: {integrity: sha512-D1pFuExo31854eAvg89KMn9Oab/wEeJR6Buy32B49A9Ogdtx5fwZPqBHUlDzaCDpycTFk2+fSQgX689Qsk7UGA==} engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.10': - resolution: {integrity: sha512-aArqzOEvcs2dK+xQVCgLbpJQGfZihw8SD4ymhkwNTtwKbnrzdhJsFDKuMQnam2kF69WzgJYOU5eJlCx+CA32bw==} - engines: {node: '>=18.0.0'} - '@smithy/eventstream-serde-universal@4.2.11': resolution: {integrity: sha512-MJ7HcI+jEkqoWT5vp+uoVaAjBrmxBtKhZTeynDRG/seEjJfqyg3SiqMMqyPnAMzmIfLaeJ/uiuSDP/l9AnMy/Q==} engines: {node: '>=18.0.0'} @@ -3070,10 +2926,6 @@ packages: resolution: {integrity: sha512-+yNuTiyBACxOJUTvbsNsSOfH9G9oKbaJE1lNL3YHpGcuucl6rPZMi3nrpehpVOVR2E07YqFFmtwpImtpzlouHQ==} engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.11': - resolution: {integrity: sha512-wbTRjOxdFuyEg0CpumjZO0hkUl+fetJFqxNROepuLIoijQh51aMBmzFLfoQdwRjxsuuS2jizzIUTjPWgd8pd7g==} - engines: {node: '>=18.0.0'} - '@smithy/fetch-http-handler@5.3.15': resolution: {integrity: sha512-T4jFU5N/yiIfrtrsb9uOQn7RdELdM/7HbyLNr6uO/mpkj1ctiVs7CihVr51w4LyQlXWDpXFn4BElf1WmQvZu/A==} engines: {node: '>=18.0.0'} @@ -3082,10 +2934,6 @@ packages: resolution: {integrity: sha512-DrcAx3PM6AEbWZxsKl6CWAGnVwiz28Wp1ZhNu+Hi4uI/6C1PIZBIaPM2VoqBDAsOWbM6ZVzOEQMxFLLdmb4eBQ==} engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.10': - resolution: {integrity: sha512-1VzIOI5CcsvMDvP3iv1vG/RfLJVVVc67dCRyLSB2Hn9SWCZrDO3zvcIzj3BfEtqRW5kcMg5KAeVf1K3dR6nD3w==} - engines: {node: '>=18.0.0'} - '@smithy/hash-node@4.2.12': resolution: {integrity: sha512-QhBYbGrbxTkZ43QoTPrK72DoYviDeg6YKDrHTMJbbC+A0sml3kSjzFtXP7BtbyJnXojLfTQldGdUR0RGD8dA3w==} engines: {node: '>=18.0.0'} @@ -3094,10 +2942,6 @@ packages: resolution: {integrity: sha512-w78xsYrOlwXKwN5tv1GnKIRbHb1HygSpeZMP6xDxCPGf1U/xDHjCpJu64c5T35UKyEPwa0bPeIcvU69VY3khUA==} engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.10': - resolution: {integrity: sha512-vy9KPNSFUU0ajFYk0sDZIYiUlAWGEAhRfehIr5ZkdFrRFTAuXEPUd41USuqHU6vvLX4r6Q9X7MKBco5+Il0Org==} - engines: {node: '>=18.0.0'} - '@smithy/invalid-dependency@4.2.12': resolution: {integrity: sha512-/4F1zb7Z8LOu1PalTdESFHR0RbPwHd3FcaG1sI3UEIriQTWakysgJr65lc1jj6QY5ye7aFsisajotH6UhWfm/g==} engines: {node: '>=18.0.0'} @@ -3106,10 +2950,6 @@ packages: resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} engines: {node: '>=14.0.0'} - '@smithy/is-array-buffer@4.2.1': - resolution: {integrity: sha512-Yfu664Qbf1B4IYIsYgKoABt010daZjkaCRvdU/sPnZG6TtHOB0md0RjNdLGzxe5UIdn9js4ftPICzmkRa9RJ4Q==} - engines: {node: '>=18.0.0'} - '@smithy/is-array-buffer@4.2.2': resolution: {integrity: sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==} engines: {node: '>=18.0.0'} @@ -3118,18 +2958,10 @@ packages: resolution: {integrity: sha512-Op+Dh6dPLWTjWITChFayDllIaCXRofOed8ecpggTC5fkh8yXes0vAEX7gRUfjGK+TlyxoCAA05gHbZW/zB9JwQ==} engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.10': - resolution: {integrity: sha512-TQZ9kX5c6XbjhaEBpvhSvMEZ0klBs1CFtOdPFwATZSbC9UeQfKHPLPN9Y+I6wZGMOavlYTOlHEPDrt42PMSH9w==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-content-length@4.2.12': resolution: {integrity: sha512-YE58Yz+cvFInWI/wOTrB+DbvUVz/pLn5mC5MvOV4fdRUc6qGwygyngcucRQjAhiCEbmfLOXX0gntSIcgMvAjmA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.20': - resolution: {integrity: sha512-9W6Np4ceBP3XCYAGLoMCmn8t2RRVzuD1ndWPLBbv7H9CrwM9Bprf6Up6BM9ZA/3alodg0b7Kf6ftBK9R1N04vw==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-endpoint@4.4.25': resolution: {integrity: sha512-dqjLwZs2eBxIUG6Qtw8/YZ4DvzHGIf0DA18wrgtfP6a50UIO7e2nY0FPdcbv5tVJKqWCCU5BmGMOUwT7Puan+A==} engines: {node: '>=18.0.0'} @@ -3138,10 +2970,6 @@ packages: resolution: {integrity: sha512-8Qfikvd2GVKSm8S6IbjfwFlRY9VlMrj0Dp4vTwAuhqbX7NhJKE5DQc2bnfJIcY0B+2YKMDBWfvexbSZeejDgeg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.37': - resolution: {integrity: sha512-/1psZZllBBSQ7+qo5+hhLz7AEPGLx3Z0+e3ramMBEuPK2PfvLK4SrncDB9VegX5mBn+oP/UTDrM6IHrFjvX1ZA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-retry@4.4.42': resolution: {integrity: sha512-vbwyqHRIpIZutNXZpLAozakzamcINaRCpEy1MYmK6xBeW3xN+TyPRA123GjXnuxZIjc9848MRRCugVMTXxC4Eg==} engines: {node: '>=18.0.0'} @@ -3150,10 +2978,6 @@ packages: resolution: {integrity: sha512-ZwsifBdyuNHrFGmbc7bAfP2b54+kt9J2rhFd18ilQGAB+GDiP4SrawqyExbB7v455QVR7Psyhb2kjULvBPIhvA==} engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.11': - resolution: {integrity: sha512-STQdONGPwbbC7cusL60s7vOa6He6A9w2jWhoapL0mgVjmR19pr26slV+yoSP76SIssMTX/95e5nOZ6UQv6jolg==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-serde@4.2.14': resolution: {integrity: sha512-+CcaLoLa5apzSRtloOyG7lQvkUw2ZDml3hRh4QiG9WyEPfW5Ke/3tPOPiPjUneuT59Tpn8+c3RVaUvvkkwqZwg==} engines: {node: '>=18.0.0'} @@ -3162,26 +2986,14 @@ packages: resolution: {integrity: sha512-ExYhcltZSli0pgAKOpQQe1DLFBLryeZ22605y/YS+mQpdNWekum9Ujb/jMKfJKgjtz1AZldtwA/wCYuKJgjjlg==} engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.10': - resolution: {integrity: sha512-pmts/WovNcE/tlyHa8z/groPeOtqtEpp61q3W0nW1nDJuMq/x+hWa/OVQBtgU0tBqupeXq0VBOLA4UZwE8I0YA==} - engines: {node: '>=18.0.0'} - '@smithy/middleware-stack@4.2.12': resolution: {integrity: sha512-kruC5gRHwsCOuyCd4ouQxYjgRAym2uDlCvQ5acuMtRrcdfg7mFBg6blaxcJ09STpt3ziEkis6bhg1uwrWU7txw==} engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.10': - resolution: {integrity: sha512-UALRbJtVX34AdP2VECKVlnNgidLHA2A7YgcJzwSBg1hzmnO/bZBHl/LDQQyYifzUwp1UOODnl9JJ3KNawpUJ9w==} - engines: {node: '>=18.0.0'} - '@smithy/node-config-provider@4.3.12': resolution: {integrity: sha512-tr2oKX2xMcO+rBOjobSwVAkV05SIfUKz8iI53rzxEmgW3GOOPOv0UioSDk+J8OpRQnpnhsO3Af6IEBabQBVmiw==} engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.12': - resolution: {integrity: sha512-zo1+WKJkR9x7ZtMeMDAAsq2PufwiLDmkhcjpWPRRkmeIuOm6nq1qjFICSZbnjBvD09ei8KMo26BWxsu2BUU+5w==} - engines: {node: '>=18.0.0'} - '@smithy/node-http-handler@4.4.16': resolution: {integrity: sha512-ULC8UCS/HivdCB3jhi+kLFYe4B5gxH2gi9vHBfEIiRrT2jfKiZNiETJSlzRtE6B26XbBHjPtc8iZKSNqMol9bw==} engines: {node: '>=18.0.0'} @@ -3190,66 +3002,34 @@ packages: resolution: {integrity: sha512-Rnq9vQWiR1+/I6NZZMNzJHV6pZYyEHt2ZnuV3MG8z2NNenC4i/8Kzttz7CjZiHSmsN5frhXhg17z3Zqjjhmz1A==} engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.10': - resolution: {integrity: sha512-5jm60P0CU7tom0eNrZ7YrkgBaoLFXzmqB0wVS+4uK8PPGmosSrLNf6rRd50UBvukztawZ7zyA8TxlrKpF5z9jw==} - engines: {node: '>=18.0.0'} - '@smithy/property-provider@4.2.12': resolution: {integrity: sha512-jqve46eYU1v7pZ5BM+fmkbq3DerkSluPr5EhvOcHxygxzD05ByDRppRwRPPpFrsFo5yDtCYLKu+kreHKVrvc7A==} engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.10': - resolution: {integrity: sha512-2NzVWpYY0tRdfeCJLsgrR89KE3NTWT2wGulhNUxYlRmtRmPwLQwKzhrfVaiNlA9ZpJvbW7cjTVChYKgnkqXj1A==} - engines: {node: '>=18.0.0'} - '@smithy/protocol-http@5.3.12': resolution: {integrity: sha512-fit0GZK9I1xoRlR4jXmbLhoN0OdEpa96ul8M65XdmXnxXkuMxM0Y8HDT0Fh0Xb4I85MBvBClOzgSrV1X2s1Hxw==} engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.10': - resolution: {integrity: sha512-HeN7kEvuzO2DmAzLukE9UryiUvejD3tMp9a1D1NJETerIfKobBUCLfviP6QEk500166eD2IATaXM59qgUI+YDA==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-builder@4.2.12': resolution: {integrity: sha512-6wTZjGABQufekycfDGMEB84BgtdOE/rCVTov+EDXQ8NHKTUNIp/j27IliwP7tjIU9LR+sSzyGBOXjeEtVgzCHg==} engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.10': - resolution: {integrity: sha512-4Mh18J26+ao1oX5wXJfWlTT+Q1OpDR8ssiC9PDOuEgVBGloqg18Fw7h5Ct8DyT9NBYwJgtJ2nLjKKFU6RP1G1Q==} - engines: {node: '>=18.0.0'} - '@smithy/querystring-parser@4.2.12': resolution: {integrity: sha512-P2OdvrgiAKpkPNKlKUtWbNZKB1XjPxM086NeVhK+W+wI46pIKdWBe5QyXvhUm3MEcyS/rkLvY8rZzyUdmyDZBw==} engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.10': - resolution: {integrity: sha512-0R/+/Il5y8nB/By90o8hy/bWVYptbIfvoTYad0igYQO5RefhNCDmNzqxaMx7K1t/QWo0d6UynqpqN5cCQt1MCg==} - engines: {node: '>=18.0.0'} - '@smithy/service-error-classification@4.2.12': resolution: {integrity: sha512-LlP29oSQN0Tw0b6D0Xo6BIikBswuIiGYbRACy5ujw/JgWSzTdYj46U83ssf6Ux0GyNJVivs2uReU8pt7Eu9okQ==} engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.5': - resolution: {integrity: sha512-pHgASxl50rrtOztgQCPmOXFjRW+mCd7ALr/3uXNzRrRoGV5G2+78GOsQ3HlQuBVHCh9o6xqMNvlIKZjWn4Euug==} - engines: {node: '>=18.0.0'} - '@smithy/shared-ini-file-loader@4.4.7': resolution: {integrity: sha512-HrOKWsUb+otTeo1HxVWeEb99t5ER1XrBi/xka2Wv6NVmTbuCUC1dvlrksdvxFtODLBjsC+PHK+fuy2x/7Ynyiw==} engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.10': - resolution: {integrity: sha512-Wab3wW8468WqTKIxI+aZe3JYO52/RYT/8sDOdzkUhjnLakLe9qoQqIcfih/qxcF4qWEFoWBszY0mj5uxffaVXA==} - engines: {node: '>=18.0.0'} - '@smithy/signature-v4@5.3.12': resolution: {integrity: sha512-B/FBwO3MVOL00DaRSXfXfa/TRXRheagt/q5A2NM13u7q+sHS59EOVGQNfG7DkmVtdQm5m3vOosoKAXSqn/OEgw==} engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.0': - resolution: {integrity: sha512-R8bQ9K3lCcXyZmBnQqUZJF4ChZmtWT5NLi6x5kgWx5D+/j0KorXcA0YcFg/X5TOgnTCy1tbKc6z2g2y4amFupQ==} - engines: {node: '>=18.0.0'} - '@smithy/smithy-client@4.12.5': resolution: {integrity: sha512-UqwYawyqSr/aog8mnLnfbPurS0gi4G7IYDcD28cUIBhsvWs1+rQcL2IwkUQ+QZ7dibaoRzhNF99fAQ9AUcO00w==} engines: {node: '>=18.0.0'} @@ -3258,42 +3038,22 @@ packages: resolution: {integrity: sha512-aib3f0jiMsJ6+cvDnXipBsGDL7ztknYSVqJs1FdN9P+u9tr/VzOR7iygSh6EUOdaBeMCMSh3N0VdyYsG4o91DQ==} engines: {node: '>=18.0.0'} - '@smithy/types@4.13.0': - resolution: {integrity: sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==} - engines: {node: '>=18.0.0'} - '@smithy/types@4.13.1': resolution: {integrity: sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.10': - resolution: {integrity: sha512-uypjF7fCDsRk26u3qHmFI/ePL7bxxB9vKkE+2WKEciHhz+4QtbzWiHRVNRJwU3cKhrYDYQE3b0MRFtqfLYdA4A==} - engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.12': resolution: {integrity: sha512-wOPKPEpso+doCZGIlr+e1lVI6+9VAKfL4kZWFgzVgGWY2hZxshNKod4l2LXS3PRC9otH/JRSjtEHqQ/7eLciRA==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.1': - resolution: {integrity: sha512-BKGuawX4Doq/bI/uEmg+Zyc36rJKWuin3py89PquXBIBqmbnJwBBsmKhdHfNEp0+A4TDgLmT/3MSKZ1SxHcR6w==} - engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.2': resolution: {integrity: sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.1': - resolution: {integrity: sha512-SiJeLiozrAoCrgDBUgsVbmqHmMgg/2bA15AzcbcW+zan7SuyAVHN4xTSbq0GlebAIwlcaX32xacnrG488/J/6g==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.2': resolution: {integrity: sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.2': - resolution: {integrity: sha512-4rHqBvxtJEBvsZcFQSPQqXP2b/yy/YlB66KlcEgcH2WNoOKCKB03DSLzXmOsXjbl8dJ4OEYTn31knhdznwk7zw==} - engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.3': resolution: {integrity: sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g==} engines: {node: '>=18.0.0'} @@ -3302,26 +3062,14 @@ packages: resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} engines: {node: '>=14.0.0'} - '@smithy/util-buffer-from@4.2.1': - resolution: {integrity: sha512-/swhmt1qTiVkaejlmMPPDgZhEaWb/HWMGRBheaxwuVkusp/z+ErJyQxO6kaXumOciZSWlmq6Z5mNylCd33X7Ig==} - engines: {node: '>=18.0.0'} - '@smithy/util-buffer-from@4.2.2': resolution: {integrity: sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==} engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.1': - resolution: {integrity: sha512-462id/00U8JWFw6qBuTSWfN5TxOHvDu4WliI97qOIOnuC/g+NDAknTU8eoGXEPlLkRVgWEr03jJBLV4o2FL8+A==} - engines: {node: '>=18.0.0'} - '@smithy/util-config-provider@4.2.2': resolution: {integrity: sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.36': - resolution: {integrity: sha512-R0smq7EHQXRVMxkAxtH5akJ/FvgAmNF6bUy/GwY/N20T4GrwjT633NFm0VuRpC+8Bbv8R9A0DoJ9OiZL/M3xew==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-browser@4.3.41': resolution: {integrity: sha512-M1w1Ux0rSVvBOxIIiqbxvZvhnjQ+VUjJrugtORE90BbadSTH+jsQL279KRL3Hv0w69rE7EuYkV/4Lepz/NBW9g==} engines: {node: '>=18.0.0'} @@ -3330,10 +3078,6 @@ packages: resolution: {integrity: sha512-0vjwmcvkWAUtikXnWIUOyV6IFHTEeQUYh3JUZcDgcszF+hD/StAsQ3rCZNZEPHgI9kVNcbnyc8P2CBHnwgmcwg==} engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.39': - resolution: {integrity: sha512-otWuoDm35btJV1L8MyHrPl462B07QCdMTktKc7/yM+Psv6KbED/ziXiHnmr7yPHUjfIwE9S8Max0LO24Mo3ZVg==} - engines: {node: '>=18.0.0'} - '@smithy/util-defaults-mode-node@4.2.44': resolution: {integrity: sha512-YPze3/lD1KmWuZsl9JlfhcgGLX7AXhSoaCDtiPntUjNW5/YY0lOHjkcgxyE9x/h5vvS1fzDifMGjzqnNlNiqOQ==} engines: {node: '>=18.0.0'} @@ -3342,42 +3086,22 @@ packages: resolution: {integrity: sha512-q5dOqqfTgUcLe38TAGiFn9srToKj2YCHJ34QGOLzM+xYLLA+qRZv7N+33kl1MERVusue36ZHnlNaNEvY/PzSrw==} engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.3.1': - resolution: {integrity: sha512-xyctc4klmjmieQiF9I1wssBWleRV0RhJ2DpO8+8yzi2LO1Z+4IWOZNGZGNj4+hq9kdo+nyfrRLmQTzc16Op2Vg==} - engines: {node: '>=18.0.0'} - '@smithy/util-endpoints@3.3.3': resolution: {integrity: sha512-VACQVe50j0HZPjpwWcjyT51KUQ4AnsvEaQ2lKHOSL4mNLD0G9BjEniQ+yCt1qqfKfiAHRAts26ud7hBjamrwig==} engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.1': - resolution: {integrity: sha512-c1hHtkgAWmE35/50gmdKajgGAKV3ePJ7t6UtEmpfCWJmQE9BQAQPz0URUVI89eSkcDqCtzqllxzG28IQoZPvwA==} - engines: {node: '>=18.0.0'} - '@smithy/util-hex-encoding@4.2.2': resolution: {integrity: sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg==} engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.10': - resolution: {integrity: sha512-LxaQIWLp4y0r72eA8mwPNQ9va4h5KeLM0I3M/HV9klmFaY2kN766wf5vsTzmaOpNNb7GgXAd9a25P3h8T49PSA==} - engines: {node: '>=18.0.0'} - '@smithy/util-middleware@4.2.12': resolution: {integrity: sha512-Er805uFUOvgc0l8nv0e0su0VFISoxhJ/AwOn3gL2NWNY2LUEldP5WtVcRYSQBcjg0y9NfG8JYrCJaYDpupBHJQ==} engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.10': - resolution: {integrity: sha512-HrBzistfpyE5uqTwiyLsFHscgnwB0kgv8vySp7q5kZ0Eltn/tjosaSGGDj/jJ9ys7pWzIP/icE2d+7vMKXLv7A==} - engines: {node: '>=18.0.0'} - '@smithy/util-retry@4.2.12': resolution: {integrity: sha512-1zopLDUEOwumjcHdJ1mwBHddubYF8GMQvstVCLC54Y46rqoHwlIU+8ZzUeaBcD+WCJHyDGSeZ2ml9YSe9aqcoQ==} engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.15': - resolution: {integrity: sha512-OlOKnaqnkU9X+6wEkd7mN+WB7orPbCVDauXOj22Q7VtiTkvy7ZdSsOg4QiNAZMgI4OkvNf+/VLUC3VXkxuWJZw==} - engines: {node: '>=18.0.0'} - '@smithy/util-stream@4.5.19': resolution: {integrity: sha512-v4sa+3xTweL1CLO2UP0p7tvIMH/Rq1X4KKOxd568mpe6LSLMQCnDHs4uv7m3ukpl3HvcN2JH6jiCS0SNRXKP/w==} engines: {node: '>=18.0.0'} @@ -3386,10 +3110,6 @@ packages: resolution: {integrity: sha512-4yXLm5n/B5SRBR2p8cZ90Sbv4zL4NKsgxdzCzp/83cXw2KxLEumt5p+GAVyRNZgQOSrzXn9ARpO0lUe8XSlSDw==} engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.1': - resolution: {integrity: sha512-YmiUDn2eo2IOiWYYvGQkgX5ZkBSiTQu4FlDo5jNPpAxng2t6Sjb6WutnZV9l6VR4eJul1ABmCrnWBC9hKHQa6Q==} - engines: {node: '>=18.0.0'} - '@smithy/util-uri-escape@4.2.2': resolution: {integrity: sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw==} engines: {node: '>=18.0.0'} @@ -3398,10 +3118,6 @@ packages: resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} engines: {node: '>=14.0.0'} - '@smithy/util-utf8@4.2.1': - resolution: {integrity: sha512-DSIwNaWtmzrNQHv8g7DBGR9mulSit65KSj5ymGEIAknmIN8IpbZefEep10LaMG/P/xquwbmJ1h9ectz8z6mV6g==} - engines: {node: '>=18.0.0'} - '@smithy/util-utf8@4.2.2': resolution: {integrity: sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==} engines: {node: '>=18.0.0'} @@ -3410,10 +3126,6 @@ packages: resolution: {integrity: sha512-4eTWph/Lkg1wZEDAyObwme0kmhEb7J/JjibY2znJdrYRgKbKqB7YoEhhJVJ4R1g/SYih4zuwX7LpJaM8RsnTVg==} engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.1': - resolution: {integrity: sha512-dSfDCeihDmZlV2oyr0yWPTUfh07suS+R5OB+FZGiv/hHyK3hrFBW5rR1UYjfa57vBsrP9lciFkRPzebaV1Qujw==} - engines: {node: '>=18.0.0'} - '@smithy/uuid@1.1.2': resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} @@ -3523,8 +3235,8 @@ packages: resolution: {integrity: sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==} engines: {node: '>=12.17.0'} - '@tloncorp/api@git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': - resolution: {commit: 7eede1c1a756977b09f96aa14a92e2b06318ae87, repo: git@github.com:tloncorp/api-beta.git, type: git} + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': + resolution: {tarball: https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87} version: 0.0.2 '@tloncorp/tlon-skill-darwin-arm64@0.2.2': @@ -3834,8 +3546,8 @@ packages: link-preview-js: optional: true - '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': - resolution: {commit: 1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67, repo: git@github.com:whiskeysockets/libsignal-node.git, type: git} + '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + resolution: {tarball: https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67} version: 2.0.1 abbrev@1.1.1: @@ -7027,21 +6739,21 @@ snapshots: '@aws-crypto/crc32@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.6 tslib: 2.8.1 '@aws-crypto/crc32c@5.2.0': dependencies: '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.6 tslib: 2.8.1 '@aws-crypto/sha1-browser@5.2.0': dependencies: '@aws-crypto/supports-web-crypto': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-locate-window': 3.965.4 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-locate-window': 3.965.5 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7067,7 +6779,7 @@ snapshots: '@aws-crypto/util@5.2.0': dependencies: - '@aws-sdk/types': 3.973.5 + '@aws-sdk/types': 3.973.6 '@smithy/util-utf8': 2.3.0 tslib: 2.8.1 @@ -7270,77 +6982,61 @@ snapshots: '@aws-crypto/sha1-browser': 5.2.0 '@aws-crypto/sha256-browser': 5.2.0 '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-node': 3.972.14 + '@aws-sdk/core': 3.973.20 + '@aws-sdk/credential-provider-node': 3.972.21 '@aws-sdk/middleware-bucket-endpoint': 3.972.6 '@aws-sdk/middleware-expect-continue': 3.972.6 '@aws-sdk/middleware-flexible-checksums': 3.973.1 - '@aws-sdk/middleware-host-header': 3.972.6 + '@aws-sdk/middleware-host-header': 3.972.8 '@aws-sdk/middleware-location-constraint': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 + '@aws-sdk/middleware-logger': 3.972.8 + '@aws-sdk/middleware-recursion-detection': 3.972.8 '@aws-sdk/middleware-sdk-s3': 3.972.15 '@aws-sdk/middleware-ssec': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/region-config-resolver': 3.972.6 + '@aws-sdk/middleware-user-agent': 3.972.21 + '@aws-sdk/region-config-resolver': 3.972.8 '@aws-sdk/signature-v4-multi-region': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/eventstream-serde-browser': 4.2.10 - '@smithy/eventstream-serde-config-resolver': 4.3.10 - '@smithy/eventstream-serde-node': 4.2.10 - '@smithy/fetch-http-handler': 5.3.11 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-endpoints': 3.996.5 + '@aws-sdk/util-user-agent-browser': 3.972.8 + '@aws-sdk/util-user-agent-node': 3.973.7 + '@smithy/config-resolver': 4.4.11 + '@smithy/core': 3.23.12 + '@smithy/eventstream-serde-browser': 4.2.12 + '@smithy/eventstream-serde-config-resolver': 4.3.12 + '@smithy/eventstream-serde-node': 4.2.12 + '@smithy/fetch-http-handler': 5.3.15 '@smithy/hash-blob-browser': 4.2.11 - '@smithy/hash-node': 4.2.10 + '@smithy/hash-node': 4.2.12 '@smithy/hash-stream-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 + '@smithy/invalid-dependency': 4.2.12 '@smithy/md5-js': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 + '@smithy/middleware-content-length': 4.2.12 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/middleware-retry': 4.4.43 + '@smithy/middleware-serde': 4.2.15 + '@smithy/middleware-stack': 4.2.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/node-http-handler': 4.5.0 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/url-parser': 4.2.12 + '@smithy/util-base64': 4.3.2 + '@smithy/util-body-length-browser': 4.2.2 + '@smithy/util-body-length-node': 4.2.3 + '@smithy/util-defaults-mode-browser': 4.3.42 + '@smithy/util-defaults-mode-node': 4.2.45 + '@smithy/util-endpoints': 3.3.3 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-retry': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 '@smithy/util-waiter': 4.2.10 tslib: 2.8.1 transitivePeerDependencies: - aws-crt - '@aws-sdk/core@3.973.15': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws-sdk/xml-builder': 3.972.8 - '@smithy/core': 3.23.6 - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@aws-sdk/core@3.973.20': dependencies: '@aws-sdk/types': 3.973.6 @@ -7359,15 +7055,7 @@ snapshots: '@aws-sdk/crc64-nvme@3.972.3': dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-env@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/credential-provider-env@3.972.18': @@ -7378,19 +7066,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.15': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/types': 3.973.4 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/node-http-handler': 4.4.12 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/util-stream': 4.5.15 - tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7404,25 +7079,6 @@ snapshots: '@smithy/util-stream': 4.5.20 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/credential-provider-env': 3.972.13 - '@aws-sdk/credential-provider-http': 3.972.15 - '@aws-sdk/credential-provider-login': 3.972.13 - '@aws-sdk/credential-provider-process': 3.972.13 - '@aws-sdk/credential-provider-sso': 3.972.13 - '@aws-sdk/credential-provider-web-identity': 3.972.13 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-ini@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7442,19 +7098,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-login@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-login@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7468,23 +7111,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-node@3.972.14': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.13 - '@aws-sdk/credential-provider-http': 3.972.15 - '@aws-sdk/credential-provider-ini': 3.972.13 - '@aws-sdk/credential-provider-process': 3.972.13 - '@aws-sdk/credential-provider-sso': 3.972.13 - '@aws-sdk/credential-provider-web-identity': 3.972.13 - '@aws-sdk/types': 3.973.4 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-node@3.972.21': dependencies: '@aws-sdk/credential-provider-env': 3.972.18 @@ -7502,15 +7128,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-process@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.18': dependencies: '@aws-sdk/core': 3.973.20 @@ -7520,19 +7137,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/token-providers': 3.999.0 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-sso@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7546,18 +7150,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.13': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-web-identity@3.972.20': dependencies: '@aws-sdk/core': 3.973.20 @@ -7586,12 +7178,12 @@ snapshots: '@aws-sdk/middleware-bucket-endpoint@3.972.6': dependencies: - '@aws-sdk/types': 3.973.4 + '@aws-sdk/types': 3.973.6 '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 tslib: 2.8.1 '@aws-sdk/middleware-eventstream@3.972.7': @@ -7610,9 +7202,9 @@ snapshots: '@aws-sdk/middleware-expect-continue@3.972.6': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/middleware-flexible-checksums@3.973.1': @@ -7620,23 +7212,16 @@ snapshots: '@aws-crypto/crc32': 5.2.0 '@aws-crypto/crc32c': 5.2.0 '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.15 + '@aws-sdk/core': 3.973.20 '@aws-sdk/crc64-nvme': 3.972.3 - '@aws-sdk/types': 3.973.4 - '@smithy/is-array-buffer': 4.2.1 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@smithy/is-array-buffer': 4.2.2 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/types': 4.13.1 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@aws-sdk/middleware-host-header@3.972.8': @@ -7648,14 +7233,8 @@ snapshots: '@aws-sdk/middleware-location-constraint@3.972.6': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/middleware-logger@3.972.8': @@ -7664,14 +7243,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/middleware-recursion-detection@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -7682,35 +7253,25 @@ snapshots: '@aws-sdk/middleware-sdk-s3@3.972.15': dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/types': 3.973.4 + '@aws-sdk/core': 3.973.20 + '@aws-sdk/types': 3.973.6 '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.23.6 - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 + '@smithy/core': 3.23.12 + '@smithy/node-config-provider': 4.3.12 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 + '@smithy/util-config-provider': 4.2.2 + '@smithy/util-middleware': 4.2.12 + '@smithy/util-stream': 4.5.20 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@aws-sdk/middleware-ssec@3.972.6': dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.972.15': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@smithy/core': 3.23.6 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/middleware-user-agent@3.972.21': @@ -7727,9 +7288,9 @@ snapshots: '@aws-sdk/middleware-websocket@3.972.12': dependencies: '@aws-sdk/types': 3.973.6 - '@aws-sdk/util-format-url': 3.972.7 + '@aws-sdk/util-format-url': 3.972.8 '@smithy/eventstream-codec': 4.2.11 - '@smithy/eventstream-serde-browser': 4.2.11 + '@smithy/eventstream-serde-browser': 4.2.12 '@smithy/fetch-http-handler': 5.3.15 '@smithy/protocol-http': 5.3.12 '@smithy/signature-v4': 5.3.12 @@ -7797,57 +7358,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/nested-clients@3.996.3': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.15 - '@aws-sdk/middleware-host-header': 3.972.6 - '@aws-sdk/middleware-logger': 3.972.6 - '@aws-sdk/middleware-recursion-detection': 3.972.6 - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/region-config-resolver': 3.972.6 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-endpoints': 3.996.3 - '@aws-sdk/util-user-agent-browser': 3.972.6 - '@aws-sdk/util-user-agent-node': 3.973.0 - '@smithy/config-resolver': 4.4.9 - '@smithy/core': 3.23.6 - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/hash-node': 4.2.10 - '@smithy/invalid-dependency': 4.2.10 - '@smithy/middleware-content-length': 4.2.10 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-retry': 4.4.37 - '@smithy/middleware-serde': 4.2.11 - '@smithy/middleware-stack': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/node-http-handler': 4.4.12 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-body-length-node': 4.2.2 - '@smithy/util-defaults-mode-browser': 4.3.36 - '@smithy/util-defaults-mode-node': 4.2.39 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/config-resolver': 4.4.9 - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/region-config-resolver@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -7859,21 +7369,21 @@ snapshots: '@aws-sdk/s3-request-presigner@3.1000.0': dependencies: '@aws-sdk/signature-v4-multi-region': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@aws-sdk/util-format-url': 3.972.6 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/protocol-http': 5.3.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@aws-sdk/util-format-url': 3.972.8 + '@smithy/middleware-endpoint': 4.4.26 + '@smithy/protocol-http': 5.3.12 + '@smithy/smithy-client': 4.12.6 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/signature-v4-multi-region@3.996.3': dependencies: '@aws-sdk/middleware-sdk-s3': 3.972.15 - '@aws-sdk/types': 3.973.4 - '@smithy/protocol-http': 5.3.10 - '@smithy/signature-v4': 5.3.10 - '@smithy/types': 4.13.0 + '@aws-sdk/types': 3.973.6 + '@smithy/protocol-http': 5.3.12 + '@smithy/signature-v4': 5.3.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@aws-sdk/token-providers@3.1004.0': @@ -7912,28 +7422,6 @@ snapshots: transitivePeerDependencies: - aws-crt - '@aws-sdk/token-providers@3.999.0': - dependencies: - '@aws-sdk/core': 3.973.15 - '@aws-sdk/nested-clients': 3.996.3 - '@aws-sdk/types': 3.973.4 - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.973.4': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@aws-sdk/types@3.973.5': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/types@3.973.6': dependencies: '@smithy/types': 4.13.1 @@ -7943,14 +7431,6 @@ snapshots: dependencies: tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.996.3': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-endpoints': 3.3.1 - tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.996.5': dependencies: '@aws-sdk/types': 3.973.6 @@ -7959,20 +7439,6 @@ snapshots: '@smithy/util-endpoints': 3.3.3 tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@aws-sdk/util-format-url@3.972.7': - dependencies: - '@aws-sdk/types': 3.973.6 - '@smithy/querystring-builder': 4.2.12 - '@smithy/types': 4.13.1 - tslib: 2.8.1 - '@aws-sdk/util-format-url@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -7980,21 +7446,10 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.4': - dependencies: - tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.5': dependencies: tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.6': - dependencies: - '@aws-sdk/types': 3.973.4 - '@smithy/types': 4.13.0 - bowser: 2.14.1 - tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.8': dependencies: '@aws-sdk/types': 3.973.6 @@ -8002,14 +7457,6 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.0': - dependencies: - '@aws-sdk/middleware-user-agent': 3.972.15 - '@aws-sdk/types': 3.973.4 - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.973.7': dependencies: '@aws-sdk/middleware-user-agent': 3.972.21 @@ -8025,14 +7472,6 @@ snapshots: fast-xml-parser: 5.5.6 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.8': - dependencies: - '@smithy/types': 4.13.0 - fast-xml-parser: 5.5.6 - tslib: 2.8.1 - - '@aws/lambda-invoke-store@0.2.3': {} - '@aws/lambda-invoke-store@0.2.4': {} '@azure/abort-controller@2.1.2': @@ -10136,11 +9575,6 @@ snapshots: transitivePeerDependencies: - debug - '@smithy/abort-controller@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/abort-controller@4.2.12': dependencies: '@smithy/types': 4.13.1 @@ -10148,7 +9582,7 @@ snapshots: '@smithy/chunked-blob-reader-native@4.2.2': dependencies: - '@smithy/util-base64': 4.3.1 + '@smithy/util-base64': 4.3.2 tslib: 2.8.1 '@smithy/chunked-blob-reader@5.2.1': @@ -10164,15 +9598,6 @@ snapshots: '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/config-resolver@4.4.9': - dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-config-provider': 4.2.1 - '@smithy/util-endpoints': 3.3.1 - '@smithy/util-middleware': 4.2.10 - tslib: 2.8.1 - '@smithy/core@3.23.11': dependencies: '@smithy/protocol-http': 5.3.12 @@ -10199,27 +9624,6 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/core@3.23.6': - dependencies: - '@smithy/middleware-serde': 4.2.11 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-body-length-browser': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-stream': 4.5.15 - '@smithy/util-utf8': 4.2.1 - '@smithy/uuid': 1.1.1 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.10': - dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - tslib: 2.8.1 - '@smithy/credential-provider-imds@4.2.12': dependencies: '@smithy/node-config-provider': 4.3.12 @@ -10228,13 +9632,6 @@ snapshots: '@smithy/url-parser': 4.2.12 tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.10': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.13.0 - '@smithy/util-hex-encoding': 4.2.1 - tslib: 2.8.1 - '@smithy/eventstream-codec@4.2.11': dependencies: '@aws-crypto/crc32': 5.2.0 @@ -10249,12 +9646,6 @@ snapshots: '@smithy/util-hex-encoding': 4.2.2 tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.10': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-browser@4.2.11': dependencies: '@smithy/eventstream-serde-universal': 4.2.11 @@ -10267,11 +9658,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-config-resolver@4.3.11': dependencies: '@smithy/types': 4.13.1 @@ -10282,12 +9668,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.10': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-node@4.2.11': dependencies: '@smithy/eventstream-serde-universal': 4.2.11 @@ -10300,12 +9680,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.10': - dependencies: - '@smithy/eventstream-codec': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/eventstream-serde-universal@4.2.11': dependencies: '@smithy/eventstream-codec': 4.2.11 @@ -10318,14 +9692,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.11': - dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - tslib: 2.8.1 - '@smithy/fetch-http-handler@5.3.15': dependencies: '@smithy/protocol-http': 5.3.12 @@ -10338,14 +9704,7 @@ snapshots: dependencies: '@smithy/chunked-blob-reader': 5.2.1 '@smithy/chunked-blob-reader-native': 4.2.2 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-utf8': 4.2.1 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@smithy/hash-node@4.2.12': @@ -10357,13 +9716,8 @@ snapshots: '@smithy/hash-stream-node@4.2.10': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.2.10': - dependencies: - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@smithy/invalid-dependency@4.2.12': @@ -10375,24 +9729,14 @@ snapshots: dependencies: tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/is-array-buffer@4.2.2': dependencies: tslib: 2.8.1 '@smithy/md5-js@4.2.10': dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.2.10': - dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 + '@smithy/types': 4.13.1 + '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 '@smithy/middleware-content-length@4.2.12': @@ -10401,17 +9745,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.20': - dependencies: - '@smithy/core': 3.23.6 - '@smithy/middleware-serde': 4.2.11 - '@smithy/node-config-provider': 4.3.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - '@smithy/url-parser': 4.2.10 - '@smithy/util-middleware': 4.2.10 - tslib: 2.8.1 - '@smithy/middleware-endpoint@4.4.25': dependencies: '@smithy/core': 3.23.11 @@ -10434,18 +9767,6 @@ snapshots: '@smithy/util-middleware': 4.2.12 tslib: 2.8.1 - '@smithy/middleware-retry@4.4.37': - dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/service-error-classification': 4.2.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-retry': 4.2.10 - '@smithy/uuid': 1.1.1 - tslib: 2.8.1 - '@smithy/middleware-retry@4.4.42': dependencies: '@smithy/node-config-provider': 4.3.12 @@ -10470,12 +9791,6 @@ snapshots: '@smithy/uuid': 1.1.2 tslib: 2.8.1 - '@smithy/middleware-serde@4.2.11': - dependencies: - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/middleware-serde@4.2.14': dependencies: '@smithy/core': 3.23.11 @@ -10490,23 +9805,11 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/middleware-stack@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/middleware-stack@4.2.12': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/node-config-provider@4.3.10': - dependencies: - '@smithy/property-provider': 4.2.10 - '@smithy/shared-ini-file-loader': 4.4.5 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/node-config-provider@4.3.12': dependencies: '@smithy/property-provider': 4.2.12 @@ -10514,14 +9817,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/node-http-handler@4.4.12': - dependencies: - '@smithy/abort-controller': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/querystring-builder': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/node-http-handler@4.4.16': dependencies: '@smithy/abort-controller': 4.2.12 @@ -10538,77 +9833,36 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/property-provider@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/property-provider@4.2.12': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/protocol-http@5.3.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/protocol-http@5.3.12': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/querystring-builder@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - '@smithy/util-uri-escape': 4.2.1 - tslib: 2.8.1 - '@smithy/querystring-builder@4.2.12': dependencies: '@smithy/types': 4.13.1 '@smithy/util-uri-escape': 4.2.2 tslib: 2.8.1 - '@smithy/querystring-parser@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/querystring-parser@4.2.12': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/service-error-classification@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - '@smithy/service-error-classification@4.2.12': dependencies: '@smithy/types': 4.13.1 - '@smithy/shared-ini-file-loader@4.4.5': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/shared-ini-file-loader@4.4.7': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/signature-v4@5.3.10': - dependencies: - '@smithy/is-array-buffer': 4.2.1 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-middleware': 4.2.10 - '@smithy/util-uri-escape': 4.2.1 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@smithy/signature-v4@5.3.12': dependencies: '@smithy/is-array-buffer': 4.2.2 @@ -10620,16 +9874,6 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/smithy-client@4.12.0': - dependencies: - '@smithy/core': 3.23.6 - '@smithy/middleware-endpoint': 4.4.20 - '@smithy/middleware-stack': 4.2.10 - '@smithy/protocol-http': 5.3.10 - '@smithy/types': 4.13.0 - '@smithy/util-stream': 4.5.15 - tslib: 2.8.1 - '@smithy/smithy-client@4.12.5': dependencies: '@smithy/core': 3.23.11 @@ -10650,50 +9894,26 @@ snapshots: '@smithy/util-stream': 4.5.20 tslib: 2.8.1 - '@smithy/types@4.13.0': - dependencies: - tslib: 2.8.1 - '@smithy/types@4.13.1': dependencies: tslib: 2.8.1 - '@smithy/url-parser@4.2.10': - dependencies: - '@smithy/querystring-parser': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/url-parser@4.2.12': dependencies: '@smithy/querystring-parser': 4.2.12 '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-base64@4.3.1': - dependencies: - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@smithy/util-base64@4.3.2': dependencies: '@smithy/util-buffer-from': 4.2.2 '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-browser@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.2': - dependencies: - tslib: 2.8.1 - '@smithy/util-body-length-node@4.2.3': dependencies: tslib: 2.8.1 @@ -10703,31 +9923,15 @@ snapshots: '@smithy/is-array-buffer': 2.2.0 tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.1': - dependencies: - '@smithy/is-array-buffer': 4.2.1 - tslib: 2.8.1 - '@smithy/util-buffer-from@4.2.2': dependencies: '@smithy/is-array-buffer': 4.2.2 tslib: 2.8.1 - '@smithy/util-config-provider@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-config-provider@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.36': - dependencies: - '@smithy/property-provider': 4.2.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-browser@4.3.41': dependencies: '@smithy/property-provider': 4.2.12 @@ -10742,16 +9946,6 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.39': - dependencies: - '@smithy/config-resolver': 4.4.9 - '@smithy/credential-provider-imds': 4.2.10 - '@smithy/node-config-provider': 4.3.10 - '@smithy/property-provider': 4.2.10 - '@smithy/smithy-client': 4.12.0 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/util-defaults-mode-node@4.2.44': dependencies: '@smithy/config-resolver': 4.4.11 @@ -10772,63 +9966,31 @@ snapshots: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-endpoints@3.3.1': - dependencies: - '@smithy/node-config-provider': 4.3.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/util-endpoints@3.3.3': dependencies: '@smithy/node-config-provider': 4.3.12 '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.2': dependencies: tslib: 2.8.1 - '@smithy/util-middleware@4.2.10': - dependencies: - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/util-middleware@4.2.12': dependencies: '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-retry@4.2.10': - dependencies: - '@smithy/service-error-classification': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - '@smithy/util-retry@4.2.12': dependencies: '@smithy/service-error-classification': 4.2.12 '@smithy/types': 4.13.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.15': - dependencies: - '@smithy/fetch-http-handler': 5.3.11 - '@smithy/node-http-handler': 4.4.12 - '@smithy/types': 4.13.0 - '@smithy/util-base64': 4.3.1 - '@smithy/util-buffer-from': 4.2.1 - '@smithy/util-hex-encoding': 4.2.1 - '@smithy/util-utf8': 4.2.1 - tslib: 2.8.1 - '@smithy/util-stream@4.5.19': dependencies: '@smithy/fetch-http-handler': 5.3.15 - '@smithy/node-http-handler': 4.4.16 + '@smithy/node-http-handler': 4.5.0 '@smithy/types': 4.13.1 '@smithy/util-base64': 4.3.2 '@smithy/util-buffer-from': 4.2.2 @@ -10847,10 +10009,6 @@ snapshots: '@smithy/util-utf8': 4.2.2 tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.1': - dependencies: - tslib: 2.8.1 - '@smithy/util-uri-escape@4.2.2': dependencies: tslib: 2.8.1 @@ -10860,11 +10018,6 @@ snapshots: '@smithy/util-buffer-from': 2.2.0 tslib: 2.8.1 - '@smithy/util-utf8@4.2.1': - dependencies: - '@smithy/util-buffer-from': 4.2.1 - tslib: 2.8.1 - '@smithy/util-utf8@4.2.2': dependencies: '@smithy/util-buffer-from': 4.2.2 @@ -10872,12 +10025,8 @@ snapshots: '@smithy/util-waiter@4.2.10': dependencies: - '@smithy/abort-controller': 4.2.10 - '@smithy/types': 4.13.0 - tslib: 2.8.1 - - '@smithy/uuid@1.1.1': - dependencies: + '@smithy/abort-controller': 4.2.12 + '@smithy/types': 4.13.1 tslib: 2.8.1 '@smithy/uuid@1.1.2': @@ -10961,7 +10110,7 @@ snapshots: '@tinyhttp/content-disposition@2.2.4': {} - '@tloncorp/api@git+https://git@github.com:tloncorp/api-beta.git#7eede1c1a756977b09f96aa14a92e2b06318ae87': + '@tloncorp/api@https://codeload.github.com/tloncorp/api-beta/tar.gz/7eede1c1a756977b09f96aa14a92e2b06318ae87': dependencies: '@aws-sdk/client-s3': 3.1000.0 '@aws-sdk/s3-request-presigner': 3.1000.0 @@ -11352,7 +10501,7 @@ snapshots: '@cacheable/node-cache': 1.7.6 '@hapi/boom': 9.1.4 async-mutex: 0.5.0 - libsignal: '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' + libsignal: '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67' lru-cache: 11.2.7 music-metadata: 11.12.3 p-queue: 9.1.0 @@ -11368,7 +10517,7 @@ snapshots: - supports-color - utf-8-validate - '@whiskeysockets/libsignal-node@git+https://git@github.com:whiskeysockets/libsignal-node.git#1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': + '@whiskeysockets/libsignal-node@https://codeload.github.com/whiskeysockets/libsignal-node/tar.gz/1c30d7d7e76a3b0aa120b04dc6a26f5a12dccf67': dependencies: curve25519-js: 0.0.4 protobufjs: 6.8.8 diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index d889433dae8..403f9523f1d 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -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", diff --git a/src/agents/acp-spawn.test.ts b/src/agents/acp-spawn.test.ts index d11b569602c..3b93bf0a826 100644 --- a/src/agents/acp-spawn.test.ts +++ b/src/agents/acp-spawn.test.ts @@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js"; import { __testing as sessionBindingServiceTesting, registerSessionBindingAdapter, + type SessionBindingPlacement, type SessionBindingRecord, } from "../infra/outbound/session-binding-service.js"; import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js"; @@ -104,7 +105,7 @@ function createSessionBindingCapabilities() { adapterAvailable: true, bindSupported: true, unbindSupported: true, - placements: ["current", "child"] as const, + placements: ["current", "child"] satisfies SessionBindingPlacement[], }; } @@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => { metaCleared: false, }); getAcpSessionManagerSpy.mockReset().mockReturnValue({ - initializeSession: async (params) => await hoisted.initializeSessionMock(params), - closeSession: async (params) => await hoisted.closeSessionMock(params), + initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params), + closeSession: async (params: unknown) => await hoisted.closeSessionMock(params), } as unknown as ReturnType); hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => { const args = argsUnknown as { @@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => { ...hoisted.state.cfg.channels, telegram: { threadBindings: { - spawnAcpSessions: true, + enabled: true, }, }, }, diff --git a/src/agents/pi-embedded-block-chunker.test.ts b/src/agents/pi-embedded-block-chunker.test.ts index c8b1f5dda55..0766dce9233 100644 --- a/src/agents/pi-embedded-block-chunker.test.ts +++ b/src/agents/pi-embedded-block-chunker.test.ts @@ -11,20 +11,12 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num }); } -function drainChunks(chunker: EmbeddedBlockChunker) { +function drainChunks(chunker: EmbeddedBlockChunker, force = false) { const chunks: string[] = []; - chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) }); + chunker.drain({ force, emit: (chunk) => chunks.push(chunk) }); return chunks; } -function expectFlushAtFirstParagraphBreak(text: string) { - const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 }); - chunker.append(text); - const chunks = drainChunks(chunker); - expect(chunks).toEqual(["First paragraph."]); - expect(chunker.bufferedText).toBe("Second paragraph."); -} - describe("EmbeddedBlockChunker", () => { it("breaks at paragraph boundary right after fence close", () => { const chunker = new EmbeddedBlockChunker({ @@ -54,12 +46,25 @@ describe("EmbeddedBlockChunker", () => { expect(chunker.bufferedText).toMatch(/^After/); }); - it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => { - expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph."); + it("waits until minChars before flushing paragraph boundaries when flushOnParagraph is set", () => { + const chunker = createFlushOnParagraphChunker({ minChars: 30, maxChars: 200 }); + + chunker.append("First paragraph.\n\nSecond paragraph.\n\nThird paragraph."); + + const chunks = drainChunks(chunker); + + expect(chunks).toEqual(["First paragraph.\n\nSecond paragraph."]); + expect(chunker.bufferedText).toBe("Third paragraph."); }); - it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => { - expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph."); + it("still force flushes buffered paragraphs below minChars at the end", () => { + const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 }); + + chunker.append("First paragraph.\n \nSecond paragraph."); + + expect(drainChunks(chunker)).toEqual([]); + expect(drainChunks(chunker, true)).toEqual(["First paragraph.\n \nSecond paragraph."]); + expect(chunker.bufferedText).toBe(""); }); it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => { @@ -97,7 +102,7 @@ describe("EmbeddedBlockChunker", () => { it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => { const chunker = new EmbeddedBlockChunker({ - minChars: 100, + minChars: 10, maxChars: 200, breakPreference: "paragraph", flushOnParagraph: true, diff --git a/src/agents/pi-embedded-block-chunker.ts b/src/agents/pi-embedded-block-chunker.ts index 11eddc2d190..6abe7b5a7da 100644 --- a/src/agents/pi-embedded-block-chunker.ts +++ b/src/agents/pi-embedded-block-chunker.ts @@ -5,7 +5,7 @@ export type BlockReplyChunking = { minChars: number; maxChars: number; breakPreference?: "paragraph" | "newline" | "sentence"; - /** When true, flush eagerly on \n\n paragraph boundaries regardless of minChars. */ + /** When true, prefer \n\n paragraph boundaries once minChars has been satisfied. */ flushOnParagraph?: boolean; }; @@ -129,7 +129,7 @@ export class EmbeddedBlockChunker { const minChars = Math.max(1, Math.floor(this.#chunking.minChars)); const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars)); - if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) { + if (this.#buffer.length < minChars && !force) { return; } @@ -150,12 +150,12 @@ export class EmbeddedBlockChunker { const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : ""; const remainingLength = reopenPrefix.length + (source.length - start); - if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) { + if (!force && remainingLength < minChars) { break; } if (this.#chunking.flushOnParagraph && !force) { - const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start); + const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars); const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length); if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) { const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`; @@ -175,12 +175,7 @@ export class EmbeddedBlockChunker { const breakResult = force && remainingLength <= maxChars ? this.#pickSoftBreakIndex(view, fenceSpans, 1, start) - : this.#pickBreakIndex( - view, - fenceSpans, - force || this.#chunking.flushOnParagraph ? 1 : undefined, - start, - ); + : this.#pickBreakIndex(view, fenceSpans, force ? 1 : undefined, start); if (breakResult.index <= 0) { if (force) { emit(`${reopenPrefix}${source.slice(start)}`); @@ -205,7 +200,7 @@ export class EmbeddedBlockChunker { const nextLength = (reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start); - if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) { + if (nextLength < minChars && !force) { break; } if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) { @@ -401,6 +396,7 @@ function findNextParagraphBreak( buffer: string, fenceSpans: FenceSpan[], startIndex = 0, + minCharsFromStart = 1, ): ParagraphBreak | null { if (startIndex < 0) { return null; @@ -413,6 +409,9 @@ function findNextParagraphBreak( if (index < 0) { continue; } + if (index - startIndex < minCharsFromStart) { + continue; + } if (!isSafeFenceBreak(fenceSpans, index)) { continue; } diff --git a/src/agents/pi-embedded-runner-extraparams.test.ts b/src/agents/pi-embedded-runner-extraparams.test.ts index 685976bf63d..b176de6fab5 100644 --- a/src/agents/pi-embedded-runner-extraparams.test.ts +++ b/src/agents/pi-embedded-runner-extraparams.test.ts @@ -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"); + }); }); diff --git a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts index 4131a33f08d..a4433f65b10 100644 --- a/src/agents/pi-embedded-runner/openai-stream-wrappers.ts +++ b/src/agents/pi-embedded-runner/openai-stream-wrappers.ts @@ -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; 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, forceStore, stripStore, + stripPromptCache, useServerCompaction, compactThreshold, }); diff --git a/src/agents/subagent-announce.format.e2e.test.ts b/src/agents/subagent-announce.format.e2e.test.ts index 7e83742b5ce..280172dc073 100644 --- a/src/agents/subagent-announce.format.e2e.test.ts +++ b/src/agents/subagent-announce.format.e2e.test.ts @@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn( const embeddedRunMock = { isEmbeddedPiRunActive: vi.fn(() => false), isEmbeddedPiRunStreaming: vi.fn(() => false), - queueEmbeddedPiMessage: vi.fn(() => false), - waitForEmbeddedPiRunEnd: vi.fn(async () => true), + queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false), + waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true), }; const { subagentRegistryMock } = vi.hoisted(() => ({ subagentRegistryMock: { @@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void { setRuntimeConfigSnapshot(configOverride); } -function loadSessionStoreFixture(): Record> { - return new Proxy(sessionStore, { +function loadSessionStoreFixture(): ReturnType { + return new Proxy(sessionStore as ReturnType, { get(target, key: string | symbol) { if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) { - return { inputTokens: 1, outputTokens: 1, totalTokens: 2 }; + return { + sessionId: key, + updatedAt: Date.now(), + inputTokens: 1, + outputTokens: 1, + totalTokens: 2, + }; } return target[key as keyof typeof target]; }, @@ -207,7 +213,11 @@ describe("subagent announce formatting", () => { resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main"); resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json"); resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main"); - getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock); + getGlobalHookRunnerSpy + .mockReset() + .mockImplementation( + () => hookRunnerMock as unknown as ReturnType, + ); readLatestAssistantReplySpy .mockReset() .mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey)); diff --git a/src/auto-reply/envelope.ts b/src/auto-reply/envelope.ts index 3a2985419dd..5eedb19dd0c 100644 --- a/src/auto-reply/envelope.ts +++ b/src/auto-reply/envelope.ts @@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" }; } -function formatTimestamp( +export function formatEnvelopeTimestamp( ts: number | Date | undefined, options?: EnvelopeFormatOptions, ): string | undefined { @@ -179,7 +179,7 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string { if (params.ip?.trim()) { parts.push(sanitizeEnvelopeHeaderPart(params.ip.trim())); } - const ts = formatTimestamp(params.timestamp, resolved); + const ts = formatEnvelopeTimestamp(params.timestamp, resolved); if (ts) { parts.push(ts); } diff --git a/src/auto-reply/reply/block-reply-coalescer.ts b/src/auto-reply/reply/block-reply-coalescer.ts index c7a6f85c26b..ada535ad7cc 100644 --- a/src/auto-reply/reply/block-reply-coalescer.ts +++ b/src/auto-reply/reply/block-reply-coalescer.ts @@ -89,8 +89,8 @@ export function createBlockReplyCoalescer(params: { return; } - // When flushOnEnqueue is set (chunkMode="newline"), each enqueued payload is treated - // as a separate paragraph and flushed immediately so delivery matches streaming boundaries. + // When flushOnEnqueue is set, treat each enqueued payload as its own outbound block + // and flush immediately instead of waiting for coalescing thresholds. if (flushOnEnqueue) { if (bufferText) { void flush({ force: true }); diff --git a/src/auto-reply/reply/block-streaming.test.ts b/src/auto-reply/reply/block-streaming.test.ts index 29264ca99b3..1850f1521c8 100644 --- a/src/auto-reply/reply/block-streaming.test.ts +++ b/src/auto-reply/reply/block-streaming.test.ts @@ -44,6 +44,34 @@ describe("resolveEffectiveBlockStreamingConfig", () => { expect(resolved.coalescing.idleMs).toBe(0); }); + it("honors newline chunkMode for plugin channels even before the plugin registry is loaded", () => { + const cfg = { + channels: { + bluebubbles: { + chunkMode: "newline", + }, + }, + agents: { + defaults: { + blockStreamingChunk: { + minChars: 1, + maxChars: 4000, + breakPreference: "paragraph", + }, + }, + }, + } as OpenClawConfig; + + const resolved = resolveEffectiveBlockStreamingConfig({ + cfg, + provider: "bluebubbles", + }); + + expect(resolved.chunking.flushOnParagraph).toBe(true); + expect(resolved.coalescing.flushOnEnqueue).toBeUndefined(); + expect(resolved.coalescing.joiner).toBe("\n\n"); + }); + it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => { const cfg = { channels: { diff --git a/src/auto-reply/reply/block-streaming.ts b/src/auto-reply/reply/block-streaming.ts index 9149f7c8562..df1582846ff 100644 --- a/src/auto-reply/reply/block-streaming.ts +++ b/src/auto-reply/reply/block-streaming.ts @@ -3,26 +3,22 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { BlockStreamingCoalesceConfig } from "../../config/types.js"; import { resolveAccountEntry } from "../../routing/account-lookup.js"; import { normalizeAccountId } from "../../routing/session-key.js"; -import { - INTERNAL_MESSAGE_CHANNEL, - listDeliverableMessageChannels, -} from "../../utils/message-channel.js"; +import { normalizeMessageChannel } from "../../utils/message-channel.js"; import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js"; const DEFAULT_BLOCK_STREAM_MIN = 800; const DEFAULT_BLOCK_STREAM_MAX = 1200; const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000; -const getBlockChunkProviders = () => - new Set([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]); function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined { if (!provider) { return undefined; } - const cleaned = provider.trim().toLowerCase(); - return getBlockChunkProviders().has(cleaned as TextChunkProvider) - ? (cleaned as TextChunkProvider) - : undefined; + const normalized = normalizeMessageChannel(provider); + if (!normalized) { + return undefined; + } + return normalized as TextChunkProvider; } function resolveProviderChunkContext( @@ -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", }; } diff --git a/src/auto-reply/reply/channel-context.ts b/src/auto-reply/reply/channel-context.ts index d8ffb261eb8..afe77e32805 100644 --- a/src/auto-reply/reply/channel-context.ts +++ b/src/auto-reply/reply/channel-context.ts @@ -24,6 +24,10 @@ export function isTelegramSurface(params: DiscordSurfaceParams): boolean { return resolveCommandSurfaceChannel(params) === "telegram"; } +export function isMatrixSurface(params: DiscordSurfaceParams): boolean { + return resolveCommandSurfaceChannel(params) === "matrix"; +} + export function resolveCommandSurfaceChannel(params: DiscordSurfaceParams): string { const channel = params.ctx.OriginatingChannel ?? diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index 5d732e4b4e6..ca8ece9b3cc 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -120,7 +120,7 @@ type FakeBinding = { targetSessionKey: string; targetKind: "subagent" | "session"; conversation: { - channel: "discord" | "telegram" | "feishu"; + channel: "discord" | "matrix" | "telegram" | "feishu"; accountId: string; conversationId: string; parentConversationId?: string; @@ -245,9 +245,10 @@ function createSessionBindingCapabilities() { type AcpBindInput = { targetSessionKey: string; conversation: { - channel?: "discord" | "telegram" | "feishu"; + channel?: "discord" | "matrix" | "telegram" | "feishu"; accountId: string; conversationId: string; + parentConversationId?: string; }; placement: "current" | "child"; metadata?: Record; @@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding { conversationId: nextConversationId, parentConversationId: "parent-1", } - : channel === "feishu" + : channel === "matrix" ? { - channel: "feishu" as const, + channel: "matrix" as const, accountId: input.conversation.accountId, conversationId: nextConversationId, + parentConversationId: + input.placement === "child" + ? input.conversation.conversationId + : input.conversation.parentConversationId, } - : { - channel: "telegram" as const, - accountId: input.conversation.accountId, - conversationId: nextConversationId, - }; + : channel === "feishu" + ? { + channel: "feishu" as const, + accountId: input.conversation.accountId, + conversationId: nextConversationId, + } + : { + channel: "telegram" as const, + accountId: input.conversation.accountId, + conversationId: nextConversationId, + }; return createSessionBinding({ targetSessionKey: input.targetSessionKey, conversation, @@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true); } +function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + +function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = createMatrixRoomParams(commandBody, cfg); + params.ctx.MessageThreadId = "$thread-root"; + return params; +} + +async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true); +} + +async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) { + return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true); +} + function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { const params = buildCommandTestParams(commandBody, cfg, { Provider: "feishu", @@ -598,6 +635,63 @@ describe("/acp command", () => { ); }); + it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixAcpCommand("/acp spawn codex", cfg); + + expect(result?.reply?.text).toContain("Created thread thread-created and bound it"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "default", + conversationId: "!room:example.org", + }), + }), + ); + }); + + it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnAcpSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg); + + expect(result?.reply?.text).toContain("Bound this thread to"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "current", + conversation: expect.objectContaining({ + channel: "matrix", + accountId: "default", + conversationId: "$thread-root", + parentConversationId: "!room:example.org", + }), + }), + ); + }); + it("binds Feishu DM ACP spawns to the current DM conversation", async () => { const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here"); @@ -654,6 +748,24 @@ describe("/acp command", () => { ); }); + it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await runMatrixAcpCommand("/acp spawn codex", cfg); + + expect(result?.reply?.text).toContain("spawnAcpSessions=true"); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + it("forbids /acp spawn from sandboxed requester sessions", async () => { const cfg = { ...baseCfg, diff --git a/src/auto-reply/reply/commands-acp/context.test.ts b/src/auto-reply/reply/commands-acp/context.test.ts index 5b1e60ad1fc..721ee325b48 100644 --- a/src/auto-reply/reply/commands-acp/context.test.ts +++ b/src/auto-reply/reply/commands-acp/context.test.ts @@ -141,6 +141,27 @@ describe("commands-acp context", () => { expect(resolveAcpCommandConversationId(params)).toBe("123456789"); }); + it("resolves Matrix thread context from the current room and thread root", () => { + const params = buildCommandTestParams("/acp status", baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "work", + MessageThreadId: "$thread-root", + }); + + expect(resolveAcpCommandBindingContext(params)).toEqual({ + channel: "matrix", + accountId: "work", + threadId: "$thread-root", + conversationId: "$thread-root", + parentConversationId: "!room:example.org", + }); + expect(resolveAcpCommandConversationId(params)).toBe("$thread-root"); + expect(resolveAcpCommandParentConversationId(params)).toBe("!room:example.org"); + }); + it("builds Feishu topic conversation ids from chat target + root message id", () => { const params = buildCommandTestParams("/acp status", baseCfg, { Provider: "feishu", diff --git a/src/auto-reply/reply/commands-acp/context.ts b/src/auto-reply/reply/commands-acp/context.ts index de3a615eb4b..7a326f4d564 100644 --- a/src/auto-reply/reply/commands-acp/context.ts +++ b/src/auto-reply/reply/commands-acp/context.ts @@ -9,6 +9,10 @@ import { getSessionBindingService } from "../../../infra/outbound/session-bindin import { parseAgentSessionKey } from "../../../routing/session-key.js"; import type { HandleCommandsParams } from "../commands-types.js"; import { parseDiscordParentChannelFromSessionKey } from "../discord-parent-channel.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { resolveTelegramConversationId } from "../telegram-context.js"; type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender"; @@ -161,6 +165,18 @@ export function resolveAcpCommandThreadId(params: HandleCommandsParams): string export function resolveAcpCommandConversationId(params: HandleCommandsParams): string | undefined { const channel = resolveAcpCommandChannel(params); + if (channel === "matrix") { + return resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } if (channel === "telegram") { const telegramConversationId = resolveTelegramConversationId({ ctx: { @@ -231,6 +247,18 @@ export function resolveAcpCommandParentConversationId( params: HandleCommandsParams, ): string | undefined { const channel = resolveAcpCommandChannel(params); + if (channel === "matrix") { + return resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } if (channel === "telegram") { return ( parseTelegramChatIdFromTarget(params.ctx.OriginatingTo) ?? diff --git a/src/auto-reply/reply/commands-acp/lifecycle.ts b/src/auto-reply/reply/commands-acp/lifecycle.ts index 42ee1d2e184..89615c9e74e 100644 --- a/src/auto-reply/reply/commands-acp/lifecycle.ts +++ b/src/auto-reply/reply/commands-acp/lifecycle.ts @@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: { } const senderId = commandParams.command.senderId?.trim() || ""; + const parentConversationId = bindingContext.parentConversationId?.trim() || undefined; + const conversationRef = { + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + conversationId: currentConversationId, + ...(parentConversationId && parentConversationId !== currentConversationId + ? { parentConversationId } + : {}), + }; if (placement === "current") { - const existingBinding = bindingService.resolveByConversation({ - channel: spawnPolicy.channel, - accountId: spawnPolicy.accountId, - conversationId: currentConversationId, - }); + const existingBinding = bindingService.resolveByConversation(conversationRef); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" ? existingBinding.metadata.boundBy.trim() @@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: { } const label = params.label || params.agentId; - const conversationId = currentConversationId; try { const binding = await bindingService.bind({ targetSessionKey: params.sessionKey, targetKind: "session", - conversation: { - channel: spawnPolicy.channel, - accountId: spawnPolicy.accountId, - conversationId, - }, + conversation: conversationRef, placement, metadata: { threadName: resolveThreadBindingThreadName({ diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 2b0571b332f..438fe963c11 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -2,7 +2,10 @@ import { randomUUID } from "node:crypto"; import { toAcpRuntimeErrorText } from "../../../acp/runtime/error-text.js"; import type { AcpRuntimeError } from "../../../acp/runtime/errors.js"; import type { AcpRuntimeSessionMode } from "../../../acp/runtime/types.js"; -import { DISCORD_THREAD_BINDING_CHANNEL } from "../../../channels/thread-bindings-policy.js"; +import { + DISCORD_THREAD_BINDING_CHANNEL, + MATRIX_THREAD_BINDING_CHANNEL, +} from "../../../channels/thread-bindings-policy.js"; import type { AcpSessionRuntimeOptions } from "../../../config/sessions/types.js"; import { normalizeAgentId } from "../../../routing/session-key.js"; import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js"; @@ -168,7 +171,8 @@ function normalizeAcpOptionToken(raw: string): string { } function resolveDefaultSpawnThreadMode(params: HandleCommandsParams): AcpSpawnThreadMode { - if (resolveAcpCommandChannel(params) !== DISCORD_THREAD_BINDING_CHANNEL) { + const channel = resolveAcpCommandChannel(params); + if (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL) { return "off"; } const currentThreadId = resolveAcpCommandThreadId(params); diff --git a/src/auto-reply/reply/commands-session-lifecycle.test.ts b/src/auto-reply/reply/commands-session-lifecycle.test.ts index bb56ef82bd9..8d31fbf8c0d 100644 --- a/src/auto-reply/reply/commands-session-lifecycle.test.ts +++ b/src/auto-reply/reply/commands-session-lifecycle.test.ts @@ -9,6 +9,8 @@ const hoisted = vi.hoisted(() => { const getThreadBindingManagerMock = vi.fn(); const setThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setThreadBindingMaxAgeBySessionKeyMock = vi.fn(); + const setMatrixThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); + const setMatrixThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const setTelegramThreadBindingIdleTimeoutBySessionKeyMock = vi.fn(); const setTelegramThreadBindingMaxAgeBySessionKeyMock = vi.fn(); const sessionBindingResolveByConversationMock = vi.fn(); @@ -16,6 +18,8 @@ const hoisted = vi.hoisted(() => { getThreadBindingManagerMock, setThreadBindingIdleTimeoutBySessionKeyMock, setThreadBindingMaxAgeBySessionKeyMock, + setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + setMatrixThreadBindingMaxAgeBySessionKeyMock, setTelegramThreadBindingIdleTimeoutBySessionKeyMock, setTelegramThreadBindingMaxAgeBySessionKeyMock, sessionBindingResolveByConversationMock, @@ -48,6 +52,12 @@ vi.mock("../../plugins/runtime/index.js", async () => { setMaxAgeBySessionKey: hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock, }, }, + matrix: { + threadBindings: { + setIdleTimeoutBySessionKey: hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + setMaxAgeBySessionKey: hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock, + }, + }, }, }), }; @@ -114,6 +124,29 @@ function createTelegramCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + MessageThreadId: "$thread-1", + ...overrides, + }); +} + +function createMatrixRoomCommandParams(commandBody: string, overrides?: Record) { + return buildCommandTestParams(commandBody, baseCfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + ...overrides, + }); +} + function createFakeBinding(overrides: Partial = {}): FakeBinding { const now = Date.now(); return { @@ -152,6 +185,29 @@ function createTelegramBinding(overrides?: Partial): Sessi }; } +function createMatrixBinding(overrides?: Partial): SessionBindingRecord { + return { + bindingId: "default:$thread-1", + targetSessionKey: "agent:main:subagent:child", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }, + status: "active", + boundAt: Date.now(), + metadata: { + boundBy: "user-1", + lastActivityAt: Date.now(), + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + }, + ...overrides, + }; +} + function expectIdleTimeoutSetReply( mock: ReturnType, text: string, @@ -183,6 +239,8 @@ describe("/session idle and /session max-age", () => { hoisted.getThreadBindingManagerMock.mockReset(); hoisted.setThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setThreadBindingMaxAgeBySessionKeyMock.mockReset(); + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); + hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReset(); hoisted.setTelegramThreadBindingIdleTimeoutBySessionKeyMock.mockReset(); hoisted.setTelegramThreadBindingMaxAgeBySessionKeyMock.mockReset(); hoisted.sessionBindingResolveByConversationMock.mockReset().mockReturnValue(null); @@ -286,6 +344,66 @@ describe("/session idle and /session max-age", () => { ); }); + it("sets idle timeout for focused Matrix threads", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + hoisted.sessionBindingResolveByConversationMock.mockReturnValue(createMatrixBinding()); + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock.mockReturnValue([ + { + targetSessionKey: "agent:main:subagent:child", + boundAt: Date.now(), + lastActivityAt: Date.now(), + idleTimeoutMs: 2 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand( + createMatrixThreadCommandParams("/session idle 2h"), + true, + ); + const text = result?.reply?.text ?? ""; + + expectIdleTimeoutSetReply( + hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock, + text, + 2 * 60 * 60 * 1000, + "2h", + ); + }); + + it("sets max age for focused Matrix threads", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); + + const boundAt = Date.parse("2026-02-19T22:00:00.000Z"); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createMatrixBinding({ boundAt }), + ); + hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock.mockReturnValue([ + { + targetSessionKey: "agent:main:subagent:child", + boundAt, + lastActivityAt: Date.now(), + maxAgeMs: 3 * 60 * 60 * 1000, + }, + ]); + + const result = await handleSessionCommand( + createMatrixThreadCommandParams("/session max-age 3h"), + true, + ); + const text = result?.reply?.text ?? ""; + + expect(hoisted.setMatrixThreadBindingMaxAgeBySessionKeyMock).toHaveBeenCalledWith({ + targetSessionKey: "agent:main:subagent:child", + accountId: "default", + maxAgeMs: 3 * 60 * 60 * 1000, + }); + expect(text).toContain("Max age set to 3h"); + expect(text).toContain("2026-02-20T01:00:00.000Z"); + }); + it("reports Telegram max-age expiry from the original bind time", async () => { vi.useFakeTimers(); vi.setSystemTime(new Date("2026-02-20T00:00:00.000Z")); @@ -340,10 +458,20 @@ describe("/session idle and /session max-age", () => { const params = buildCommandTestParams("/session idle 2h", baseCfg); const result = await handleSessionCommand(params, true); expect(result?.reply?.text).toContain( - "currently available for Discord and Telegram bound sessions", + "currently available for Discord, Matrix, and Telegram bound sessions", ); }); + it("requires a focused Matrix thread for lifecycle updates", async () => { + const result = await handleSessionCommand( + createMatrixRoomCommandParams("/session idle 2h"), + true, + ); + + expect(result?.reply?.text).toContain("must be run inside a focused Matrix thread"); + expect(hoisted.setMatrixThreadBindingIdleTimeoutBySessionKeyMock).not.toHaveBeenCalled(); + }); + it("requires binding owner for lifecycle updates", async () => { const binding = createFakeBinding({ boundBy: "owner-1" }); hoisted.getThreadBindingManagerMock.mockReturnValue(createFakeThreadBindingManager(binding)); diff --git a/src/auto-reply/reply/commands-session.ts b/src/auto-reply/reply/commands-session.ts index 0359c77331b..29f85050a43 100644 --- a/src/auto-reply/reply/commands-session.ts +++ b/src/auto-reply/reply/commands-session.ts @@ -12,10 +12,19 @@ import { formatTokenCount, formatUsd } from "../../utils/usage-format.js"; import { parseActivationCommand } from "../group-activation.js"; import { parseSendPolicyCommand } from "../send-policy.js"; import { normalizeFastMode, normalizeUsageDisplay, resolveResponseUsageMode } from "../thinking.js"; -import { isDiscordSurface, isTelegramSurface, resolveChannelAccountId } from "./channel-context.js"; +import { + isDiscordSurface, + isMatrixSurface, + isTelegramSurface, + resolveChannelAccountId, +} from "./channel-context.js"; import { handleAbortTrigger, handleStopCommand } from "./commands-session-abort.js"; import { persistSessionEntry } from "./commands-session-store.js"; import type { CommandHandler } from "./commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "./matrix-context.js"; import { resolveTelegramConversationId } from "./telegram-context.js"; const SESSION_COMMAND_PREFIX = "/session"; @@ -55,7 +64,7 @@ function formatSessionExpiry(expiresAt: number) { return new Date(expiresAt).toISOString(); } -function resolveTelegramBindingDurationMs( +function resolveSessionBindingDurationMs( binding: SessionBindingRecord, key: "idleTimeoutMs" | "maxAgeMs", fallbackMs: number, @@ -67,7 +76,7 @@ function resolveTelegramBindingDurationMs( return Math.max(0, Math.floor(raw)); } -function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): number { +function resolveSessionBindingLastActivityAt(binding: SessionBindingRecord): number { const raw = binding.metadata?.lastActivityAt; if (typeof raw !== "number" || !Number.isFinite(raw)) { return binding.boundAt; @@ -75,7 +84,7 @@ function resolveTelegramBindingLastActivityAt(binding: SessionBindingRecord): nu return Math.max(Math.floor(raw), binding.boundAt); } -function resolveTelegramBindingBoundBy(binding: SessionBindingRecord): string { +function resolveSessionBindingBoundBy(binding: SessionBindingRecord): string { const raw = binding.metadata?.boundBy; return typeof raw === "string" ? raw.trim() : ""; } @@ -87,6 +96,46 @@ type UpdatedLifecycleBinding = { maxAgeMs?: number; }; +function isSessionBindingRecord( + binding: UpdatedLifecycleBinding | SessionBindingRecord, +): binding is SessionBindingRecord { + return "bindingId" in binding; +} + +function resolveUpdatedLifecycleDurationMs( + binding: UpdatedLifecycleBinding | SessionBindingRecord, + key: "idleTimeoutMs" | "maxAgeMs", +): number | undefined { + if (!isSessionBindingRecord(binding)) { + const raw = binding[key]; + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.max(0, Math.floor(raw)); + } + } + if (!isSessionBindingRecord(binding)) { + return undefined; + } + const raw = binding.metadata?.[key]; + if (typeof raw !== "number" || !Number.isFinite(raw)) { + return undefined; + } + return Math.max(0, Math.floor(raw)); +} + +function toUpdatedLifecycleBinding( + binding: UpdatedLifecycleBinding | SessionBindingRecord, +): UpdatedLifecycleBinding { + const lastActivityAt = isSessionBindingRecord(binding) + ? resolveSessionBindingLastActivityAt(binding) + : Math.max(Math.floor(binding.lastActivityAt), binding.boundAt); + return { + boundAt: binding.boundAt, + lastActivityAt, + idleTimeoutMs: resolveUpdatedLifecycleDurationMs(binding, "idleTimeoutMs"), + maxAgeMs: resolveUpdatedLifecycleDurationMs(binding, "maxAgeMs"), + }; +} + function resolveUpdatedBindingExpiry(params: { action: typeof SESSION_ACTION_IDLE | typeof SESSION_ACTION_MAX_AGE; bindings: UpdatedLifecycleBinding[]; @@ -363,12 +412,13 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm } const onDiscord = isDiscordSurface(params); + const onMatrix = isMatrixSurface(params); const onTelegram = isTelegramSurface(params); - if (!onDiscord && !onTelegram) { + if (!onDiscord && !onMatrix && !onTelegram) { return { shouldContinue: false, reply: { - text: "⚠️ /session idle and /session max-age are currently available for Discord and Telegram bound sessions.", + text: "⚠️ /session idle and /session max-age are currently available for Discord, Matrix, and Telegram bound sessions.", }, }; } @@ -377,6 +427,30 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const sessionBindingService = getSessionBindingService(); const threadId = params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + const matrixConversationId = onMatrix + ? resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }) + : undefined; + const matrixParentConversationId = onMatrix + ? resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }) + : undefined; const telegramConversationId = onTelegram ? resolveTelegramConversationId(params) : undefined; const channelRuntime = getChannelRuntime(); @@ -400,6 +474,17 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm conversationId: telegramConversationId, }) : null; + const matrixBinding = + onMatrix && matrixConversationId + ? sessionBindingService.resolveByConversation({ + channel: "matrix", + accountId, + conversationId: matrixConversationId, + ...(matrixParentConversationId && matrixParentConversationId !== matrixConversationId + ? { parentConversationId: matrixParentConversationId } + : {}), + }) + : null; if (onDiscord && !discordBinding) { if (onDiscord && !threadId) { return { @@ -414,6 +499,20 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm reply: { text: "ℹ️ This thread is not currently focused." }, }; } + if (onMatrix && !matrixBinding) { + if (!threadId) { + return { + shouldContinue: false, + reply: { + text: "⚠️ /session idle and /session max-age must be run inside a focused Matrix thread.", + }, + }; + } + return { + shouldContinue: false, + reply: { text: "ℹ️ This thread is not currently focused." }, + }; + } if (onTelegram && !telegramBinding) { if (!telegramConversationId) { return { @@ -434,28 +533,33 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm record: discordBinding!, defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(), }) - : resolveTelegramBindingDurationMs(telegramBinding!, "idleTimeoutMs", 24 * 60 * 60 * 1000); + : resolveSessionBindingDurationMs( + (onMatrix ? matrixBinding : telegramBinding)!, + "idleTimeoutMs", + 24 * 60 * 60 * 1000, + ); const idleExpiresAt = onDiscord ? channelRuntime.discord.threadBindings.resolveInactivityExpiresAt({ record: discordBinding!, defaultIdleTimeoutMs: discordManager!.getIdleTimeoutMs(), }) : idleTimeoutMs > 0 - ? resolveTelegramBindingLastActivityAt(telegramBinding!) + idleTimeoutMs + ? resolveSessionBindingLastActivityAt((onMatrix ? matrixBinding : telegramBinding)!) + + idleTimeoutMs : undefined; const maxAgeMs = onDiscord ? channelRuntime.discord.threadBindings.resolveMaxAgeMs({ record: discordBinding!, defaultMaxAgeMs: discordManager!.getMaxAgeMs(), }) - : resolveTelegramBindingDurationMs(telegramBinding!, "maxAgeMs", 0); + : resolveSessionBindingDurationMs((onMatrix ? matrixBinding : telegramBinding)!, "maxAgeMs", 0); const maxAgeExpiresAt = onDiscord ? channelRuntime.discord.threadBindings.resolveMaxAgeExpiresAt({ record: discordBinding!, defaultMaxAgeMs: discordManager!.getMaxAgeMs(), }) : maxAgeMs > 0 - ? telegramBinding!.boundAt + maxAgeMs + ? (onMatrix ? matrixBinding : telegramBinding)!.boundAt + maxAgeMs : undefined; const durationArgRaw = tokens.slice(1).join(""); @@ -500,14 +604,16 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const senderId = params.command.senderId?.trim() || ""; const boundBy = onDiscord ? discordBinding!.boundBy - : resolveTelegramBindingBoundBy(telegramBinding!); + : resolveSessionBindingBoundBy((onMatrix ? matrixBinding : telegramBinding)!); if (boundBy && boundBy !== "system" && senderId && senderId !== boundBy) { return { shouldContinue: false, reply: { text: onDiscord ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` - : `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, + : onMatrix + ? `⚠️ Only ${boundBy} can update session lifecycle settings for this thread.` + : `⚠️ Only ${boundBy} can update session lifecycle settings for this conversation.`, }, }; } @@ -536,6 +642,19 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm maxAgeMs: durationMs, }); } + if (onMatrix) { + return action === SESSION_ACTION_IDLE + ? channelRuntime.matrix.threadBindings.setIdleTimeoutBySessionKey({ + targetSessionKey: matrixBinding!.targetSessionKey, + accountId, + idleTimeoutMs: durationMs, + }) + : channelRuntime.matrix.threadBindings.setMaxAgeBySessionKey({ + targetSessionKey: matrixBinding!.targetSessionKey, + accountId, + maxAgeMs: durationMs, + }); + } return action === SESSION_ACTION_IDLE ? channelRuntime.telegram.threadBindings.setIdleTimeoutBySessionKey({ targetSessionKey: telegramBinding!.targetSessionKey, @@ -574,7 +693,7 @@ export const handleSessionCommand: CommandHandler = async (params, allowTextComm const nextExpiry = resolveUpdatedBindingExpiry({ action, - bindings: updatedBindings, + bindings: updatedBindings.map((binding) => toUpdatedLifecycleBinding(binding)), }); const expiryLabel = typeof nextExpiry === "number" && Number.isFinite(nextExpiry) diff --git a/src/auto-reply/reply/commands-subagents-focus.test.ts b/src/auto-reply/reply/commands-subagents-focus.test.ts index 651d8088486..de799e5208b 100644 --- a/src/auto-reply/reply/commands-subagents-focus.test.ts +++ b/src/auto-reply/reply/commands-subagents-focus.test.ts @@ -103,6 +103,31 @@ function createTelegramTopicCommandParams(commandBody: string) { return params; } +function createMatrixThreadCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + MessageThreadId: "$thread-1", + }); + params.command.senderId = "user-1"; + return params; +} + +function createMatrixRoomCommandParams(commandBody: string, cfg: OpenClawConfig = baseCfg) { + const params = buildCommandTestParams(commandBody, cfg, { + Provider: "matrix", + Surface: "matrix", + OriginatingChannel: "matrix", + OriginatingTo: "room:!room:example.org", + AccountId: "default", + }); + params.command.senderId = "user-1"; + return params; +} + function createSessionBindingRecord( overrides?: Partial, ): SessionBindingRecord { @@ -144,7 +169,13 @@ async function focusCodexAcp( hoisted.sessionBindingBindMock.mockImplementation( async (input: { targetSessionKey: string; - conversation: { channel: string; accountId: string; conversationId: string }; + placement: "current" | "child"; + conversation: { + channel: string; + accountId: string; + conversationId: string; + parentConversationId?: string; + }; metadata?: Record; }) => createSessionBindingRecord({ @@ -152,7 +183,11 @@ async function focusCodexAcp( conversation: { channel: input.conversation.channel, accountId: input.conversation.accountId, - conversationId: input.conversation.conversationId, + conversationId: + input.placement === "child" ? "thread-created" : input.conversation.conversationId, + ...(input.conversation.parentConversationId + ? { parentConversationId: input.conversation.parentConversationId } + : {}), }, metadata: { boundBy: typeof input.metadata?.boundBy === "string" ? input.metadata.boundBy : "user-1", @@ -220,6 +255,51 @@ describe("/focus, /unfocus, /agents", () => { ); }); + it("/focus creates a Matrix thread from a top-level room when spawnSubagentSessions is enabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + spawnSubagentSessions: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + + expect(result?.reply?.text).toContain("created thread thread-created and bound it"); + expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith( + expect.objectContaining({ + placement: "child", + conversation: expect.objectContaining({ + channel: "matrix", + conversationId: "!room:example.org", + }), + }), + ); + }); + + it("/focus rejects Matrix top-level thread creation when spawnSubagentSessions is disabled", async () => { + const cfg = { + ...baseCfg, + channels: { + matrix: { + threadBindings: { + enabled: true, + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await focusCodexAcp(createMatrixRoomCommandParams("/focus codex-acp", cfg)); + + expect(result?.reply?.text).toContain("spawnSubagentSessions=true"); + expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled(); + }); + it("/focus includes ACP session identifiers in intro text when available", async () => { hoisted.readAcpSessionEntryMock.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", @@ -283,6 +363,36 @@ describe("/focus, /unfocus, /agents", () => { }); }); + it("/unfocus removes an active Matrix thread binding for the binding owner", async () => { + const params = createMatrixThreadCommandParams("/unfocus"); + hoisted.sessionBindingResolveByConversationMock.mockReturnValue( + createSessionBindingRecord({ + bindingId: "default:matrix-thread-1", + conversation: { + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }, + metadata: { boundBy: "user-1" }, + }), + ); + + const result = await handleSubagentsCommand(params, true); + + expect(result?.reply?.text).toContain("Thread unfocused"); + expect(hoisted.sessionBindingResolveByConversationMock).toHaveBeenCalledWith({ + channel: "matrix", + accountId: "default", + conversationId: "$thread-1", + parentConversationId: "!room:example.org", + }); + expect(hoisted.sessionBindingUnbindMock).toHaveBeenCalledWith({ + bindingId: "default:matrix-thread-1", + reason: "manual", + }); + }); + it("/focus rejects rebinding when the thread is focused by another user", async () => { const result = await focusCodexAcp(undefined, { existingBinding: createSessionBindingRecord({ @@ -401,6 +511,6 @@ describe("/focus, /unfocus, /agents", () => { it("/focus rejects unsupported channels", async () => { const params = buildCommandTestParams("/focus codex-acp", baseCfg); const result = await handleSubagentsCommand(params, true); - expect(result?.reply?.text).toContain("only available on Discord and Telegram"); + expect(result?.reply?.text).toContain("only available on Discord, Matrix, and Telegram"); }); }); diff --git a/src/auto-reply/reply/commands-subagents/action-focus.ts b/src/auto-reply/reply/commands-subagents/action-focus.ts index df7a268b3b0..f55cbe95a39 100644 --- a/src/auto-reply/reply/commands-subagents/action-focus.ts +++ b/src/auto-reply/reply/commands-subagents/action-focus.ts @@ -8,14 +8,22 @@ import { resolveThreadBindingThreadName, } from "../../../channels/thread-bindings-messages.js"; import { + formatThreadBindingDisabledError, + formatThreadBindingSpawnDisabledError, resolveThreadBindingIdleTimeoutMsForChannel, resolveThreadBindingMaxAgeMsForChannel, + resolveThreadBindingSpawnPolicy, } from "../../../channels/thread-bindings-policy.js"; import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { type SubagentsCommandContext, isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveChannelAccountId, resolveCommandSurfaceChannel, @@ -26,9 +34,10 @@ import { } from "./shared.js"; type FocusBindingContext = { - channel: "discord" | "telegram"; + channel: "discord" | "matrix" | "telegram"; accountId: string; conversationId: string; + parentConversationId?: string; placement: "current" | "child"; labelNoun: "thread" | "conversation"; }; @@ -65,6 +74,41 @@ function resolveFocusBindingContext( labelNoun: "conversation", }; } + if (isMatrixSurface(params)) { + const conversationId = resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + if (!conversationId) { + return null; + } + const parentConversationId = resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + const currentThreadId = + params.ctx.MessageThreadId != null ? String(params.ctx.MessageThreadId).trim() : ""; + return { + channel: "matrix", + accountId: resolveChannelAccountId(params), + conversationId, + ...(parentConversationId ? { parentConversationId } : {}), + placement: currentThreadId ? "current" : "child", + labelNoun: "thread", + }; + } return null; } @@ -73,8 +117,8 @@ export async function handleSubagentsFocusAction( ): Promise { const { params, runs, restTokens } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram") { - return stopWithText("⚠️ /focus is only available on Discord and Telegram."); + if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { + return stopWithText("⚠️ /focus is only available on Discord, Matrix, and Telegram."); } const token = restTokens.join(" ").trim(); @@ -89,7 +133,12 @@ export async function handleSubagentsFocusAction( accountId, }); if (!capabilities.adapterAvailable || !capabilities.bindSupported) { - const label = channel === "discord" ? "Discord thread" : "Telegram conversation"; + const label = + channel === "discord" + ? "Discord thread" + : channel === "matrix" + ? "Matrix thread" + : "Telegram conversation"; return stopWithText(`⚠️ ${label} bindings are unavailable for this account.`); } @@ -105,14 +154,48 @@ export async function handleSubagentsFocusAction( "⚠️ /focus on Telegram requires a topic context in groups, or a direct-message conversation.", ); } + if (channel === "matrix") { + return stopWithText("⚠️ Could not resolve a Matrix room for /focus."); + } return stopWithText("⚠️ Could not resolve a Discord channel for /focus."); } + if (channel === "matrix") { + const spawnPolicy = resolveThreadBindingSpawnPolicy({ + cfg: params.cfg, + channel, + accountId: bindingContext.accountId, + kind: "subagent", + }); + if (!spawnPolicy.enabled) { + return stopWithText( + `⚠️ ${formatThreadBindingDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "subagent", + })}`, + ); + } + if (bindingContext.placement === "child" && !spawnPolicy.spawnEnabled) { + return stopWithText( + `⚠️ ${formatThreadBindingSpawnDisabledError({ + channel: spawnPolicy.channel, + accountId: spawnPolicy.accountId, + kind: "subagent", + })}`, + ); + } + } + const senderId = params.command.senderId?.trim() || ""; const existingBinding = bindingService.resolveByConversation({ channel: bindingContext.channel, accountId: bindingContext.accountId, conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId && + bindingContext.parentConversationId !== bindingContext.conversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }); const boundBy = typeof existingBinding?.metadata?.boundBy === "string" @@ -143,6 +226,10 @@ export async function handleSubagentsFocusAction( channel: bindingContext.channel, accountId: bindingContext.accountId, conversationId: bindingContext.conversationId, + ...(bindingContext.parentConversationId && + bindingContext.parentConversationId !== bindingContext.conversationId + ? { parentConversationId: bindingContext.parentConversationId } + : {}), }, placement: bindingContext.placement, metadata: { diff --git a/src/auto-reply/reply/commands-subagents/action-unfocus.ts b/src/auto-reply/reply/commands-subagents/action-unfocus.ts index 78bb02b2427..0331772316e 100644 --- a/src/auto-reply/reply/commands-subagents/action-unfocus.ts +++ b/src/auto-reply/reply/commands-subagents/action-unfocus.ts @@ -1,8 +1,13 @@ import { getSessionBindingService } from "../../../infra/outbound/session-binding-service.js"; import type { CommandHandlerResult } from "../commands-types.js"; +import { + resolveMatrixConversationId, + resolveMatrixParentConversationId, +} from "../matrix-context.js"; import { type SubagentsCommandContext, isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveChannelAccountId, resolveCommandSurfaceChannel, @@ -15,8 +20,8 @@ export async function handleSubagentsUnfocusAction( ): Promise { const { params } = ctx; const channel = resolveCommandSurfaceChannel(params); - if (channel !== "discord" && channel !== "telegram") { - return stopWithText("⚠️ /unfocus is only available on Discord and Telegram."); + if (channel !== "discord" && channel !== "matrix" && channel !== "telegram") { + return stopWithText("⚠️ /unfocus is only available on Discord, Matrix, and Telegram."); } const accountId = resolveChannelAccountId(params); @@ -30,13 +35,43 @@ export async function handleSubagentsUnfocusAction( if (isTelegramSurface(params)) { return resolveTelegramConversationId(params); } + if (isMatrixSurface(params)) { + return resolveMatrixConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + } return undefined; })(); + const parentConversationId = (() => { + if (!isMatrixSurface(params)) { + return undefined; + } + return resolveMatrixParentConversationId({ + ctx: { + MessageThreadId: params.ctx.MessageThreadId, + OriginatingTo: params.ctx.OriginatingTo, + To: params.ctx.To, + }, + command: { + to: params.command.to, + }, + }); + })(); if (!conversationId) { if (channel === "discord") { return stopWithText("⚠️ /unfocus must be run inside a Discord thread."); } + if (channel === "matrix") { + return stopWithText("⚠️ /unfocus must be run inside a Matrix thread."); + } return stopWithText( "⚠️ /unfocus on Telegram requires a topic context in groups, or a direct-message conversation.", ); @@ -46,12 +81,17 @@ export async function handleSubagentsUnfocusAction( channel, accountId, conversationId, + ...(parentConversationId && parentConversationId !== conversationId + ? { parentConversationId } + : {}), }); if (!binding) { return stopWithText( channel === "discord" ? "ℹ️ This thread is not currently focused." - : "ℹ️ This conversation is not currently focused.", + : channel === "matrix" + ? "ℹ️ This thread is not currently focused." + : "ℹ️ This conversation is not currently focused.", ); } @@ -62,7 +102,9 @@ export async function handleSubagentsUnfocusAction( return stopWithText( channel === "discord" ? `⚠️ Only ${boundBy} can unfocus this thread.` - : `⚠️ Only ${boundBy} can unfocus this conversation.`, + : channel === "matrix" + ? `⚠️ Only ${boundBy} can unfocus this thread.` + : `⚠️ Only ${boundBy} can unfocus this conversation.`, ); } @@ -71,6 +113,8 @@ export async function handleSubagentsUnfocusAction( reason: "manual", }); return stopWithText( - channel === "discord" ? "✅ Thread unfocused." : "✅ Conversation unfocused.", + channel === "discord" || channel === "matrix" + ? "✅ Thread unfocused." + : "✅ Conversation unfocused.", ); } diff --git a/src/auto-reply/reply/commands-subagents/shared.ts b/src/auto-reply/reply/commands-subagents/shared.ts index 9781683267e..3d2b9726da3 100644 --- a/src/auto-reply/reply/commands-subagents/shared.ts +++ b/src/auto-reply/reply/commands-subagents/shared.ts @@ -30,6 +30,7 @@ import { } from "../../../shared/subagents-format.js"; import { isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveCommandSurfaceChannel, resolveDiscordAccountId, @@ -47,6 +48,7 @@ import { resolveTelegramConversationId } from "../telegram-context.js"; export { extractAssistantText, stripToolMessages }; export { isDiscordSurface, + isMatrixSurface, isTelegramSurface, resolveCommandSurfaceChannel, resolveDiscordAccountId, diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts index 760c42aed1a..c8451fd88f6 100644 --- a/src/auto-reply/reply/get-reply-run.ts +++ b/src/auto-reply/reply/get-reply-run.ts @@ -21,6 +21,7 @@ import { clearCommandLane, getQueueSize } from "../../process/command-queue.js"; import { normalizeMainKey } from "../../routing/session-key.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { hasControlCommand } from "../command-detection.js"; +import { resolveEnvelopeFormatOptions } from "../envelope.js"; import { buildInboundMediaNote } from "../media-note.js"; import type { MsgContext, TemplateContext } from "../templating.js"; import { @@ -292,6 +293,7 @@ export async function runPreparedReply( isNewSession && ((baseBodyTrimmedRaw.length === 0 && rawBodyTrimmed.length > 0) || isBareNewOrReset); const baseBodyFinal = isBareSessionReset ? buildBareSessionResetPrompt(cfg) : baseBody; + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const inboundUserContext = buildInboundUserContextPrefix( isNewSession ? { @@ -301,6 +303,7 @@ export async function runPreparedReply( : {}), } : { ...sessionCtx, ThreadStarterBody: undefined }, + envelopeOptions, ); const baseBodyForPrompt = isBareSessionReset ? baseBodyFinal diff --git a/src/auto-reply/reply/inbound-meta.test.ts b/src/auto-reply/reply/inbound-meta.test.ts index b39fe5c9805..db964a9db26 100644 --- a/src/auto-reply/reply/inbound-meta.test.ts +++ b/src/auto-reply/reply/inbound-meta.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { withEnv } from "../../test-utils/env.js"; import type { TemplateContext } from "../templating.js"; import { buildInboundMetaSystemPrompt, buildInboundUserContextPrefix } from "./inbound-meta.js"; @@ -217,6 +218,25 @@ describe("buildInboundUserContextPrefix", () => { expect(conversationInfo["timestamp"]).toEqual(expect.any(String)); }); + it("honors envelope user timezone for conversation timestamps", () => { + withEnv({ TZ: "America/Los_Angeles" }, () => { + const text = buildInboundUserContextPrefix( + { + ChatType: "group", + MessageSid: "msg-with-user-tz", + Timestamp: Date.UTC(2026, 2, 19, 0, 0), + } as TemplateContext, + { + timezone: "user", + userTimezone: "Asia/Tokyo", + }, + ); + + const conversationInfo = parseConversationInfoPayload(text); + expect(conversationInfo["timestamp"]).toBe("Thu 2026-03-19 09:00 GMT+9"); + }); + }); + it("omits invalid timestamps instead of throwing", () => { expect(() => buildInboundUserContextPrefix({ diff --git a/src/auto-reply/reply/inbound-meta.ts b/src/auto-reply/reply/inbound-meta.ts index 519414fa109..8aa9973bae0 100644 --- a/src/auto-reply/reply/inbound-meta.ts +++ b/src/auto-reply/reply/inbound-meta.ts @@ -1,6 +1,7 @@ import { normalizeChatType } from "../../channels/chat-type.js"; import { resolveSenderLabel } from "../../channels/sender-label.js"; -import { formatZonedTimestamp } from "../../infra/format-time/format-datetime.js"; +import type { EnvelopeFormatOptions } from "../envelope.js"; +import { formatEnvelopeTimestamp } from "../envelope.js"; import type { TemplateContext } from "../templating.js"; function safeTrim(value: unknown): string | undefined { @@ -11,24 +12,14 @@ function safeTrim(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } -function formatConversationTimestamp(value: unknown): string | undefined { +function formatConversationTimestamp( + value: unknown, + envelope?: EnvelopeFormatOptions, +): string | undefined { if (typeof value !== "number" || !Number.isFinite(value)) { return undefined; } - const date = new Date(value); - if (Number.isNaN(date.getTime())) { - return undefined; - } - const formatted = formatZonedTimestamp(date); - if (!formatted) { - return undefined; - } - try { - const weekday = new Intl.DateTimeFormat("en-US", { weekday: "short" }).format(date); - return weekday ? `${weekday} ${formatted}` : formatted; - } catch { - return formatted; - } + return formatEnvelopeTimestamp(value, envelope); } function resolveInboundChannel(ctx: TemplateContext): string | undefined { @@ -81,7 +72,10 @@ export function buildInboundMetaSystemPrompt(ctx: TemplateContext): string { ].join("\n"); } -export function buildInboundUserContextPrefix(ctx: TemplateContext): string { +export function buildInboundUserContextPrefix( + ctx: TemplateContext, + envelope?: EnvelopeFormatOptions, +): string { const blocks: string[] = []; const chatType = normalizeChatType(ctx.ChatType); const isDirect = !chatType || chatType === "direct"; @@ -94,7 +88,7 @@ export function buildInboundUserContextPrefix(ctx: TemplateContext): string { const messageId = safeTrim(ctx.MessageSid); const messageIdFull = safeTrim(ctx.MessageSidFull); const resolvedMessageId = messageId ?? messageIdFull; - const timestampStr = formatConversationTimestamp(ctx.Timestamp); + const timestampStr = formatConversationTimestamp(ctx.Timestamp, envelope); const conversationInfo = { message_id: shouldIncludeConversationInfo ? resolvedMessageId : undefined, diff --git a/src/auto-reply/reply/reply-utils.test.ts b/src/auto-reply/reply/reply-utils.test.ts index fc499e93676..2055ce54583 100644 --- a/src/auto-reply/reply/reply-utils.test.ts +++ b/src/auto-reply/reply/reply-utils.test.ts @@ -675,6 +675,39 @@ describe("block reply coalescer", () => { coalescer.stop(); }); + it("keeps buffering newline-style chunks until minChars is reached", async () => { + vi.useFakeTimers(); + const { flushes, coalescer } = createBlockCoalescerHarness({ + minChars: 25, + maxChars: 2000, + idleMs: 50, + joiner: "\n\n", + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + + await vi.advanceTimersByTimeAsync(50); + expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); + coalescer.stop(); + }); + + it("force flushes buffered newline-style chunks even below minChars", async () => { + const { flushes, coalescer } = createBlockCoalescerHarness({ + minChars: 100, + maxChars: 2000, + idleMs: 50, + joiner: "\n\n", + }); + + coalescer.enqueue({ text: "First paragraph" }); + coalescer.enqueue({ text: "Second paragraph" }); + await coalescer.flush({ force: true }); + + expect(flushes).toEqual(["First paragraph\n\nSecond paragraph"]); + coalescer.stop(); + }); + it("flushes immediately per enqueue when flushOnEnqueue is set", async () => { const cases = [ { diff --git a/src/channels/plugins/contracts/registry.ts b/src/channels/plugins/contracts/registry.ts index 94892151c7b..3068f790053 100644 --- a/src/channels/plugins/contracts/registry.ts +++ b/src/channels/plugins/contracts/registry.ts @@ -1,9 +1,13 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { expect, vi } from "vitest"; import { __testing as discordThreadBindingTesting, createThreadBindingManager as createDiscordThreadBindingManager, } from "../../../../extensions/discord/runtime-api.js"; import { createFeishuThreadBindingManager } from "../../../../extensions/feishu/api.js"; +import { createMatrixThreadBindingManager } from "../../../../extensions/matrix/api.js"; import { createTelegramThreadBindingManager } from "../../../../extensions/telegram/runtime-api.js"; import type { OpenClawConfig } from "../../../config/config.js"; import { @@ -126,7 +130,7 @@ type DirectoryContractEntry = { type SessionBindingContractEntry = { id: string; expectedCapabilities: SessionBindingCapabilities; - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; @@ -136,6 +140,7 @@ function expectResolvedSessionBinding(params: { channel: string; accountId: string; conversationId: string; + parentConversationId?: string; targetSessionKey: string; }) { expect( @@ -143,6 +148,7 @@ function expectResolvedSessionBinding(params: { channel: params.channel, accountId: params.accountId, conversationId: params.conversationId, + parentConversationId: params.parentConversationId, }), )?.toMatchObject({ targetSessionKey: params.targetSessionKey, @@ -589,6 +595,24 @@ const baseSessionBindingCfg = { session: { mainKey: "main", scope: "per-sender" }, } satisfies OpenClawConfig; +async function createContractMatrixThreadBindingManager() { + const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "matrix-contract-thread-bindings-")); + return await createMatrixThreadBindingManager({ + accountId: "ops", + auth: { + accountId: "ops", + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "token", + }, + client: {} as never, + stateDir, + idleTimeoutMs: 24 * 60 * 60 * 1000, + maxAgeMs: 0, + enableSweeper: false, + }); +} + export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ { id: "discord", @@ -708,6 +732,61 @@ export const sessionBindingContractRegistry: SessionBindingContractEntry[] = [ }); }, }, + { + id: "matrix", + expectedCapabilities: { + adapterAvailable: true, + bindSupported: true, + unbindSupported: true, + placements: ["current", "child"], + }, + getCapabilities: async () => { + await createContractMatrixThreadBindingManager(); + return getSessionBindingService().getCapabilities({ + channel: "matrix", + accountId: "ops", + }); + }, + bindAndResolve: async () => { + await createContractMatrixThreadBindingManager(); + const service = getSessionBindingService(); + const binding = await service.bind({ + targetSessionKey: "agent:matrix:subagent:child-1", + targetKind: "subagent", + conversation: { + channel: "matrix", + accountId: "ops", + conversationId: "!room:example", + }, + placement: "child", + metadata: { + label: "codex-matrix", + introText: "intro root", + }, + }); + expectResolvedSessionBinding({ + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + targetSessionKey: "agent:matrix:subagent:child-1", + }); + return binding; + }, + unbindAndVerify: unbindAndExpectClearedSessionBinding, + cleanup: async () => { + const manager = await createContractMatrixThreadBindingManager(); + manager.stop(); + expect( + getSessionBindingService().resolveByConversation({ + channel: "matrix", + accountId: "ops", + conversationId: "$root", + parentConversationId: "!room:example", + }), + ).toBeNull(); + }, + }, { id: "telegram", expectedCapabilities: { diff --git a/src/channels/plugins/contracts/session-binding.contract.test.ts b/src/channels/plugins/contracts/session-binding.contract.test.ts index b8201569cde..efc85cb74b4 100644 --- a/src/channels/plugins/contracts/session-binding.contract.test.ts +++ b/src/channels/plugins/contracts/session-binding.contract.test.ts @@ -1,15 +1,32 @@ -import { beforeEach, describe } from "vitest"; +import { beforeEach, describe, vi } from "vitest"; import { __testing as discordThreadBindingTesting } from "../../../../extensions/discord/src/monitor/thread-bindings.manager.js"; import { __testing as feishuThreadBindingTesting } from "../../../../extensions/feishu/src/thread-bindings.js"; +import { resetMatrixThreadBindingsForTests } from "../../../../extensions/matrix/api.js"; import { __testing as telegramThreadBindingTesting } from "../../../../extensions/telegram/src/thread-bindings.js"; import { __testing as sessionBindingTesting } from "../../../infra/outbound/session-binding-service.js"; import { sessionBindingContractRegistry } from "./registry.js"; import { installSessionBindingContractSuite } from "./suites.js"; +vi.mock("../../../../extensions/matrix/src/matrix/send.js", async () => { + const actual = await vi.importActual< + typeof import("../../../../extensions/matrix/src/matrix/send.js") + >("../../../../extensions/matrix/src/matrix/send.js"); + return { + ...actual, + sendMessageMatrix: vi.fn( + async (_to: string, _message: string, opts?: { threadId?: string }) => ({ + messageId: opts?.threadId ? "$reply" : "$root", + roomId: "!room:example", + }), + ), + }; +}); + beforeEach(() => { sessionBindingTesting.resetSessionBindingAdaptersForTests(); discordThreadBindingTesting.resetThreadBindingsForTests(); feishuThreadBindingTesting.resetFeishuThreadBindingsForTests(); + resetMatrixThreadBindingsForTests(); telegramThreadBindingTesting.resetTelegramThreadBindingsForTests(); }); diff --git a/src/channels/plugins/contracts/suites.ts b/src/channels/plugins/contracts/suites.ts index 892d4b293f9..7c9803ee47f 100644 --- a/src/channels/plugins/contracts/suites.ts +++ b/src/channels/plugins/contracts/suites.ts @@ -478,14 +478,14 @@ export function installChannelDirectoryContractSuite(params: { } export function installSessionBindingContractSuite(params: { - getCapabilities: () => SessionBindingCapabilities; + getCapabilities: () => SessionBindingCapabilities | Promise; bindAndResolve: () => Promise; unbindAndVerify: (binding: SessionBindingRecord) => Promise; cleanup: () => Promise | void; expectedCapabilities: SessionBindingCapabilities; }) { - it("registers the expected session binding capabilities", () => { - expect(params.getCapabilities()).toEqual(params.expectedCapabilities); + it("registers the expected session binding capabilities", async () => { + expect(await params.getCapabilities()).toEqual(params.expectedCapabilities); }); it("binds and resolves a session binding through the shared service", async () => { diff --git a/src/channels/plugins/message-action-names.ts b/src/channels/plugins/message-action-names.ts index aadff95c77d..3bf58083d14 100644 --- a/src/channels/plugins/message-action-names.ts +++ b/src/channels/plugins/message-action-names.ts @@ -51,6 +51,7 @@ export const CHANNEL_MESSAGE_ACTION_NAMES = [ "timeout", "kick", "ban", + "set-profile", "set-presence", "download-file", ] as const; diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 15f3f5557fe..5fe30994da0 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { normalizeAccountId } from "../routing/session-key.js"; export const DISCORD_THREAD_BINDING_CHANNEL = "discord"; +export const MATRIX_THREAD_BINDING_CHANNEL = "matrix"; const DEFAULT_THREAD_BINDING_IDLE_HOURS = 24; const DEFAULT_THREAD_BINDING_MAX_AGE_HOURS = 0; @@ -127,8 +128,9 @@ export function resolveThreadBindingSpawnPolicy(params: { const spawnFlagKey = resolveSpawnFlagKey(params.kind); const spawnEnabledRaw = normalizeBoolean(account?.[spawnFlagKey]) ?? normalizeBoolean(root?.[spawnFlagKey]); - // Non-Discord channels currently have no dedicated spawn gate config keys. - const spawnEnabled = spawnEnabledRaw ?? channel !== DISCORD_THREAD_BINDING_CHANNEL; + const spawnEnabled = + spawnEnabledRaw ?? + (channel !== DISCORD_THREAD_BINDING_CHANNEL && channel !== MATRIX_THREAD_BINDING_CHANNEL); return { channel, accountId, @@ -183,6 +185,9 @@ export function formatThreadBindingDisabledError(params: { if (params.channel === DISCORD_THREAD_BINDING_CHANNEL) { return "Discord thread bindings are disabled (set channels.discord.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL) { + return "Matrix thread bindings are disabled (set channels.matrix.threadBindings.enabled=true to override for this account, or session.threadBindings.enabled=true globally)."; + } return `Thread bindings are disabled for ${params.channel} (set session.threadBindings.enabled=true to enable).`; } @@ -197,5 +202,11 @@ export function formatThreadBindingSpawnDisabledError(params: { if (params.channel === DISCORD_THREAD_BINDING_CHANNEL && params.kind === "subagent") { return "Discord thread-bound subagent spawns are disabled for this account (set channels.discord.threadBindings.spawnSubagentSessions=true to enable)."; } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "acp") { + return "Matrix thread-bound ACP spawns are disabled for this account (set channels.matrix.threadBindings.spawnAcpSessions=true to enable)."; + } + if (params.channel === MATRIX_THREAD_BINDING_CHANNEL && params.kind === "subagent") { + return "Matrix thread-bound subagent spawns are disabled for this account (set channels.matrix.threadBindings.spawnSubagentSessions=true to enable)."; + } return `Thread-bound ${params.kind} spawns are disabled for ${params.channel}.`; } diff --git a/src/cli/program/register.agent.test.ts b/src/cli/program/register.agent.test.ts index 2d37e56a702..15fcc4d06dd 100644 --- a/src/cli/program/register.agent.test.ts +++ b/src/cli/program/register.agent.test.ts @@ -174,7 +174,7 @@ describe("registerAgentCommands", () => { "--agent", "ops", "--bind", - "matrix-js:ops", + "matrix:ops", "--bind", "telegram", "--json", @@ -182,7 +182,7 @@ describe("registerAgentCommands", () => { expect(agentsBindCommandMock).toHaveBeenCalledWith( { agent: "ops", - bind: ["matrix-js:ops", "telegram"], + bind: ["matrix:ops", "telegram"], json: true, }, runtime, diff --git a/src/commands/agents.bind.commands.test.ts b/src/commands/agents.bind.commands.test.ts index 0fe03173be6..0b55adb2cdd 100644 --- a/src/commands/agents.bind.commands.test.ts +++ b/src/commands/agents.bind.commands.test.ts @@ -15,9 +15,9 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => { return { ...actual, getChannelPlugin: (channel: string) => { - if (channel === "matrix-js") { + if (channel === "matrix") { return { - id: "matrix-js", + id: "matrix", setup: { resolveBindingAccountId: ({ agentId }: { agentId: string }) => agentId.toLowerCase(), }, @@ -26,8 +26,8 @@ vi.mock("../channels/plugins/index.js", async (importOriginal) => { return actual.getChannelPlugin(channel); }, normalizeChannelId: (channel: string) => { - if (channel.trim().toLowerCase() === "matrix-js") { - return "matrix-js"; + if (channel.trim().toLowerCase() === "matrix") { + return "matrix"; } return actual.normalizeChannelId(channel); }, @@ -52,7 +52,7 @@ describe("agents bind/unbind commands", () => { ...baseConfigSnapshot, config: { bindings: [ - { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "main", match: { channel: "matrix" } }, { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, ], }, @@ -60,7 +60,7 @@ describe("agents bind/unbind commands", () => { await agentsBindingsCommand({}, runtime); - expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix-js")); + expect(runtime.log).toHaveBeenCalledWith(expect.stringContaining("main <- matrix")); expect(runtime.log).toHaveBeenCalledWith( expect.stringContaining("ops <- telegram accountId=work"), ); @@ -76,23 +76,29 @@ describe("agents bind/unbind commands", () => { expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "telegram" } }], + bindings: [{ type: "route", agentId: "main", match: { channel: "telegram" } }], }), ); expect(runtime.exit).not.toHaveBeenCalled(); }); - it("defaults matrix-js accountId to the target agent id when omitted", async () => { + it("defaults matrix accountId to the target agent id when omitted", async () => { readConfigFileSnapshotMock.mockResolvedValue({ ...baseConfigSnapshot, config: {}, }); - await agentsBindCommand({ agent: "main", bind: ["matrix-js"] }, runtime); + await agentsBindCommand({ agent: "main", bind: ["matrix"] }, runtime); expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "matrix-js", accountId: "main" } }], + bindings: [ + { + type: "route", + agentId: "main", + match: { channel: "matrix", accountId: "main" }, + }, + ], }), ); expect(runtime.exit).not.toHaveBeenCalled(); @@ -123,7 +129,7 @@ describe("agents bind/unbind commands", () => { config: { agents: { list: [{ id: "ops", workspace: "/tmp/ops" }] }, bindings: [ - { agentId: "main", match: { channel: "matrix-js" } }, + { agentId: "main", match: { channel: "matrix" } }, { agentId: "ops", match: { channel: "telegram", accountId: "work" } }, ], }, @@ -133,7 +139,7 @@ describe("agents bind/unbind commands", () => { expect(writeConfigFileMock).toHaveBeenCalledWith( expect.objectContaining({ - bindings: [{ agentId: "main", match: { channel: "matrix-js" } }], + bindings: [{ agentId: "main", match: { channel: "matrix" } }], }), ); expect(runtime.exit).not.toHaveBeenCalled(); diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts new file mode 100644 index 00000000000..1c223d8a75a --- /dev/null +++ b/src/commands/channels.remove.test.ts @@ -0,0 +1,154 @@ +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; +import { setActivePluginRegistry } from "../plugins/runtime.js"; +import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import { + ensureChannelSetupPluginInstalled, + loadChannelSetupPluginRegistrySnapshotForChannel, +} from "./channel-setup/plugin-install.js"; +import { configMocks } from "./channels.mock-harness.js"; +import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; + +const catalogMocks = vi.hoisted(() => ({ + listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []), +})); + +vi.mock("../channels/plugins/catalog.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, + }; +}); + +vi.mock("./channel-setup/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureChannelSetupPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })), + loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()), + }; +}); + +const runtime = createTestRuntime(); +let channelsRemoveCommand: typeof import("./channels.js").channelsRemoveCommand; + +describe("channelsRemoveCommand", () => { + beforeAll(async () => { + ({ channelsRemoveCommand } = await import("./channels.js")); + }); + + beforeEach(() => { + configMocks.readConfigFileSnapshot.mockClear(); + configMocks.writeConfigFile.mockClear(); + runtime.log.mockClear(); + runtime.error.mockClear(); + runtime.exit.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockClear(); + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]); + vi.mocked(ensureChannelSetupPluginInstalled).mockClear(); + vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ + cfg, + installed: true, + })); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( + createTestRegistry(), + ); + setActivePluginRegistry(createTestRegistry()); + }); + + it("removes an external channel account after installing its plugin on demand", async () => { + configMocks.readConfigFileSnapshot.mockResolvedValue({ + ...baseConfigSnapshot, + config: { + channels: { + msteams: { + enabled: true, + tenantId: "tenant-1", + }, + }, + }, + }); + const catalogEntry: ChannelPluginCatalogEntry = { + id: "msteams", + pluginId: "@openclaw/msteams-plugin", + meta: { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams", + docsPath: "/channels/msteams", + blurb: "teams channel", + }, + install: { + npmSpec: "@openclaw/msteams", + }, + }; + catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]); + const scopedPlugin = { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }), + config: { + ...createChannelTestPluginBase({ + id: "msteams", + label: "Microsoft Teams", + docsPath: "/channels/msteams", + }).config, + deleteAccount: vi.fn(({ cfg }: { cfg: Record }) => { + const channels = (cfg.channels as Record | undefined) ?? {}; + const nextChannels = { ...channels }; + delete nextChannels.msteams; + return { + ...cfg, + channels: nextChannels, + }; + }), + }, + }; + vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel) + .mockReturnValueOnce(createTestRegistry()) + .mockReturnValueOnce( + createTestRegistry([ + { + pluginId: "@openclaw/msteams-plugin", + plugin: scopedPlugin, + source: "test", + }, + ]), + ); + + await channelsRemoveCommand( + { + channel: "msteams", + account: "default", + delete: true, + }, + runtime, + { hasFlags: true }, + ); + + expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: catalogEntry, + }), + ); + expect(loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + pluginId: "@openclaw/msteams-plugin", + }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.not.objectContaining({ + channels: expect.objectContaining({ + msteams: expect.anything(), + }), + }), + ); + expect(runtime.error).not.toHaveBeenCalled(); + expect(runtime.exit).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 03aa841edd5..ddddae5ee71 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -350,14 +350,15 @@ export async function channelsAddCommand( await writeConfigFile(nextConfig); runtime.log(`Added ${channelLabel(channel)} account "${accountId}".`); - if (plugin.setup.afterAccountConfigWritten) { + const setup = plugin.setup; + if (setup?.afterAccountConfigWritten) { await runCollectedChannelOnboardingPostWriteHooks({ hooks: [ { channel, accountId, run: async ({ cfg: writtenCfg, runtime: hookRuntime }) => - await plugin.setup.afterAccountConfigWritten?.({ + await setup.afterAccountConfigWritten?.({ previousCfg: cfg, cfg: writtenCfg, accountId, diff --git a/src/commands/channels/remove.ts b/src/commands/channels/remove.ts index 1cd5fded7d3..127dee5a3f9 100644 --- a/src/commands/channels/remove.ts +++ b/src/commands/channels/remove.ts @@ -8,6 +8,7 @@ import { type OpenClawConfig, writeConfigFile } from "../../config/config.js"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js"; import { defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { createClackPrompter } from "../../wizard/clack-prompter.js"; +import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js"; import { type ChatChannel, channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js"; export type ChannelsRemoveOptions = { @@ -29,14 +30,16 @@ export async function channelsRemoveCommand( runtime: RuntimeEnv = defaultRuntime, params?: { hasFlags?: boolean }, ) { - const cfg = await requireValidConfig(runtime); - if (!cfg) { + const loadedCfg = await requireValidConfig(runtime); + if (!loadedCfg) { return; } + let cfg = loadedCfg; const useWizard = shouldUseWizard(params); const prompter = useWizard ? createClackPrompter() : null; - let channel: ChatChannel | null = normalizeChannelId(opts.channel); + const rawChannel = opts.channel?.trim() ?? ""; + let channel: ChatChannel | null = normalizeChannelId(rawChannel); let accountId = normalizeAccountId(opts.account); const deleteConfig = Boolean(opts.delete); @@ -73,15 +76,16 @@ export async function channelsRemoveCommand( return; } } else { - if (!channel) { + if (!rawChannel) { runtime.error("Channel is required. Use --channel ."); runtime.exit(1); return; } if (!deleteConfig) { const confirm = createClackPrompter(); + const channelPromptLabel = channel ? channelLabel(channel) : rawChannel; const ok = await confirm.confirm({ - message: `Disable ${channelLabel(channel)} account "${accountId}"? (keeps config)`, + message: `Disable ${channelPromptLabel} account "${accountId}"? (keeps config)`, initialValue: true, }); if (!ok) { @@ -90,9 +94,28 @@ export async function channelsRemoveCommand( } } - const plugin = getChannelPlugin(channel); + const resolvedPluginState = + !useWizard && rawChannel + ? await resolveInstallableChannelPlugin({ + cfg, + runtime, + rawChannel, + allowInstall: true, + }) + : null; + if (resolvedPluginState?.configChanged) { + cfg = resolvedPluginState.cfg; + } + const resolvedChannel = resolvedPluginState?.channelId ?? channel; + if (!resolvedChannel) { + runtime.error(`Unknown channel: ${rawChannel}`); + runtime.exit(1); + return; + } + channel = resolvedChannel; + const plugin = resolvedPluginState?.plugin ?? getChannelPlugin(resolvedChannel); if (!plugin) { - runtime.error(`Unknown channel: ${channel}`); + runtime.error(`Unknown channel: ${resolvedChannel}`); runtime.exit(1); return; } diff --git a/src/commands/doctor-config-flow.test.ts b/src/commands/doctor-config-flow.test.ts index 39e7b9d00fe..4a461c58267 100644 --- a/src/commands/doctor-config-flow.test.ts +++ b/src/commands/doctor-config-flow.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { resolveMatrixAccountStorageRoot } from "../../extensions/matrix/runtime-api.js"; import { withTempHome } from "../../test/helpers/temp-home.js"; import * as noteModule from "../terminal/note.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; @@ -203,6 +204,250 @@ describe("doctor config flow", () => { ).toBe("existing-session"); }); + it("previews Matrix legacy sync-store migration in read-only mode", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"next_batch":"s1"}', + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true }, + confirm: async () => false, + }); + }); + + const warning = noteSpy.mock.calls.find( + (call) => + call[1] === "Doctor warnings" && + String(call[0]).includes("Matrix plugin upgraded in place."), + ); + expect(warning?.[0]).toContain("Legacy sync store:"); + expect(warning?.[0]).toContain( + 'Run "openclaw doctor --fix" to migrate this Matrix state now.', + ); + } finally { + noteSpy.mockRestore(); + } + }); + + it("previews Matrix encrypted-state migration in read-only mode", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + const { rootDir: accountRoot } = resolveMatrixAccountStorageRoot({ + stateDir, + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }); + await fs.mkdir(path.join(accountRoot, "crypto"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(accountRoot, "crypto", "bot-sdk.json"), + JSON.stringify({ deviceId: "DEVICE123" }), + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true }, + confirm: async () => false, + }); + }); + + const warning = noteSpy.mock.calls.find( + (call) => + call[1] === "Doctor warnings" && + String(call[0]).includes("Matrix encrypted-state migration is pending"), + ); + expect(warning?.[0]).toContain("Legacy crypto store:"); + expect(warning?.[0]).toContain("New recovery key file:"); + } finally { + noteSpy.mockRestore(); + } + }); + + it("migrates Matrix legacy state on doctor repair", async () => { + const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); + try { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile( + path.join(stateDir, "matrix", "bot-storage.json"), + '{"next_batch":"s1"}', + ); + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + + const migratedRoot = path.join( + stateDir, + "matrix", + "accounts", + "default", + "matrix.example.org__bot_example.org", + ); + const migratedChildren = await fs.readdir(migratedRoot); + expect(migratedChildren.length).toBe(1); + expect( + await fs + .access(path.join(migratedRoot, migratedChildren[0] ?? "", "bot-storage.json")) + .then(() => true) + .catch(() => false), + ).toBe(true); + expect( + await fs + .access(path.join(stateDir, "matrix", "bot-storage.json")) + .then(() => true) + .catch(() => false), + ).toBe(false); + }); + + expect( + noteSpy.mock.calls.some( + (call) => + call[1] === "Doctor changes" && + String(call[0]).includes("Matrix plugin upgraded in place."), + ), + ).toBe(true); + } finally { + noteSpy.mockRestore(); + } + }); + + it("creates a Matrix migration snapshot before doctor repair mutates Matrix state", async () => { + await withTempHome(async (home) => { + const stateDir = path.join(home, ".openclaw"); + await fs.mkdir(path.join(stateDir, "matrix"), { recursive: true }); + await fs.writeFile( + path.join(stateDir, "openclaw.json"), + JSON.stringify({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + ); + await fs.writeFile(path.join(stateDir, "matrix", "bot-storage.json"), '{"next_batch":"s1"}'); + + await loadAndMaybeMigrateDoctorConfig({ + options: { nonInteractive: true, repair: true }, + confirm: async () => false, + }); + + const snapshotDir = path.join(home, "Backups", "openclaw-migrations"); + const snapshotEntries = await fs.readdir(snapshotDir); + expect(snapshotEntries.some((entry) => entry.endsWith(".tar.gz"))).toBe(true); + + const marker = JSON.parse( + await fs.readFile(path.join(stateDir, "matrix", "migration-snapshot.json"), "utf8"), + ) as { + archivePath: string; + }; + expect(marker.archivePath).toContain(path.join("Backups", "openclaw-migrations")); + }); + }); + + it("warns when Matrix is installed from a stale custom path", async () => { + const doctorWarnings = await collectDoctorWarnings({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + }, + }, + plugins: { + installs: { + matrix: { + source: "path", + sourcePath: "/tmp/openclaw-matrix-missing", + installPath: "/tmp/openclaw-matrix-missing", + }, + }, + }, + }); + + expect( + doctorWarnings.some( + (line) => line.includes("custom path") && line.includes("/tmp/openclaw-matrix-missing"), + ), + ).toBe(true); + }); + + it("warns when Matrix is installed from an existing custom path", async () => { + await withTempHome(async (home) => { + const pluginPath = path.join(home, "matrix-plugin"); + await fs.mkdir(pluginPath, { recursive: true }); + + const doctorWarnings = await collectDoctorWarnings({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + accessToken: "tok-123", + }, + }, + plugins: { + installs: { + matrix: { + source: "path", + sourcePath: pluginPath, + installPath: pluginPath, + }, + }, + }, + }); + + expect( + doctorWarnings.some((line) => line.includes("Matrix is installed from a custom path")), + ).toBe(true); + expect( + doctorWarnings.some((line) => line.includes("will not automatically replace that plugin")), + ).toBe(true); + }); + }); + it("notes legacy browser extension migration changes", async () => { const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {}); try { diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index ed82ea4473f..e0599eca1bb 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -26,6 +26,23 @@ import { isTrustedSafeBinPath, normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; +import { + autoPrepareLegacyMatrixCrypto, + detectLegacyMatrixCrypto, +} from "../infra/matrix-legacy-crypto.js"; +import { + autoMigrateLegacyMatrixState, + detectLegacyMatrixState, +} from "../infra/matrix-legacy-state.js"; +import { + hasActionableMatrixMigration, + hasPendingMatrixMigration, + maybeCreateMatrixMigrationSnapshot, +} from "../infra/matrix-migration-snapshot.js"; +import { + detectPluginInstallPathIssue, + formatPluginInstallPathIssue, +} from "../infra/plugin-install-path-warnings.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { resolveTelegramAccount } from "../plugin-sdk/account-resolution.js"; import { @@ -312,6 +329,56 @@ function scanTelegramAllowFromUsernameEntries(cfg: OpenClawConfig): TelegramAllo return hits; } +function formatMatrixLegacyStatePreview( + detection: Exclude, null | { warning: string }>, +): string { + return [ + "- Matrix plugin upgraded in place.", + `- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`, + `- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`, + ...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []), + '- Run "openclaw doctor --fix" to migrate this Matrix state now.', + ].join("\n"); +} + +function formatMatrixLegacyCryptoPreview( + detection: ReturnType, +): string[] { + const notes: string[] = []; + for (const warning of detection.warnings) { + notes.push(`- ${warning}`); + } + for (const plan of detection.plans) { + notes.push( + [ + `- Matrix encrypted-state migration is pending for account "${plan.accountId}".`, + `- Legacy crypto store: ${plan.legacyCryptoPath}`, + `- New recovery key file: ${plan.recoveryKeyPath}`, + `- Migration state file: ${plan.statePath}`, + '- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.', + ].join("\n"), + ); + } + return notes; +} + +async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise { + const issue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: cfg.plugins?.installs?.matrix, + }); + if (!issue) { + return []; + } + return formatPluginInstallPathIssue({ + issue, + pluginLabel: "Matrix", + defaultInstallCommand: "openclaw plugins install @openclaw/matrix", + repoInstallCommand: "openclaw plugins install ./extensions/matrix", + formatCommand: formatCliCommand, + }).map((entry) => `- ${entry}`); +} + async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promise<{ config: OpenClawConfig; changes: string[]; @@ -1699,6 +1766,110 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { } } + const matrixLegacyState = detectLegacyMatrixState({ + cfg: candidate, + env: process.env, + }); + const matrixLegacyCrypto = detectLegacyMatrixCrypto({ + cfg: candidate, + env: process.env, + }); + const pendingMatrixMigration = hasPendingMatrixMigration({ + cfg: candidate, + env: process.env, + }); + const actionableMatrixMigration = hasActionableMatrixMigration({ + cfg: candidate, + env: process.env, + }); + if (shouldRepair) { + let matrixSnapshotReady = true; + if (actionableMatrixMigration) { + try { + const snapshot = await maybeCreateMatrixMigrationSnapshot({ + trigger: "doctor-fix", + env: process.env, + }); + note( + `Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`, + "Doctor changes", + ); + } catch (err) { + matrixSnapshotReady = false; + note( + `- Failed creating a Matrix migration snapshot before repair: ${String(err)}`, + "Doctor warnings", + ); + note( + '- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".', + "Doctor warnings", + ); + } + } else if (pendingMatrixMigration) { + note( + "- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.", + "Doctor warnings", + ); + } + if (matrixSnapshotReady) { + const matrixStateRepair = await autoMigrateLegacyMatrixState({ + cfg: candidate, + env: process.env, + }); + if (matrixStateRepair.changes.length > 0) { + note( + [ + "Matrix plugin upgraded in place.", + ...matrixStateRepair.changes.map((entry) => `- ${entry}`), + "- No user action required.", + ].join("\n"), + "Doctor changes", + ); + } + if (matrixStateRepair.warnings.length > 0) { + note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings"); + } + const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({ + cfg: candidate, + env: process.env, + }); + if (matrixCryptoRepair.changes.length > 0) { + note( + [ + "Matrix encrypted-state migration prepared.", + ...matrixCryptoRepair.changes.map((entry) => `- ${entry}`), + ].join("\n"), + "Doctor changes", + ); + } + if (matrixCryptoRepair.warnings.length > 0) { + note( + matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"), + "Doctor warnings", + ); + } + } + } else if (matrixLegacyState) { + if ("warning" in matrixLegacyState) { + note(`- ${matrixLegacyState.warning}`, "Doctor warnings"); + } else { + note(formatMatrixLegacyStatePreview(matrixLegacyState), "Doctor warnings"); + } + } + if ( + !shouldRepair && + (matrixLegacyCrypto.warnings.length > 0 || matrixLegacyCrypto.plans.length > 0) + ) { + for (const preview of formatMatrixLegacyCryptoPreview(matrixLegacyCrypto)) { + note(preview, "Doctor warnings"); + } + } + + const matrixInstallWarnings = await collectMatrixInstallPathWarnings(candidate); + if (matrixInstallWarnings.length > 0) { + note(matrixInstallWarnings.join("\n"), "Doctor warnings"); + } + const missingDefaultAccountBindingWarnings = collectMissingDefaultAccountBindingWarnings(candidate); if (missingDefaultAccountBindingWarnings.length > 0) { diff --git a/src/commands/doctor.e2e-harness.ts b/src/commands/doctor.e2e-harness.ts index 320e8e1258c..32615377773 100644 --- a/src/commands/doctor.e2e-harness.ts +++ b/src/commands/doctor.e2e-harness.ts @@ -110,6 +110,7 @@ export const autoMigrateLegacyStateDir = vi.fn().mockResolvedValue({ changes: [], warnings: [], }) as unknown as MockFn; +export const runStartupMatrixMigration = vi.fn().mockResolvedValue(undefined) as unknown as MockFn; function createLegacyStateMigrationDetectionResult(params?: { hasLegacySessions?: boolean; @@ -299,6 +300,10 @@ vi.mock("./doctor-state-migrations.js", () => ({ runLegacyStateMigrations, })); +vi.mock("../gateway/server-startup-matrix-migration.js", () => ({ + runStartupMatrixMigration, +})); + export function mockDoctorConfigSnapshot( params: { config?: Record; @@ -393,6 +398,7 @@ beforeEach(() => { serviceRestart.mockReset().mockResolvedValue(undefined); serviceUninstall.mockReset().mockResolvedValue(undefined); callGateway.mockReset().mockRejectedValue(new Error("gateway closed")); + runStartupMatrixMigration.mockReset().mockResolvedValue(undefined); originalIsTTY = process.stdin.isTTY; setStdinTty(true); diff --git a/src/commands/doctor.matrix-migration.test.ts b/src/commands/doctor.matrix-migration.test.ts new file mode 100644 index 00000000000..1e7a3572ab2 --- /dev/null +++ b/src/commands/doctor.matrix-migration.test.ts @@ -0,0 +1,70 @@ +import { beforeAll, describe, expect, it, vi } from "vitest"; +import { + createDoctorRuntime, + mockDoctorConfigSnapshot, + runStartupMatrixMigration, +} from "./doctor.e2e-harness.js"; +import "./doctor.fast-path-mocks.js"; + +vi.mock("../plugins/providers.js", () => ({ + resolvePluginProviders: vi.fn(() => []), +})); + +const DOCTOR_MIGRATION_TIMEOUT_MS = process.platform === "win32" ? 60_000 : 45_000; +let doctorCommand: typeof import("./doctor.js").doctorCommand; + +describe("doctor command", () => { + beforeAll(async () => { + ({ doctorCommand } = await import("./doctor.js")); + }); + + it( + "runs Matrix startup migration during repair flows", + { timeout: DOCTOR_MIGRATION_TIMEOUT_MS }, + async () => { + mockDoctorConfigSnapshot({ + config: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }, + parsed: { + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }, + }); + + await doctorCommand(createDoctorRuntime(), { nonInteractive: true, repair: true }); + + expect(runStartupMatrixMigration).toHaveBeenCalledTimes(1); + expect(runStartupMatrixMigration).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }, + }), + trigger: "doctor-fix", + logPrefix: "doctor", + log: expect.objectContaining({ + info: expect.any(Function), + warn: expect.any(Function), + }), + }), + ); + }, + ); +}); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3e4cbebe5d0..252b44efaca 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -17,6 +17,7 @@ import { resolveGatewayService } from "../daemon/service.js"; import { hasAmbiguousGatewayAuthModeConfig } from "../gateway/auth-mode-policy.js"; import { resolveGatewayAuth } from "../gateway/auth.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js"; +import { runStartupMatrixMigration } from "../gateway/server-startup-matrix-migration.js"; import { resolveOpenClawPackageRoot } from "../infra/openclaw-root.js"; import type { RuntimeEnv } from "../runtime.js"; import { defaultRuntime } from "../runtime.js"; @@ -236,6 +237,19 @@ export async function doctorCommand( await noteMacLaunchAgentOverrides(); await noteMacLaunchctlGatewayEnvOverrides(cfg); + if (prompter.shouldRepair) { + await runStartupMatrixMigration({ + cfg, + env: process.env, + log: { + info: (message) => runtime.log(message), + warn: (message) => runtime.error(message), + }, + trigger: "doctor-fix", + logPrefix: "doctor", + }); + } + await noteSecurityWarnings(cfg); await noteChromeMcpBrowserReadiness(cfg); await noteOpenAIOAuthTlsPrerequisites({ diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index 2b115ec67b6..2177791bce1 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -1,4 +1,3 @@ -import type { DiscordPluralKitConfig } from "openclaw/plugin-sdk/discord"; import type { BlockStreamingChunkConfig, BlockStreamingCoalesceConfig, @@ -19,6 +18,11 @@ import type { TtsConfig } from "./types.tts.js"; export type DiscordStreamMode = "off" | "partial" | "block" | "progress"; +export type DiscordPluralKitConfig = { + enabled?: boolean; + token?: string; +}; + export type DiscordDmConfig = { /** If false, ignore all incoming Discord DMs. Default: true. */ enabled?: boolean; diff --git a/src/gateway/server-startup-matrix-migration.ts b/src/gateway/server-startup-matrix-migration.ts index 64a5f4e0721..0db6bc5be59 100644 --- a/src/gateway/server-startup-matrix-migration.ts +++ b/src/gateway/server-startup-matrix-migration.ts @@ -15,13 +15,14 @@ type MatrixMigrationLogger = { async function runBestEffortMatrixMigrationStep(params: { label: string; log: MatrixMigrationLogger; + logPrefix?: string; run: () => Promise; }): Promise { try { await params.run(); } catch (err) { params.log.warn?.( - `gateway: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`, + `${params.logPrefix?.trim() || "gateway"}: ${params.label} failed during Matrix migration; continuing startup: ${String(err)}`, ); } } @@ -30,6 +31,8 @@ export async function runStartupMatrixMigration(params: { cfg: OpenClawConfig; env?: NodeJS.ProcessEnv; log: MatrixMigrationLogger; + trigger?: string; + logPrefix?: string; deps?: { maybeCreateMatrixMigrationSnapshot?: typeof maybeCreateMatrixMigrationSnapshot; autoMigrateLegacyMatrixState?: typeof autoMigrateLegacyMatrixState; @@ -43,6 +46,8 @@ export async function runStartupMatrixMigration(params: { params.deps?.autoMigrateLegacyMatrixState ?? autoMigrateLegacyMatrixState; const prepareLegacyCrypto = params.deps?.autoPrepareLegacyMatrixCrypto ?? autoPrepareLegacyMatrixCrypto; + const trigger = params.trigger?.trim() || "gateway-startup"; + const logPrefix = params.logPrefix?.trim() || "gateway"; const actionable = hasActionableMatrixMigration({ cfg: params.cfg, env }); const pending = actionable || hasPendingMatrixMigration({ cfg: params.cfg, env }); @@ -58,13 +63,13 @@ export async function runStartupMatrixMigration(params: { try { await createSnapshot({ - trigger: "gateway-startup", + trigger, env, log: params.log, }); } catch (err) { params.log.warn?.( - `gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`, + `${logPrefix}: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ${String(err)}`, ); return; } @@ -72,6 +77,7 @@ export async function runStartupMatrixMigration(params: { await runBestEffortMatrixMigrationStep({ label: "legacy Matrix state migration", log: params.log, + logPrefix, run: () => migrateLegacyState({ cfg: params.cfg, @@ -82,6 +88,7 @@ export async function runStartupMatrixMigration(params: { await runBestEffortMatrixMigrationStep({ label: "legacy Matrix encrypted-state preparation", log: params.log, + logPrefix, run: () => prepareLegacyCrypto({ cfg: params.cfg, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index af8d1c18759..7a4c18b6593 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -36,6 +36,10 @@ import { onHeartbeatEvent } from "../infra/heartbeat-events.js"; import { startHeartbeatRunner, type HeartbeatRunner } from "../infra/heartbeat-runner.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { ensureOpenClawCliOnPath } from "../infra/path-env.js"; +import { + detectPluginInstallPathIssue, + formatPluginInstallPathIssue, +} from "../infra/plugin-install-path-warnings.js"; import { setGatewaySigusr1RestartPolicy, setPreRestartDeferralCheck } from "../infra/restart.js"; import { primeRemoteSkillsCache, @@ -105,6 +109,7 @@ import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js"; import { createGatewayRuntimeState } from "./server-runtime-state.js"; import { resolveSessionKeyForRun } from "./server-session-key.js"; import { logGatewayStartup } from "./server-startup-log.js"; +import { runStartupMatrixMigration } from "./server-startup-matrix-migration.js"; import { startGatewaySidecars } from "./server-startup.js"; import { startGatewayTailscaleExposure } from "./server-tailscale.js"; import { createWizardSessionTracker } from "./server-wizard-sessions.js"; @@ -519,6 +524,27 @@ export async function startGatewayServer( writeConfig: writeConfigFile, log, }); + await runStartupMatrixMigration({ + cfg: cfgAtStart, + env: process.env, + log, + }); + const matrixInstallPathIssue = await detectPluginInstallPathIssue({ + pluginId: "matrix", + install: cfgAtStart.plugins?.installs?.matrix, + }); + if (matrixInstallPathIssue) { + const lines = formatPluginInstallPathIssue({ + issue: matrixInstallPathIssue, + pluginLabel: "Matrix", + defaultInstallCommand: "openclaw plugins install @openclaw/matrix", + repoInstallCommand: "openclaw plugins install ./extensions/matrix", + formatCommand: formatCliCommand, + }); + log.warn( + `gateway: matrix install path warning:\n${lines.map((entry) => `- ${entry}`).join("\n")}`, + ); + } initSubagentRegistry(); const defaultAgentId = resolveDefaultAgentId(cfgAtStart); diff --git a/src/gateway/server.startup-matrix-migration.integration.test.ts b/src/gateway/server.startup-matrix-migration.integration.test.ts new file mode 100644 index 00000000000..3757a311ff3 --- /dev/null +++ b/src/gateway/server.startup-matrix-migration.integration.test.ts @@ -0,0 +1,54 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +const runStartupMatrixMigrationMock = vi.fn().mockResolvedValue(undefined); + +vi.mock("./server-startup-matrix-migration.js", () => ({ + runStartupMatrixMigration: runStartupMatrixMigrationMock, +})); + +import { + getFreePort, + installGatewayTestHooks, + startGatewayServer, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway startup Matrix migration wiring", () => { + let server: Awaited> | undefined; + + beforeAll(async () => { + testState.channelsConfig = { + matrix: { + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }, + }; + server = await startGatewayServer(await getFreePort()); + }); + + afterAll(async () => { + await server?.close(); + }); + + it("runs startup Matrix migration with the resolved startup config", () => { + expect(runStartupMatrixMigrationMock).toHaveBeenCalledTimes(1); + expect(runStartupMatrixMigrationMock).toHaveBeenCalledWith( + expect.objectContaining({ + cfg: expect.objectContaining({ + channels: expect.objectContaining({ + matrix: expect.objectContaining({ + homeserver: "https://matrix.example.org", + userId: "@bot:example.org", + accessToken: "tok-123", + }), + }), + }), + env: process.env, + log: expect.anything(), + }), + ); + }); +}); diff --git a/src/infra/matrix-plugin-helper.test.ts b/src/infra/matrix-plugin-helper.test.ts index 650edc434ca..ae71aca0bc8 100644 --- a/src/infra/matrix-plugin-helper.test.ts +++ b/src/infra/matrix-plugin-helper.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { withTempHome } from "../../test/helpers/temp-home.js"; +import type { OpenClawConfig } from "../config/config.js"; import { isMatrixLegacyCryptoInspectorAvailable, loadMatrixLegacyCryptoInspector, @@ -89,13 +90,13 @@ describe("matrix plugin helper resolution", () => { ].join("\n"), ); - const cfg = { + const cfg: OpenClawConfig = { plugins: { load: { paths: [customRoot], }, }, - } as const; + }; expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(true); const inspectLegacyStore = await loadMatrixLegacyCryptoInspector({ @@ -160,13 +161,13 @@ describe("matrix plugin helper resolution", () => { return; } - const cfg = { + const cfg: OpenClawConfig = { plugins: { load: { paths: [customRoot], }, }, - } as const; + }; expect(isMatrixLegacyCryptoInspectorAvailable({ cfg, env: process.env })).toBe(false); await expect( diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index a71bc35b6fb..f5149e715ef 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -56,6 +56,7 @@ export const MESSAGE_ACTION_TARGET_MODE: Record = { 'export { sendMessageIMessage } from "./src/send.js";', ], "extensions/googlechat/runtime-api.ts": ['export * from "openclaw/plugin-sdk/googlechat";'], + "extensions/matrix/runtime-api.ts": [ + 'export * from "openclaw/plugin-sdk/matrix";', + 'export * from "./src/auth-precedence.js";', + 'export { findMatrixAccountEntry, hashMatrixAccessToken, listMatrixEnvAccountIds, resolveConfiguredMatrixAccountIds, resolveMatrixChannelConfig, resolveMatrixCredentialsFilename, resolveMatrixEnvAccountToken, resolveMatrixHomeserverKey, resolveMatrixLegacyFlatStoreRoot, sanitizeMatrixPathSegment } from "./helper-api.js";', + ], "extensions/nextcloud-talk/runtime-api.ts": [ 'export * from "openclaw/plugin-sdk/nextcloud-talk";', ], diff --git a/src/plugins/commands.test.ts b/src/plugins/commands.test.ts index c1c482e2bd2..9f10ae7fe81 100644 --- a/src/plugins/commands.test.ts +++ b/src/plugins/commands.test.ts @@ -12,6 +12,14 @@ import { } from "./commands.js"; import { setActivePluginRegistry } from "./runtime.js"; +type CommandsModule = typeof import("./commands.js"); + +const commandsModuleUrl = new URL("./commands.ts", import.meta.url).href; + +async function importCommandsModule(cacheBust: string): Promise { + return (await import(`${commandsModuleUrl}?t=${cacheBust}`)) as CommandsModule; +} + beforeEach(() => { setActivePluginRegistry( createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), @@ -108,6 +116,40 @@ describe("registerPluginCommand", () => { expect(getPluginCommandSpecs("slack")).toEqual([]); }); + it("shares plugin commands across duplicate module instances", async () => { + const first = await importCommandsModule(`first-${Date.now()}`); + const second = await importCommandsModule(`second-${Date.now()}`); + + first.clearPluginCommands(); + + expect( + first.registerPluginCommand("demo-plugin", { + name: "voice", + nativeNames: { + telegram: "voice", + }, + description: "Voice command", + handler: async () => ({ text: "ok" }), + }), + ).toEqual({ ok: true }); + + expect(second.getPluginCommandSpecs("telegram")).toEqual([ + { + name: "voice", + description: "Voice command", + acceptsArgs: false, + }, + ]); + expect(second.matchPluginCommand("/voice")).toMatchObject({ + command: expect.objectContaining({ + name: "voice", + pluginId: "demo-plugin", + }), + }); + + second.clearPluginCommands(); + }); + it("matches provider-specific native aliases back to the canonical command", () => { const result = registerPluginCommand("demo-plugin", { name: "voice", diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index a44cbc26e7e..8137ebbed1b 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -8,6 +8,7 @@ import { parseExplicitTargetForChannel } from "../channels/plugins/target-parsing.js"; import type { OpenClawConfig } from "../config/config.js"; import { logVerbose } from "../globals.js"; +import { resolveGlobalSingleton } from "../shared/global-singleton.js"; import { detachPluginConversationBinding, getCurrentPluginConversationBinding, @@ -25,11 +26,19 @@ type RegisteredPluginCommand = OpenClawPluginCommandDefinition & { pluginRoot?: string; }; -// Registry of plugin commands -const pluginCommands: Map = new Map(); +type PluginCommandState = { + pluginCommands: Map; + registryLocked: boolean; +}; -// Lock to prevent modifications during command execution -let registryLocked = false; +const PLUGIN_COMMAND_STATE_KEY = Symbol.for("openclaw.pluginCommandsState"); + +const state = resolveGlobalSingleton(PLUGIN_COMMAND_STATE_KEY, () => ({ + pluginCommands: new Map(), + registryLocked: false, +})); + +const pluginCommands = state.pluginCommands; // Maximum allowed length for command arguments (defense in depth) const MAX_ARGS_LENGTH = 4096; @@ -172,7 +181,7 @@ export function registerPluginCommand( opts?: { pluginName?: string; pluginRoot?: string }, ): CommandRegistrationResult { // Prevent registration while commands are being processed - if (registryLocked) { + if (state.registryLocked) { return { ok: false, error: "Cannot register commands while processing is in progress" }; } @@ -451,7 +460,7 @@ export async function executePluginCommand(params: { }; // Lock registry during execution to prevent concurrent modifications - registryLocked = true; + state.registryLocked = true; try { const result = await command.handler(ctx); logVerbose( @@ -464,7 +473,7 @@ export async function executePluginCommand(params: { // Don't leak internal error details - return a safe generic message return { text: "⚠️ Command failed. Please try again later." }; } finally { - registryLocked = false; + state.registryLocked = false; } } diff --git a/src/plugins/runtime/runtime-channel.ts b/src/plugins/runtime/runtime-channel.ts index 80bb1aba736..0617cb7f8ff 100644 --- a/src/plugins/runtime/runtime-channel.ts +++ b/src/plugins/runtime/runtime-channel.ts @@ -78,6 +78,7 @@ import { import { buildAgentSessionKey, resolveAgentRoute } from "../../routing/resolve-route.js"; import { createRuntimeDiscord } from "./runtime-discord.js"; import { createRuntimeIMessage } from "./runtime-imessage.js"; +import { createRuntimeMatrix } from "./runtime-matrix.js"; import { createRuntimeSignal } from "./runtime-signal.js"; import { createRuntimeSlack } from "./runtime-slack.js"; import { createRuntimeTelegram } from "./runtime-telegram.js"; @@ -206,18 +207,19 @@ export function createRuntimeChannel(): PluginRuntime["channel"] { }, } satisfies Omit< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > & Partial< Pick< PluginRuntime["channel"], - "discord" | "slack" | "telegram" | "signal" | "imessage" | "whatsapp" + "discord" | "slack" | "telegram" | "matrix" | "signal" | "imessage" | "whatsapp" > >; defineCachedValue(channelRuntime, "discord", createRuntimeDiscord); defineCachedValue(channelRuntime, "slack", createRuntimeSlack); defineCachedValue(channelRuntime, "telegram", createRuntimeTelegram); + defineCachedValue(channelRuntime, "matrix", createRuntimeMatrix); defineCachedValue(channelRuntime, "signal", createRuntimeSignal); defineCachedValue(channelRuntime, "imessage", createRuntimeIMessage); defineCachedValue(channelRuntime, "whatsapp", createRuntimeWhatsApp); diff --git a/src/plugins/runtime/runtime-matrix.ts b/src/plugins/runtime/runtime-matrix.ts new file mode 100644 index 00000000000..d97734397c0 --- /dev/null +++ b/src/plugins/runtime/runtime-matrix.ts @@ -0,0 +1,14 @@ +import { + setMatrixThreadBindingIdleTimeoutBySessionKey, + setMatrixThreadBindingMaxAgeBySessionKey, +} from "openclaw/plugin-sdk/matrix"; +import type { PluginRuntimeChannel } from "./types-channel.js"; + +export function createRuntimeMatrix(): PluginRuntimeChannel["matrix"] { + return { + threadBindings: { + setIdleTimeoutBySessionKey: setMatrixThreadBindingIdleTimeoutBySessionKey, + setMaxAgeBySessionKey: setMatrixThreadBindingMaxAgeBySessionKey, + }, + }; +} diff --git a/src/plugins/runtime/types-channel.ts b/src/plugins/runtime/types-channel.ts index a0fe9a1d9bc..0a7eab63727 100644 --- a/src/plugins/runtime/types-channel.ts +++ b/src/plugins/runtime/types-channel.ts @@ -193,6 +193,12 @@ export type PluginRuntimeChannel = { unpinMessage: typeof import("../../../extensions/telegram/runtime-api.js").unpinMessageTelegram; }; }; + matrix: { + threadBindings: { + setIdleTimeoutBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingIdleTimeoutBySessionKey; + setMaxAgeBySessionKey: typeof import("../../../extensions/matrix/runtime-api.js").setMatrixThreadBindingMaxAgeBySessionKey; + }; + }; signal: { probeSignal: typeof import("../../../extensions/signal/runtime-api.js").probeSignal; sendMessageSignal: typeof import("../../../extensions/signal/runtime-api.js").sendMessageSignal; diff --git a/test/helpers/extensions/matrix-route-test.ts b/test/helpers/extensions/matrix-route-test.ts new file mode 100644 index 00000000000..2eb0f7ccf09 --- /dev/null +++ b/test/helpers/extensions/matrix-route-test.ts @@ -0,0 +1,8 @@ +export type { OpenClawConfig } from "../../../src/config/config.js"; +export { + __testing as sessionBindingTesting, + registerSessionBindingAdapter, +} from "../../../src/infra/outbound/session-binding-service.js"; +export { setActivePluginRegistry } from "../../../src/plugins/runtime.js"; +export { resolveAgentRoute } from "../../../src/routing/resolve-route.js"; +export { createTestRegistry } from "../../../src/test-utils/channel-plugins.js"; diff --git a/test/helpers/extensions/plugin-runtime-mock.ts b/test/helpers/extensions/plugin-runtime-mock.ts index d71eeb2d584..c0b73a6e15d 100644 --- a/test/helpers/extensions/plugin-runtime-mock.ts +++ b/test/helpers/extensions/plugin-runtime-mock.ts @@ -297,6 +297,7 @@ export function createPluginRuntimeMock(overrides: DeepPartial = line: {} as PluginRuntime["channel"]["line"], slack: {} as PluginRuntime["channel"]["slack"], telegram: {} as PluginRuntime["channel"]["telegram"], + matrix: {} as PluginRuntime["channel"]["matrix"], signal: {} as PluginRuntime["channel"]["signal"], imessage: {} as PluginRuntime["channel"]["imessage"], whatsapp: {} as PluginRuntime["channel"]["whatsapp"], diff --git a/test/openclaw-launcher.e2e.test.ts b/test/openclaw-launcher.e2e.test.ts index ab9400da5db..53a6d14d8d4 100644 --- a/test/openclaw-launcher.e2e.test.ts +++ b/test/openclaw-launcher.e2e.test.ts @@ -15,6 +15,11 @@ async function makeLauncherFixture(fixtureRoots: string[]): Promise { return fixtureRoot; } +async function addSourceTreeMarker(fixtureRoot: string): Promise { + await fs.mkdir(path.join(fixtureRoot, "src"), { recursive: true }); + await fs.writeFile(path.join(fixtureRoot, "src", "entry.ts"), "export {};\n", "utf8"); +} + describe("openclaw launcher", () => { const fixtureRoots: string[] = []; @@ -55,4 +60,20 @@ describe("openclaw launcher", () => { expect(result.status).not.toBe(0); expect(result.stderr).toContain("missing dist/entry.(m)js"); }); + + it("explains how to recover from an unbuilt source install", async () => { + const fixtureRoot = await makeLauncherFixture(fixtureRoots); + await addSourceTreeMarker(fixtureRoot); + + const result = spawnSync(process.execPath, [path.join(fixtureRoot, "openclaw.mjs"), "--help"], { + cwd: fixtureRoot, + encoding: "utf8", + }); + + expect(result.status).not.toBe(0); + expect(result.stderr).toContain("missing dist/entry.(m)js"); + expect(result.stderr).toContain("unbuilt source tree or GitHub source archive"); + expect(result.stderr).toContain("pnpm install && pnpm build"); + expect(result.stderr).toContain("github:openclaw/openclaw#"); + }); });