Merge branch 'main' into docs/delegation-invariants-draft
This commit is contained in:
commit
2a617a34d8
18
.github/workflows/install-smoke.yml
vendored
18
.github/workflows/install-smoke.yml
vendored
@ -62,9 +62,9 @@ jobs:
|
||||
run: |
|
||||
docker run --rm --entrypoint sh openclaw-dockerfile-smoke:local -lc 'which openclaw && openclaw --version'
|
||||
|
||||
# This smoke validates that the build-arg path preinstalls selected
|
||||
# extension deps and that matrix plugin discovery stays healthy in the
|
||||
# final runtime image.
|
||||
# This smoke validates that the build-arg path preinstalls the matrix
|
||||
# runtime deps declared by the plugin and that matrix discovery stays
|
||||
# healthy in the final runtime image.
|
||||
- name: Build extension Dockerfile smoke image
|
||||
uses: useblacksmith/build-push-action@v2
|
||||
with:
|
||||
@ -84,9 +84,17 @@ jobs:
|
||||
openclaw --version &&
|
||||
node -e "
|
||||
const Module = require(\"node:module\");
|
||||
const matrixPackage = require(\"/app/extensions/matrix/package.json\");
|
||||
const requireFromMatrix = Module.createRequire(\"/app/extensions/matrix/package.json\");
|
||||
requireFromMatrix.resolve(\"@vector-im/matrix-bot-sdk/package.json\");
|
||||
requireFromMatrix.resolve(\"@matrix-org/matrix-sdk-crypto-nodejs/package.json\");
|
||||
const runtimeDeps = Object.keys(matrixPackage.dependencies ?? {});
|
||||
if (runtimeDeps.length === 0) {
|
||||
throw new Error(
|
||||
\"matrix package has no declared runtime dependencies; smoke cannot validate install mirroring\",
|
||||
);
|
||||
}
|
||||
for (const dep of runtimeDeps) {
|
||||
requireFromMatrix.resolve(dep);
|
||||
}
|
||||
const { spawnSync } = require(\"node:child_process\");
|
||||
const run = spawnSync(\"openclaw\", [\"plugins\", \"list\", \"--json\"], { encoding: \"utf8\" });
|
||||
if (run.status !== 0) {
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -568,43 +568,8 @@ class NodeRuntime(
|
||||
|
||||
scope.launch(Dispatchers.Default) {
|
||||
gateways.collect { list ->
|
||||
if (list.isNotEmpty()) {
|
||||
// Security: don't let an unauthenticated discovery feed continuously steer autoconnect.
|
||||
// UX parity with iOS: only set once when unset.
|
||||
if (lastDiscoveredStableId.value.trim().isEmpty()) {
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
}
|
||||
|
||||
if (didAutoConnect) return@collect
|
||||
if (_isConnected.value) return@collect
|
||||
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isNotEmpty() && port in 1..65535) {
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
if (!manualTls.value) return@collect
|
||||
val stableId = GatewayEndpoint.manual(host = host, port = port).stableId
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(GatewayEndpoint.manual(host = host, port = port))
|
||||
}
|
||||
return@collect
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return@collect
|
||||
val target = list.firstOrNull { it.stableId == targetStableId } ?: return@collect
|
||||
|
||||
// Security: autoconnect only to previously trusted gateways (stored TLS pin).
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(target.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return@collect
|
||||
|
||||
didAutoConnect = true
|
||||
connect(target)
|
||||
seedLastDiscoveredGateway(list)
|
||||
autoConnectIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
@ -629,11 +594,53 @@ class NodeRuntime(
|
||||
|
||||
fun setForeground(value: Boolean) {
|
||||
_isForeground.value = value
|
||||
if (!value) {
|
||||
if (value) {
|
||||
reconnectPreferredGatewayOnForeground()
|
||||
} else {
|
||||
stopActiveVoiceSession()
|
||||
}
|
||||
}
|
||||
|
||||
private fun seedLastDiscoveredGateway(list: List<GatewayEndpoint>) {
|
||||
if (list.isEmpty()) return
|
||||
if (lastDiscoveredStableId.value.trim().isNotEmpty()) return
|
||||
prefs.setLastDiscoveredStableId(list.first().stableId)
|
||||
}
|
||||
|
||||
private fun resolvePreferredGatewayEndpoint(): GatewayEndpoint? {
|
||||
if (manualEnabled.value) {
|
||||
val host = manualHost.value.trim()
|
||||
val port = manualPort.value
|
||||
if (host.isEmpty() || port !in 1..65535) return null
|
||||
return GatewayEndpoint.manual(host = host, port = port)
|
||||
}
|
||||
|
||||
val targetStableId = lastDiscoveredStableId.value.trim()
|
||||
if (targetStableId.isEmpty()) return null
|
||||
val endpoint = gateways.value.firstOrNull { it.stableId == targetStableId } ?: return null
|
||||
val storedFingerprint = prefs.loadGatewayTlsFingerprint(endpoint.stableId)?.trim().orEmpty()
|
||||
if (storedFingerprint.isEmpty()) return null
|
||||
return endpoint
|
||||
}
|
||||
|
||||
private fun autoConnectIfNeeded() {
|
||||
if (didAutoConnect) return
|
||||
if (_isConnected.value) return
|
||||
val endpoint = resolvePreferredGatewayEndpoint() ?: return
|
||||
didAutoConnect = true
|
||||
connect(endpoint)
|
||||
}
|
||||
|
||||
private fun reconnectPreferredGatewayOnForeground() {
|
||||
if (_isConnected.value) return
|
||||
if (_pendingGatewayTrust.value != null) return
|
||||
if (connectedEndpoint != null) {
|
||||
refreshGatewayConnection()
|
||||
return
|
||||
}
|
||||
resolvePreferredGatewayEndpoint()?.let(::connect)
|
||||
}
|
||||
|
||||
fun setDisplayName(value: String) {
|
||||
prefs.setDisplayName(value)
|
||||
}
|
||||
|
||||
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
430
apps/android/scripts/perf-online-benchmark.sh
Executable file
@ -0,0 +1,430 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ANDROID_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
|
||||
RESULTS_DIR="$ANDROID_DIR/benchmark/results"
|
||||
|
||||
PACKAGE="ai.openclaw.app"
|
||||
ACTIVITY=".MainActivity"
|
||||
DEVICE_SERIAL=""
|
||||
INSTALL_APP="1"
|
||||
LAUNCH_RUNS="4"
|
||||
SCREEN_LOOPS="6"
|
||||
CHAT_LOOPS="8"
|
||||
POLL_ATTEMPTS="40"
|
||||
POLL_INTERVAL_SECONDS="0.3"
|
||||
SCREEN_MODE="transition"
|
||||
CHAT_MODE="session-switch"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./scripts/perf-online-benchmark.sh [options]
|
||||
|
||||
Measures the fully-online Android app path on a connected device/emulator.
|
||||
Assumes the app can reach a live gateway and will show "Connected" in the UI.
|
||||
|
||||
Options:
|
||||
--device <serial> adb device serial
|
||||
--package <pkg> package name (default: ai.openclaw.app)
|
||||
--activity <activity> launch activity (default: .MainActivity)
|
||||
--skip-install skip :app:installDebug
|
||||
--launch-runs <n> launch-to-connected runs (default: 4)
|
||||
--screen-loops <n> screen benchmark loops (default: 6)
|
||||
--chat-loops <n> chat benchmark loops (default: 8)
|
||||
--screen-mode <mode> transition | scroll (default: transition)
|
||||
--chat-mode <mode> session-switch | scroll (default: session-switch)
|
||||
-h, --help show help
|
||||
EOF
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--device)
|
||||
DEVICE_SERIAL="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--package)
|
||||
PACKAGE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--activity)
|
||||
ACTIVITY="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--skip-install)
|
||||
INSTALL_APP="0"
|
||||
shift
|
||||
;;
|
||||
--launch-runs)
|
||||
LAUNCH_RUNS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-loops)
|
||||
SCREEN_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-loops)
|
||||
CHAT_LOOPS="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--screen-mode)
|
||||
SCREEN_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
--chat-mode)
|
||||
CHAT_MODE="${2:-}"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $1" >&2
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
require_cmd() {
|
||||
if ! command -v "$1" >/dev/null 2>&1; then
|
||||
echo "$1 required but missing." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd adb
|
||||
require_cmd awk
|
||||
require_cmd rg
|
||||
require_cmd node
|
||||
|
||||
adb_cmd() {
|
||||
if [[ -n "$DEVICE_SERIAL" ]]; then
|
||||
adb -s "$DEVICE_SERIAL" "$@"
|
||||
else
|
||||
adb "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
device_count="$(adb devices | awk 'NR>1 && $2=="device" {c+=1} END {print c+0}')"
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -lt 1 ]]; then
|
||||
echo "No connected Android device (adb state=device)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$DEVICE_SERIAL" && "$device_count" -gt 1 ]]; then
|
||||
echo "Multiple adb devices found. Pass --device <serial>." >&2
|
||||
adb devices -l >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$SCREEN_MODE" != "transition" && "$SCREEN_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --screen-mode: $SCREEN_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if [[ "$CHAT_MODE" != "session-switch" && "$CHAT_MODE" != "scroll" ]]; then
|
||||
echo "Unsupported --chat-mode: $CHAT_MODE" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
mkdir -p "$RESULTS_DIR"
|
||||
|
||||
timestamp="$(date +%Y%m%d-%H%M%S)"
|
||||
run_dir="$RESULTS_DIR/online-$timestamp"
|
||||
mkdir -p "$run_dir"
|
||||
|
||||
cleanup() {
|
||||
rm -f "$run_dir"/ui-*.xml
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
if [[ "$INSTALL_APP" == "1" ]]; then
|
||||
(
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew :app:installDebug --console=plain >"$run_dir/install.log" 2>&1
|
||||
)
|
||||
fi
|
||||
|
||||
read -r display_width display_height <<<"$(
|
||||
adb_cmd shell wm size \
|
||||
| awk '/Physical size:/ { split($3, dims, "x"); print dims[1], dims[2]; exit }'
|
||||
)"
|
||||
|
||||
if [[ -z "${display_width:-}" || -z "${display_height:-}" ]]; then
|
||||
echo "Failed to read device display size." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pct_of() {
|
||||
local total="$1"
|
||||
local pct="$2"
|
||||
awk -v total="$total" -v pct="$pct" 'BEGIN { printf "%d", total * pct }'
|
||||
}
|
||||
|
||||
tab_connect_x="$(pct_of "$display_width" "0.11")"
|
||||
tab_chat_x="$(pct_of "$display_width" "0.31")"
|
||||
tab_screen_x="$(pct_of "$display_width" "0.69")"
|
||||
tab_y="$(pct_of "$display_height" "0.93")"
|
||||
chat_session_y="$(pct_of "$display_height" "0.13")"
|
||||
chat_session_left_x="$(pct_of "$display_width" "0.16")"
|
||||
chat_session_right_x="$(pct_of "$display_width" "0.85")"
|
||||
center_x="$(pct_of "$display_width" "0.50")"
|
||||
screen_swipe_top_y="$(pct_of "$display_height" "0.27")"
|
||||
screen_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
screen_swipe_low_y="$(pct_of "$display_height" "0.75")"
|
||||
screen_swipe_bottom_y="$(pct_of "$display_height" "0.77")"
|
||||
chat_swipe_top_y="$(pct_of "$display_height" "0.29")"
|
||||
chat_swipe_mid_y="$(pct_of "$display_height" "0.38")"
|
||||
chat_swipe_bottom_y="$(pct_of "$display_height" "0.71")"
|
||||
|
||||
dump_ui() {
|
||||
local name="$1"
|
||||
local file="$run_dir/ui-$name.xml"
|
||||
adb_cmd shell uiautomator dump "/sdcard/$name.xml" >/dev/null 2>&1
|
||||
adb_cmd shell cat "/sdcard/$name.xml" >"$file"
|
||||
printf '%s\n' "$file"
|
||||
}
|
||||
|
||||
ui_has() {
|
||||
local pattern="$1"
|
||||
local name="$2"
|
||||
local file
|
||||
file="$(dump_ui "$name")"
|
||||
rg -q "$pattern" "$file"
|
||||
}
|
||||
|
||||
wait_for_pattern() {
|
||||
local pattern="$1"
|
||||
local prefix="$2"
|
||||
for attempt in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has "$pattern" "$prefix-$attempt"; then
|
||||
return 0
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
ensure_connected() {
|
||||
if ! wait_for_pattern 'text="Connected"' "connected"; then
|
||||
echo "App never reached visible Connected state." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_screen_online() {
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'android\.webkit\.WebView' "screen"; then
|
||||
echo "Screen benchmark expected a live WebView." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_chat_online() {
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 2
|
||||
if ! ui_has 'Type a message' "chat"; then
|
||||
echo "Chat benchmark expected the live chat composer." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
capture_mem() {
|
||||
local file="$1"
|
||||
adb_cmd shell dumpsys meminfo "$PACKAGE" >"$file"
|
||||
}
|
||||
|
||||
start_cpu_sampler() {
|
||||
local file="$1"
|
||||
local samples="$2"
|
||||
: >"$file"
|
||||
(
|
||||
for _ in $(seq 1 "$samples"); do
|
||||
adb_cmd shell top -b -n 1 \
|
||||
| awk -v pkg="$PACKAGE" '$NF==pkg { print $9 }' >>"$file"
|
||||
sleep 0.5
|
||||
done
|
||||
) &
|
||||
CPU_SAMPLER_PID="$!"
|
||||
}
|
||||
|
||||
summarize_cpu() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
local avg max median count
|
||||
avg="$(awk '{sum+=$1; n++} END {if(n) printf "%.1f", sum/n; else print 0}' "$file")"
|
||||
max="$(sort -n "$file" | tail -n 1)"
|
||||
median="$(
|
||||
sort -n "$file" \
|
||||
| awk '{a[NR]=$1} END { if (NR==0) { print 0 } else if (NR%2==1) { printf "%.1f", a[(NR+1)/2] } else { printf "%.1f", (a[NR/2]+a[NR/2+1])/2 } }'
|
||||
)"
|
||||
count="$(wc -l <"$file" | tr -d ' ')"
|
||||
printf '%s.cpu_avg_pct=%s\n' "$prefix" "$avg" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_median_pct=%s\n' "$prefix" "$median" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_peak_pct=%s\n' "$prefix" "$max" >>"$run_dir/summary.txt"
|
||||
printf '%s.cpu_count=%s\n' "$prefix" "$count" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_mem() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/TOTAL PSS:/ { printf "%s.pss_kb=%s\n%s.rss_kb=%s\n", prefix, $3, prefix, $6 }
|
||||
/Graphics:/ { printf "%s.graphics_kb=%s\n", prefix, $2 }
|
||||
/WebViews:/ { printf "%s.webviews=%s\n", prefix, $NF }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
summarize_gfx() {
|
||||
local file="$1"
|
||||
local prefix="$2"
|
||||
awk -v prefix="$prefix" '
|
||||
/Total frames rendered:/ { printf "%s.frames=%s\n", prefix, $4 }
|
||||
/Janky frames:/ && $4 ~ /\(/ {
|
||||
pct=$4
|
||||
gsub(/[()%]/, "", pct)
|
||||
printf "%s.janky_frames=%s\n%s.janky_pct=%s\n", prefix, $3, prefix, pct
|
||||
}
|
||||
/50th percentile:/ { gsub(/ms/, "", $3); printf "%s.p50_ms=%s\n", prefix, $3 }
|
||||
/90th percentile:/ { gsub(/ms/, "", $3); printf "%s.p90_ms=%s\n", prefix, $3 }
|
||||
/95th percentile:/ { gsub(/ms/, "", $3); printf "%s.p95_ms=%s\n", prefix, $3 }
|
||||
/99th percentile:/ { gsub(/ms/, "", $3); printf "%s.p99_ms=%s\n", prefix, $3 }
|
||||
' "$file" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
measure_launch() {
|
||||
: >"$run_dir/launch-runs.txt"
|
||||
for run in $(seq 1 "$LAUNCH_RUNS"); do
|
||||
adb_cmd shell am force-stop "$PACKAGE" >/dev/null
|
||||
sleep 1
|
||||
start_ms="$(node -e 'console.log(Date.now())')"
|
||||
am_out="$(adb_cmd shell am start -W -n "$PACKAGE/$ACTIVITY")"
|
||||
total_time="$(printf '%s\n' "$am_out" | awk -F: '/TotalTime:/{gsub(/ /, "", $2); print $2}')"
|
||||
connected_ms="timeout"
|
||||
for _ in $(seq 1 "$POLL_ATTEMPTS"); do
|
||||
if ui_has 'text="Connected"' "launch-run-$run"; then
|
||||
now_ms="$(node -e 'console.log(Date.now())')"
|
||||
connected_ms="$((now_ms - start_ms))"
|
||||
break
|
||||
fi
|
||||
sleep "$POLL_INTERVAL_SECONDS"
|
||||
done
|
||||
printf 'run=%s total_time_ms=%s connected_ms=%s\n' "$run" "${total_time:-na}" "$connected_ms" \
|
||||
| tee -a "$run_dir/launch-runs.txt"
|
||||
done
|
||||
|
||||
awk -F'[ =]' '
|
||||
/total_time_ms=[0-9]+/ {
|
||||
value=$4
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.total_time_avg_ms=%.1f\nlaunch.total_time_min_ms=%d\nlaunch.total_time_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
|
||||
awk -F'[ =]' '
|
||||
/connected_ms=[0-9]+/ {
|
||||
value=$6
|
||||
sum+=value
|
||||
count+=1
|
||||
if (min==0 || value<min) min=value
|
||||
if (value>max) max=value
|
||||
}
|
||||
END {
|
||||
if (count==0) exit
|
||||
printf "launch.connected_avg_ms=%.1f\nlaunch.connected_min_ms=%d\nlaunch.connected_max_ms=%d\n", sum/count, min, max
|
||||
}
|
||||
' "$run_dir/launch-runs.txt" >>"$run_dir/summary.txt"
|
||||
}
|
||||
|
||||
run_screen_benchmark() {
|
||||
ensure_screen_online
|
||||
capture_mem "$run_dir/screen-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/screen-cpu.txt" 18
|
||||
|
||||
if [[ "$SCREEN_MODE" == "transition" ]]; then
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.0
|
||||
adb_cmd shell input tap "$tab_chat_x" "$tab_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
adb_cmd shell input tap "$tab_screen_x" "$tab_y" >/dev/null
|
||||
sleep 1.5
|
||||
for _ in $(seq 1 "$SCREEN_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_bottom_y" "$center_x" "$screen_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$screen_swipe_mid_y" "$center_x" "$screen_swipe_low_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/screen-gfx.txt"
|
||||
capture_mem "$run_dir/screen-mem-after.txt"
|
||||
summarize_gfx "$run_dir/screen-gfx.txt" "screen"
|
||||
summarize_cpu "$run_dir/screen-cpu.txt" "screen"
|
||||
summarize_mem "$run_dir/screen-mem-before.txt" "screen.before"
|
||||
summarize_mem "$run_dir/screen-mem-after.txt" "screen.after"
|
||||
}
|
||||
|
||||
run_chat_benchmark() {
|
||||
ensure_chat_online
|
||||
capture_mem "$run_dir/chat-mem-before.txt"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" reset >/dev/null
|
||||
start_cpu_sampler "$run_dir/chat-cpu.txt" 18
|
||||
|
||||
if [[ "$CHAT_MODE" == "session-switch" ]]; then
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input tap "$chat_session_left_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
adb_cmd shell input tap "$chat_session_right_x" "$chat_session_y" >/dev/null
|
||||
sleep 0.8
|
||||
done
|
||||
else
|
||||
for _ in $(seq 1 "$CHAT_LOOPS"); do
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_bottom_y" "$center_x" "$chat_swipe_top_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
adb_cmd shell input swipe "$center_x" "$chat_swipe_mid_y" "$center_x" "$chat_swipe_bottom_y" 250 >/dev/null
|
||||
sleep 0.35
|
||||
done
|
||||
fi
|
||||
|
||||
wait "$CPU_SAMPLER_PID"
|
||||
adb_cmd shell dumpsys gfxinfo "$PACKAGE" >"$run_dir/chat-gfx.txt"
|
||||
capture_mem "$run_dir/chat-mem-after.txt"
|
||||
summarize_gfx "$run_dir/chat-gfx.txt" "chat"
|
||||
summarize_cpu "$run_dir/chat-cpu.txt" "chat"
|
||||
summarize_mem "$run_dir/chat-mem-before.txt" "chat.before"
|
||||
summarize_mem "$run_dir/chat-mem-after.txt" "chat.after"
|
||||
}
|
||||
|
||||
printf 'device.serial=%s\n' "${DEVICE_SERIAL:-default}" >"$run_dir/summary.txt"
|
||||
printf 'device.display=%sx%s\n' "$display_width" "$display_height" >>"$run_dir/summary.txt"
|
||||
printf 'config.launch_runs=%s\n' "$LAUNCH_RUNS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_loops=%s\n' "$SCREEN_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_loops=%s\n' "$CHAT_LOOPS" >>"$run_dir/summary.txt"
|
||||
printf 'config.screen_mode=%s\n' "$SCREEN_MODE" >>"$run_dir/summary.txt"
|
||||
printf 'config.chat_mode=%s\n' "$CHAT_MODE" >>"$run_dir/summary.txt"
|
||||
|
||||
ensure_connected
|
||||
measure_launch
|
||||
ensure_connected
|
||||
run_screen_benchmark
|
||||
ensure_connected
|
||||
run_chat_benchmark
|
||||
|
||||
printf 'results_dir=%s\n' "$run_dir"
|
||||
cat "$run_dir/summary.txt"
|
||||
@ -9,6 +9,7 @@ struct ExecApprovalEvaluation {
|
||||
let env: [String: String]
|
||||
let resolution: ExecCommandResolution?
|
||||
let allowlistResolutions: [ExecCommandResolution]
|
||||
let allowAlwaysPatterns: [String]
|
||||
let allowlistMatches: [ExecAllowlistEntry]
|
||||
let allowlistSatisfied: Bool
|
||||
let allowlistMatch: ExecAllowlistEntry?
|
||||
@ -31,9 +32,16 @@ enum ExecApprovalEvaluator {
|
||||
let shellWrapper = ExecShellWrapperParser.extract(command: command, rawCommand: rawCommand).isWrapper
|
||||
let env = HostEnvSanitizer.sanitize(overrides: envOverrides, shellWrapper: shellWrapper)
|
||||
let displayCommand = ExecCommandFormatter.displayString(for: command, rawCommand: rawCommand)
|
||||
let allowlistRawCommand = ExecSystemRunCommandValidator.allowlistEvaluationRawCommand(
|
||||
command: command,
|
||||
rawCommand: rawCommand)
|
||||
let allowlistResolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
rawCommand: allowlistRawCommand,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowAlwaysPatterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env)
|
||||
let allowlistMatches = security == .allowlist
|
||||
@ -60,6 +68,7 @@ enum ExecApprovalEvaluator {
|
||||
env: env,
|
||||
resolution: allowlistResolutions.first,
|
||||
allowlistResolutions: allowlistResolutions,
|
||||
allowAlwaysPatterns: allowAlwaysPatterns,
|
||||
allowlistMatches: allowlistMatches,
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: allowlistSatisfied ? allowlistMatches.first : nil,
|
||||
|
||||
@ -378,7 +378,7 @@ private enum ExecHostExecutor {
|
||||
let context = await self.buildContext(
|
||||
request: request,
|
||||
command: validatedRequest.command,
|
||||
rawCommand: validatedRequest.displayCommand)
|
||||
rawCommand: validatedRequest.evaluationRawCommand)
|
||||
|
||||
switch ExecHostRequestEvaluator.evaluate(
|
||||
context: context,
|
||||
@ -476,13 +476,7 @@ private enum ExecHostExecutor {
|
||||
{
|
||||
guard decision == .allowAlways, context.security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in context.allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(
|
||||
command: context.command,
|
||||
resolution: candidate)
|
||||
else {
|
||||
continue
|
||||
}
|
||||
for pattern in context.allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: context.agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -52,6 +52,23 @@ struct ExecCommandResolution {
|
||||
return [resolution]
|
||||
}
|
||||
|
||||
static func resolveAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?) -> [String]
|
||||
{
|
||||
var patterns: [String] = []
|
||||
var seen = Set<String>()
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: command,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: 0,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return patterns
|
||||
}
|
||||
|
||||
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||
let effective = ExecEnvInvocationUnwrapper.unwrapDispatchWrappersForResolution(command)
|
||||
guard let raw = effective.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||
@ -101,6 +118,115 @@ struct ExecCommandResolution {
|
||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
||||
}
|
||||
|
||||
private static func collectAllowAlwaysPatterns(
|
||||
command: [String],
|
||||
cwd: String?,
|
||||
env: [String: String]?,
|
||||
depth: Int,
|
||||
patterns: inout [String],
|
||||
seen: inout Set<String>)
|
||||
{
|
||||
guard depth < 3, !command.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
if let token0 = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
ExecCommandToken.basenameLower(token0) == "env",
|
||||
let envUnwrapped = ExecEnvInvocationUnwrapper.unwrap(command),
|
||||
!envUnwrapped.isEmpty
|
||||
{
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: envUnwrapped,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
if let shellMultiplexer = self.unwrapShellMultiplexerInvocation(command) {
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: shellMultiplexer,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
return
|
||||
}
|
||||
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
if shell.isWrapper {
|
||||
guard let shellCommand = shell.command,
|
||||
let segments = self.splitShellCommandChain(shellCommand)
|
||||
else {
|
||||
return
|
||||
}
|
||||
for segment in segments {
|
||||
let tokens = self.tokenizeShellWords(segment)
|
||||
guard !tokens.isEmpty else {
|
||||
continue
|
||||
}
|
||||
self.collectAllowAlwaysPatterns(
|
||||
command: tokens,
|
||||
cwd: cwd,
|
||||
env: env,
|
||||
depth: depth + 1,
|
||||
patterns: &patterns,
|
||||
seen: &seen)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let resolution = self.resolve(command: command, cwd: cwd, env: env),
|
||||
let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: resolution),
|
||||
seen.insert(pattern).inserted
|
||||
else {
|
||||
return
|
||||
}
|
||||
patterns.append(pattern)
|
||||
}
|
||||
|
||||
private static func unwrapShellMultiplexerInvocation(_ argv: [String]) -> [String]? {
|
||||
guard let token0 = argv.first?.trimmingCharacters(in: .whitespacesAndNewlines), !token0.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
let wrapper = ExecCommandToken.basenameLower(token0)
|
||||
guard wrapper == "busybox" || wrapper == "toybox" else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var appletIndex = 1
|
||||
if appletIndex < argv.count, argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines) == "--" {
|
||||
appletIndex += 1
|
||||
}
|
||||
guard appletIndex < argv.count else {
|
||||
return nil
|
||||
}
|
||||
let applet = argv[appletIndex].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !applet.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let normalizedApplet = ExecCommandToken.basenameLower(applet)
|
||||
let shellWrappers = Set([
|
||||
"ash",
|
||||
"bash",
|
||||
"dash",
|
||||
"fish",
|
||||
"ksh",
|
||||
"powershell",
|
||||
"pwsh",
|
||||
"sh",
|
||||
"zsh",
|
||||
])
|
||||
guard shellWrappers.contains(normalizedApplet) else {
|
||||
return nil
|
||||
}
|
||||
return Array(argv[appletIndex...])
|
||||
}
|
||||
|
||||
private static func parseFirstToken(_ command: String) -> String? {
|
||||
let trimmed = command.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
|
||||
@ -12,14 +12,24 @@ enum ExecCommandToken {
|
||||
enum ExecEnvInvocationUnwrapper {
|
||||
static let maxWrapperDepth = 4
|
||||
|
||||
struct UnwrapResult {
|
||||
let command: [String]
|
||||
let usesModifiers: Bool
|
||||
}
|
||||
|
||||
private static func isEnvAssignment(_ token: String) -> Bool {
|
||||
let pattern = #"^[A-Za-z_][A-Za-z0-9_]*=.*"#
|
||||
return token.range(of: pattern, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
static func unwrap(_ command: [String]) -> [String]? {
|
||||
self.unwrapWithMetadata(command)?.command
|
||||
}
|
||||
|
||||
static func unwrapWithMetadata(_ command: [String]) -> UnwrapResult? {
|
||||
var idx = 1
|
||||
var expectsOptionValue = false
|
||||
var usesModifiers = false
|
||||
while idx < command.count {
|
||||
let token = command[idx].trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if token.isEmpty {
|
||||
@ -28,6 +38,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
if expectsOptionValue {
|
||||
expectsOptionValue = false
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -36,6 +47,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
break
|
||||
}
|
||||
if self.isEnvAssignment(token) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -43,10 +55,12 @@ enum ExecEnvInvocationUnwrapper {
|
||||
let lower = token.lowercased()
|
||||
let flag = lower.split(separator: "=", maxSplits: 1).first.map(String.init) ?? lower
|
||||
if ExecEnvOptions.flagOnly.contains(flag) {
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
if ExecEnvOptions.withValue.contains(flag) {
|
||||
usesModifiers = true
|
||||
if !lower.contains("=") {
|
||||
expectsOptionValue = true
|
||||
}
|
||||
@ -63,6 +77,7 @@ enum ExecEnvInvocationUnwrapper {
|
||||
lower.hasPrefix("--ignore-signal=") ||
|
||||
lower.hasPrefix("--block-signal=")
|
||||
{
|
||||
usesModifiers = true
|
||||
idx += 1
|
||||
continue
|
||||
}
|
||||
@ -70,8 +85,8 @@ enum ExecEnvInvocationUnwrapper {
|
||||
}
|
||||
break
|
||||
}
|
||||
guard idx < command.count else { return nil }
|
||||
return Array(command[idx...])
|
||||
guard !expectsOptionValue, idx < command.count else { return nil }
|
||||
return UnwrapResult(command: Array(command[idx...]), usesModifiers: usesModifiers)
|
||||
}
|
||||
|
||||
static func unwrapDispatchWrappersForResolution(_ command: [String]) -> [String] {
|
||||
@ -84,10 +99,13 @@ enum ExecEnvInvocationUnwrapper {
|
||||
guard ExecCommandToken.basenameLower(token) == "env" else {
|
||||
break
|
||||
}
|
||||
guard let unwrapped = self.unwrap(current), !unwrapped.isEmpty else {
|
||||
guard let unwrapped = self.unwrapWithMetadata(current), !unwrapped.command.isEmpty else {
|
||||
break
|
||||
}
|
||||
current = unwrapped
|
||||
if unwrapped.usesModifiers {
|
||||
break
|
||||
}
|
||||
current = unwrapped.command
|
||||
depth += 1
|
||||
}
|
||||
return current
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
struct ExecHostValidatedRequest {
|
||||
let command: [String]
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ExecHostPolicyDecision {
|
||||
@ -27,7 +28,10 @@ enum ExecHostRequestEvaluator {
|
||||
rawCommand: request.rawCommand)
|
||||
switch validatedCommand {
|
||||
case let .ok(resolved):
|
||||
return .success(ExecHostValidatedRequest(command: command, displayCommand: resolved.displayCommand))
|
||||
return .success(ExecHostValidatedRequest(
|
||||
command: command,
|
||||
displayCommand: resolved.displayCommand,
|
||||
evaluationRawCommand: resolved.evaluationRawCommand))
|
||||
case let .invalid(message):
|
||||
return .failure(
|
||||
ExecHostError(
|
||||
|
||||
@ -3,6 +3,7 @@ import Foundation
|
||||
enum ExecSystemRunCommandValidator {
|
||||
struct ResolvedCommand {
|
||||
let displayCommand: String
|
||||
let evaluationRawCommand: String?
|
||||
}
|
||||
|
||||
enum ValidationResult {
|
||||
@ -52,18 +53,43 @@ enum ExecSystemRunCommandValidator {
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
|
||||
let inferred: String = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
let formattedArgv = ExecCommandFormatter.displayString(for: command)
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
ExecCommandFormatter.displayString(for: command)
|
||||
nil
|
||||
}
|
||||
|
||||
if let raw = normalizedRaw, raw != inferred {
|
||||
if let raw = normalizedRaw, raw != formattedArgv, raw != previewCommand {
|
||||
return .invalid(message: "INVALID_REQUEST: rawCommand does not match command")
|
||||
}
|
||||
|
||||
return .ok(ResolvedCommand(displayCommand: normalizedRaw ?? inferred))
|
||||
return .ok(ResolvedCommand(
|
||||
displayCommand: formattedArgv,
|
||||
evaluationRawCommand: self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)))
|
||||
}
|
||||
|
||||
static func allowlistEvaluationRawCommand(command: [String], rawCommand: String?) -> String? {
|
||||
let normalizedRaw = self.normalizeRaw(rawCommand)
|
||||
let shell = ExecShellWrapperParser.extract(command: command, rawCommand: nil)
|
||||
let shellCommand = shell.isWrapper ? self.trimmedNonEmpty(shell.command) : nil
|
||||
|
||||
let envManipulationBeforeShellWrapper = self.hasEnvManipulationBeforeShellWrapper(command)
|
||||
let shellWrapperPositionalArgv = self.hasTrailingPositionalArgvAfterInlineCommand(command)
|
||||
let mustBindDisplayToFullArgv = envManipulationBeforeShellWrapper || shellWrapperPositionalArgv
|
||||
let previewCommand: String? = if let shellCommand, !mustBindDisplayToFullArgv {
|
||||
shellCommand
|
||||
} else {
|
||||
nil
|
||||
}
|
||||
|
||||
return self.allowlistEvaluationRawCommand(
|
||||
normalizedRaw: normalizedRaw,
|
||||
shellIsWrapper: shell.isWrapper,
|
||||
previewCommand: previewCommand)
|
||||
}
|
||||
|
||||
private static func normalizeRaw(_ rawCommand: String?) -> String? {
|
||||
@ -76,6 +102,20 @@ enum ExecSystemRunCommandValidator {
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
private static func allowlistEvaluationRawCommand(
|
||||
normalizedRaw: String?,
|
||||
shellIsWrapper: Bool,
|
||||
previewCommand: String?) -> String?
|
||||
{
|
||||
guard shellIsWrapper else {
|
||||
return normalizedRaw
|
||||
}
|
||||
guard let normalizedRaw else {
|
||||
return nil
|
||||
}
|
||||
return normalizedRaw == previewCommand ? normalizedRaw : nil
|
||||
}
|
||||
|
||||
private static func normalizeExecutableToken(_ token: String) -> String {
|
||||
let base = ExecCommandToken.basenameLower(token)
|
||||
if base.hasSuffix(".exe") {
|
||||
|
||||
@ -507,8 +507,7 @@ actor MacNodeRuntime {
|
||||
persistAllowlist: persistAllowlist,
|
||||
security: evaluation.security,
|
||||
agentId: evaluation.agentId,
|
||||
command: command,
|
||||
allowlistResolutions: evaluation.allowlistResolutions)
|
||||
allowAlwaysPatterns: evaluation.allowAlwaysPatterns)
|
||||
|
||||
if evaluation.security == .allowlist, !evaluation.allowlistSatisfied, !evaluation.skillAllow, !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
@ -795,15 +794,11 @@ extension MacNodeRuntime {
|
||||
persistAllowlist: Bool,
|
||||
security: ExecSecurity,
|
||||
agentId: String?,
|
||||
command: [String],
|
||||
allowlistResolutions: [ExecCommandResolution])
|
||||
allowAlwaysPatterns: [String])
|
||||
{
|
||||
guard persistAllowlist, security == .allowlist else { return }
|
||||
var seenPatterns = Set<String>()
|
||||
for candidate in allowlistResolutions {
|
||||
guard let pattern = ExecApprovalHelpers.allowlistPattern(command: command, resolution: candidate) else {
|
||||
continue
|
||||
}
|
||||
for pattern in allowAlwaysPatterns {
|
||||
if seenPatterns.insert(pattern).inserted {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
|
||||
@ -45,7 +45,7 @@ import Testing
|
||||
let nodePath = tmp.appendingPathComponent("node_modules/.bin/node")
|
||||
let scriptPath = tmp.appendingPathComponent("bin/openclaw.js")
|
||||
try makeExecutableForTests(at: nodePath)
|
||||
try "#!/bin/sh\necho v22.0.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try "#!/bin/sh\necho v22.16.0\n".write(to: nodePath, atomically: true, encoding: .utf8)
|
||||
try FileManager().setAttributes([.posixPermissions: 0o755], ofItemAtPath: nodePath.path)
|
||||
try makeExecutableForTests(at: scriptPath)
|
||||
|
||||
|
||||
@ -240,7 +240,7 @@ struct ExecAllowlistTests {
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env assignments inside shell segments`() {
|
||||
@Test func `resolve for allowlist preserves env assignments inside shell segments`() {
|
||||
let command = ["/bin/sh", "-lc", "env FOO=bar /usr/bin/touch /tmp/openclaw-allowlist-test"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@ -248,11 +248,11 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/touch")
|
||||
#expect(resolutions[0].executableName == "touch")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `resolve for allowlist unwraps env to effective direct executable`() {
|
||||
@Test func `resolve for allowlist preserves env wrapper with modifiers`() {
|
||||
let command = ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"]
|
||||
let resolutions = ExecCommandResolution.resolveForAllowlist(
|
||||
command: command,
|
||||
@ -260,8 +260,33 @@ struct ExecAllowlistTests {
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
#expect(resolutions.count == 1)
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(resolutions[0].executableName == "printf")
|
||||
#expect(resolutions[0].resolvedPath == "/usr/bin/env")
|
||||
#expect(resolutions[0].executableName == "env")
|
||||
}
|
||||
|
||||
@Test func `approval evaluator resolves shell payload from canonical wrapper text`() async {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let evaluation = await ExecApprovalEvaluator.evaluate(
|
||||
command: command,
|
||||
rawCommand: rawCommand,
|
||||
cwd: nil,
|
||||
envOverrides: ["PATH": "/usr/bin:/bin"],
|
||||
agentId: nil)
|
||||
|
||||
#expect(evaluation.displayCommand == rawCommand)
|
||||
#expect(evaluation.allowlistResolutions.count == 1)
|
||||
#expect(evaluation.allowlistResolutions[0].resolvedPath == "/usr/bin/printf")
|
||||
#expect(evaluation.allowlistResolutions[0].executableName == "printf")
|
||||
}
|
||||
|
||||
@Test func `allow always patterns unwrap env wrapper modifiers to the inner executable`() {
|
||||
let patterns = ExecCommandResolution.resolveAllowAlwaysPatterns(
|
||||
command: ["/usr/bin/env", "FOO=bar", "/usr/bin/printf", "ok"],
|
||||
cwd: nil,
|
||||
env: ["PATH": "/usr/bin:/bin"])
|
||||
|
||||
#expect(patterns == ["/usr/bin/printf"])
|
||||
}
|
||||
|
||||
@Test func `match all requires every segment to match`() {
|
||||
|
||||
@ -21,13 +21,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
try await self.withTempStateDir { _ in
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let url = ExecApprovalsStore.fileURL()
|
||||
let firstWriteDate = try Self.modificationDate(at: url)
|
||||
let firstIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
try await Task.sleep(nanoseconds: 1_100_000_000)
|
||||
_ = ExecApprovalsStore.ensureFile()
|
||||
let secondWriteDate = try Self.modificationDate(at: url)
|
||||
let secondIdentity = try Self.fileIdentity(at: url)
|
||||
|
||||
#expect(firstWriteDate == secondWriteDate)
|
||||
#expect(firstIdentity == secondIdentity)
|
||||
}
|
||||
}
|
||||
|
||||
@ -81,12 +80,12 @@ struct ExecApprovalsStoreRefactorTests {
|
||||
}
|
||||
}
|
||||
|
||||
private static func modificationDate(at url: URL) throws -> Date {
|
||||
private static func fileIdentity(at url: URL) throws -> Int {
|
||||
let attributes = try FileManager().attributesOfItem(atPath: url.path)
|
||||
guard let date = attributes[.modificationDate] as? Date else {
|
||||
struct MissingDateError: Error {}
|
||||
throw MissingDateError()
|
||||
guard let identifier = (attributes[.systemFileNumber] as? NSNumber)?.intValue else {
|
||||
struct MissingIdentifierError: Error {}
|
||||
throw MissingIdentifierError()
|
||||
}
|
||||
return date
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
@ -77,6 +77,7 @@ struct ExecHostRequestEvaluatorTests {
|
||||
env: [:],
|
||||
resolution: nil,
|
||||
allowlistResolutions: [],
|
||||
allowAlwaysPatterns: [],
|
||||
allowlistMatches: [],
|
||||
allowlistSatisfied: allowlistSatisfied,
|
||||
allowlistMatch: nil,
|
||||
|
||||
@ -50,6 +50,20 @@ struct ExecSystemRunCommandValidatorTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test func `validator keeps canonical wrapper text out of allowlist raw parsing`() {
|
||||
let command = ["/bin/sh", "-lc", "/usr/bin/printf ok"]
|
||||
let rawCommand = "/bin/sh -lc \"/usr/bin/printf ok\""
|
||||
let result = ExecSystemRunCommandValidator.resolve(command: command, rawCommand: rawCommand)
|
||||
|
||||
switch result {
|
||||
case let .ok(resolved):
|
||||
#expect(resolved.displayCommand == rawCommand)
|
||||
#expect(resolved.evaluationRawCommand == nil)
|
||||
case let .invalid(message):
|
||||
Issue.record("unexpected invalid result: \(message)")
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadContractCases() throws -> [SystemRunCommandContractCase] {
|
||||
let fixtureURL = try self.findContractFixtureURL()
|
||||
let data = try Data(contentsOf: fixtureURL)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -767,6 +767,14 @@
|
||||
"source": "/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/install/azure/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/fly",
|
||||
"destination": "/install/fly"
|
||||
@ -779,6 +787,10 @@
|
||||
"source": "/platforms/gcp",
|
||||
"destination": "/install/gcp"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/azure",
|
||||
"destination": "/install/azure"
|
||||
},
|
||||
{
|
||||
"source": "/platforms/macos-vm",
|
||||
"destination": "/install/macos-vm"
|
||||
@ -872,6 +884,7 @@
|
||||
"install/fly",
|
||||
"install/hetzner",
|
||||
"install/gcp",
|
||||
"install/azure",
|
||||
"install/macos-vm",
|
||||
"install/exe-dev",
|
||||
"install/railway",
|
||||
|
||||
169
docs/install/azure.md
Normal file
169
docs/install/azure.md
Normal file
@ -0,0 +1,169 @@
|
||||
---
|
||||
summary: "Run OpenClaw Gateway 24/7 on an Azure Linux VM with durable state"
|
||||
read_when:
|
||||
- You want OpenClaw running 24/7 on Azure with Network Security Group hardening
|
||||
- You want a production-grade, always-on OpenClaw Gateway on your own Azure Linux VM
|
||||
- You want secure administration with Azure Bastion SSH
|
||||
- You want repeatable deployments with Azure Resource Manager templates
|
||||
title: "Azure"
|
||||
---
|
||||
|
||||
# OpenClaw on Azure Linux VM
|
||||
|
||||
This guide sets up an Azure Linux VM, applies Network Security Group (NSG) hardening, configures Azure Bastion (managed Azure SSH entry point), and installs OpenClaw.
|
||||
|
||||
## What 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)
|
||||
@ -204,7 +204,9 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: OpenClaw found a helper file path that escapes the plugin root or fails plugin boundary checks, so it refused to import it.
|
||||
- What to do: reinstall the Matrix plugin from a trusted path, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
|
||||
`gateway: failed creating a Matrix migration snapshot; skipping Matrix migration for now: ...`
|
||||
`- Failed creating a Matrix migration snapshot before repair: ...`
|
||||
|
||||
`- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".`
|
||||
|
||||
- Meaning: OpenClaw refused to mutate Matrix state because it could not create the recovery snapshot first.
|
||||
- What to do: resolve the backup error, then rerun `openclaw doctor --fix` or restart the gateway.
|
||||
@ -236,7 +238,7 @@ If the old store reports room keys that were never backed up, OpenClaw warns ins
|
||||
- Meaning: backup exists, but OpenClaw could not recover the recovery key automatically.
|
||||
- What to do: run `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
|
||||
`Failed inspecting legacy Matrix encrypted state for account "...": ...`
|
||||
`Failed inspecting legacy Matrix encrypted state for account "..." (...): ...`
|
||||
|
||||
- Meaning: OpenClaw found the old encrypted store, but it could not inspect it safely enough to prepare recovery.
|
||||
- What to do: rerun `openclaw doctor --fix`. If it repeats, keep the old state directory intact and recover using another verified Matrix client plus `openclaw matrix verify backup restore --recovery-key "<your-recovery-key>"`.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -3,58 +3,21 @@ import { vi } from "vitest";
|
||||
|
||||
export const sendMock: MockFn = vi.fn();
|
||||
export const reactMock: MockFn = vi.fn();
|
||||
export const recordInboundSessionMock: MockFn = vi.fn();
|
||||
export const updateLastRouteMock: MockFn = vi.fn();
|
||||
export const dispatchMock: MockFn = vi.fn();
|
||||
export const readAllowFromStoreMock: MockFn = vi.fn();
|
||||
export const upsertPairingRequestMock: MockFn = vi.fn();
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
addRoleDiscord: vi.fn(),
|
||||
banMemberDiscord: vi.fn(),
|
||||
createChannelDiscord: vi.fn(),
|
||||
createScheduledEventDiscord: vi.fn(),
|
||||
createThreadDiscord: vi.fn(),
|
||||
deleteChannelDiscord: vi.fn(),
|
||||
deleteMessageDiscord: vi.fn(),
|
||||
editChannelDiscord: vi.fn(),
|
||||
editMessageDiscord: vi.fn(),
|
||||
fetchChannelInfoDiscord: vi.fn(),
|
||||
fetchChannelPermissionsDiscord: vi.fn(),
|
||||
fetchMemberInfoDiscord: vi.fn(),
|
||||
fetchMessageDiscord: vi.fn(),
|
||||
fetchReactionsDiscord: vi.fn(),
|
||||
fetchRoleInfoDiscord: vi.fn(),
|
||||
fetchVoiceStatusDiscord: vi.fn(),
|
||||
hasAnyGuildPermissionDiscord: vi.fn(),
|
||||
kickMemberDiscord: vi.fn(),
|
||||
listGuildChannelsDiscord: vi.fn(),
|
||||
listGuildEmojisDiscord: vi.fn(),
|
||||
listPinsDiscord: vi.fn(),
|
||||
listScheduledEventsDiscord: vi.fn(),
|
||||
listThreadsDiscord: vi.fn(),
|
||||
moveChannelDiscord: vi.fn(),
|
||||
pinMessageDiscord: vi.fn(),
|
||||
reactMessageDiscord: async (...args: unknown[]) => {
|
||||
reactMock(...args);
|
||||
},
|
||||
readMessagesDiscord: vi.fn(),
|
||||
removeChannelPermissionDiscord: vi.fn(),
|
||||
removeOwnReactionsDiscord: vi.fn(),
|
||||
removeReactionDiscord: vi.fn(),
|
||||
removeRoleDiscord: vi.fn(),
|
||||
searchMessagesDiscord: vi.fn(),
|
||||
sendDiscordComponentMessage: vi.fn(),
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||
sendPollDiscord: vi.fn(),
|
||||
sendStickerDiscord: vi.fn(),
|
||||
sendVoiceMessageDiscord: vi.fn(),
|
||||
setChannelPermissionDiscord: vi.fn(),
|
||||
timeoutMemberDiscord: vi.fn(),
|
||||
unpinMessageDiscord: vi.fn(),
|
||||
uploadEmojiDiscord: vi.fn(),
|
||||
uploadStickerDiscord: vi.fn(),
|
||||
}));
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageDiscord: (...args: unknown[]) => sendMock(...args),
|
||||
reactMessageDiscord: async (...args: unknown[]) => {
|
||||
reactMock(...args);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
@ -85,19 +48,10 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
resolveSessionKey: vi.fn(),
|
||||
|
||||
@ -58,28 +58,29 @@ const resolvePluginConversationBindingApprovalMock = vi.hoisted(() => vi.fn());
|
||||
const buildPluginBindingResolvedTextMock = vi.hoisted(() => vi.fn());
|
||||
let lastDispatchCtx: Record<string, unknown> | undefined;
|
||||
|
||||
vi.mock("../../../../src/security/dm-policy-shared.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/security/dm-policy-shared.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
readStoreAllowFromForDmPolicy: async (params: {
|
||||
provider: string;
|
||||
accountId: string;
|
||||
dmPolicy?: string | null;
|
||||
shouldRead?: boolean | null;
|
||||
}) => {
|
||||
if (params.shouldRead === false || params.dmPolicy === "allowlist") {
|
||||
return [];
|
||||
}
|
||||
return await readAllowFromStoreMock(params.provider, params.accountId);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/pairing/pairing-store.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/pairing/pairing-store.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../../src/plugins/conversation-binding.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolvePluginConversationBindingApproval: (...args: unknown[]) =>
|
||||
resolvePluginConversationBindingApprovalMock(...args),
|
||||
buildPluginBindingResolvedText: (...args: unknown[]) =>
|
||||
@ -87,14 +88,24 @@ vi.mock("../../../../src/plugins/conversation-binding.js", async (importOriginal
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/infra/system-events.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/infra/system-events.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/infra-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchReplyWithBufferedBlockDispatcher: (...args: unknown[]) => dispatchReplyMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
// agent-components.ts can bind the core dispatcher via reply-runtime re-exports,
|
||||
// so keep this direct mock to avoid hitting real embedded-agent dispatch in tests.
|
||||
vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
@ -106,16 +117,16 @@ vi.mock("../../../../src/auto-reply/reply/provider-dispatcher.js", async (import
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/channels/session.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/channels/session.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
recordInboundSession: (...args: unknown[]) => recordInboundSessionMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/config/sessions.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readSessionUpdatedAt: (...args: unknown[]) => readSessionUpdatedAtMock(...args),
|
||||
@ -123,8 +134,8 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../../src/plugins/interactive.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/plugins/interactive.js")>();
|
||||
vi.mock("openclaw/plugin-sdk/plugin-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/plugin-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
dispatchPluginInteractiveHandler: (...args: unknown[]) =>
|
||||
@ -189,13 +200,13 @@ describe("agent components", () => {
|
||||
|
||||
expect(defer).toHaveBeenCalledWith({ ephemeral: true });
|
||||
expect(reply).toHaveBeenCalledTimes(1);
|
||||
expect(reply.mock.calls[0]?.[0]?.content).toContain("Pairing code: PAIRCODE");
|
||||
const pairingText = String(reply.mock.calls[0]?.[0]?.content ?? "");
|
||||
expect(pairingText).toContain("Pairing code:");
|
||||
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
|
||||
expect(code).toBeDefined();
|
||||
expect(pairingText).toContain(`openclaw pairing approve discord ${code}`);
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
});
|
||||
|
||||
it("blocks DM interactions in allowlist mode when sender is not in configured allowFrom", async () => {
|
||||
@ -229,11 +240,7 @@ describe("agent components", () => {
|
||||
expect(reply).toHaveBeenCalledWith({ content: "✓" });
|
||||
expect(enqueueSystemEventMock).toHaveBeenCalled();
|
||||
expect(upsertPairingRequestMock).not.toHaveBeenCalled();
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith({
|
||||
provider: "discord",
|
||||
accountId: "default",
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
expect(readAllowFromStoreMock).toHaveBeenCalledWith("discord", "default");
|
||||
});
|
||||
|
||||
it("allows DM component interactions in open mode without reading pairing store", async () => {
|
||||
@ -831,10 +838,9 @@ describe("discord component interactions", () => {
|
||||
|
||||
await button.run(interaction, { cid: "btn_1" } as ComponentData);
|
||||
|
||||
expect(resolvePluginConversationBindingApprovalMock).toHaveBeenCalledTimes(1);
|
||||
expect(update).toHaveBeenCalledWith({ components: [] });
|
||||
expect(followUp).toHaveBeenCalledWith({
|
||||
content: "Binding approved.",
|
||||
content: expect.stringContaining("bind approval"),
|
||||
ephemeral: true,
|
||||
});
|
||||
expect(dispatchReplyMock).not.toHaveBeenCalled();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import path from "node:path";
|
||||
import { createJiti } from "jiti";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const setMatrixRuntimeMock = vi.hoisted(() => vi.fn());
|
||||
@ -14,6 +16,20 @@ describe("matrix plugin registration", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("loads the matrix runtime api through Jiti", () => {
|
||||
const jiti = createJiti(import.meta.url, {
|
||||
interopDefault: true,
|
||||
tryNative: false,
|
||||
extensions: [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs", ".json"],
|
||||
});
|
||||
const runtimeApiPath = path.join(process.cwd(), "extensions", "matrix", "runtime-api.ts");
|
||||
|
||||
expect(jiti(runtimeApiPath)).toMatchObject({
|
||||
requiresExplicitMatrixDefaultAccount: expect.any(Function),
|
||||
resolveMatrixDefaultOrOnlyAccountId: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the channel without bootstrapping crypto runtime", () => {
|
||||
const runtime = {} as never;
|
||||
matrixPlugin.register({
|
||||
|
||||
@ -1,3 +1,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";
|
||||
|
||||
@ -59,7 +59,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [] };
|
||||
const actions = discovery.actions;
|
||||
|
||||
expect(actions).toContain("poll");
|
||||
@ -74,7 +74,7 @@ describe("matrixMessageActions", () => {
|
||||
|
||||
const discovery = describeMessageTool!({
|
||||
cfg: createConfiguredMatrixConfig(),
|
||||
} as never);
|
||||
} as never) ?? { actions: [], schema: null };
|
||||
const actions = discovery.actions;
|
||||
const properties =
|
||||
(discovery.schema as { properties?: Record<string, unknown> } | null)?.properties ?? {};
|
||||
@ -87,64 +87,66 @@ describe("matrixMessageActions", () => {
|
||||
});
|
||||
|
||||
it("hides gated actions when the default Matrix account disables them", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
defaultAccount: "assistant",
|
||||
actions: {
|
||||
messages: true,
|
||||
reactions: true,
|
||||
pins: true,
|
||||
profile: true,
|
||||
memberInfo: true,
|
||||
channelInfo: true,
|
||||
verification: true,
|
||||
},
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
userId: "@bot:example.org",
|
||||
accessToken: "token",
|
||||
encryption: true,
|
||||
actions: {
|
||||
messages: false,
|
||||
reactions: false,
|
||||
pins: false,
|
||||
profile: false,
|
||||
memberInfo: false,
|
||||
channelInfo: false,
|
||||
verification: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual(["poll", "poll-vote"]);
|
||||
});
|
||||
|
||||
it("hides actions until defaultAccount is set for ambiguous multi-account configs", () => {
|
||||
const actions = matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
const actions =
|
||||
matrixMessageActions.describeMessageTool!({
|
||||
cfg: {
|
||||
channels: {
|
||||
matrix: {
|
||||
accounts: {
|
||||
assistant: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "assistant-token",
|
||||
},
|
||||
ops: {
|
||||
homeserver: "https://matrix.example.org",
|
||||
accessToken: "ops-token",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
} as never).actions;
|
||||
} as CoreConfig,
|
||||
} as never)?.actions ?? [];
|
||||
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
@ -2,11 +2,13 @@ import { listMatrixDirectoryGroupsLive, listMatrixDirectoryPeersLive } from "./d
|
||||
import { resolveMatrixAuth } from "./matrix/client.js";
|
||||
import { probeMatrix } from "./matrix/probe.js";
|
||||
import { sendMessageMatrix } from "./matrix/send.js";
|
||||
import { matrixOutbound } from "./outbound.js";
|
||||
import { resolveMatrixTargets } from "./resolve-targets.js";
|
||||
|
||||
export const matrixChannelRuntime = {
|
||||
listMatrixDirectoryGroupsLive,
|
||||
listMatrixDirectoryPeersLive,
|
||||
matrixOutbound,
|
||||
probeMatrix,
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixTargets,
|
||||
|
||||
@ -15,8 +15,8 @@ import {
|
||||
createTextPairingAdapter,
|
||||
listResolvedDirectoryEntriesFromSources,
|
||||
} from "openclaw/plugin-sdk/channel-runtime";
|
||||
import { buildTrafficStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { buildTrafficStatusSummary } from "../../shared/channel-status-summary.js";
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
buildProbeChannelStatusSummary,
|
||||
@ -47,7 +47,6 @@ import {
|
||||
import { getMatrixRuntime } from "./runtime.js";
|
||||
import { resolveMatrixOutboundSessionRoute } from "./session-route.js";
|
||||
import { matrixSetupAdapter } from "./setup-core.js";
|
||||
import { matrixSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
// Mutex for serializing account startup (workaround for concurrent dynamic import race condition)
|
||||
@ -190,7 +189,6 @@ function matchMatrixAcpConversation(params: {
|
||||
export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
id: "matrix",
|
||||
meta,
|
||||
setupWizard: matrixSetupWizard,
|
||||
pairing: createTextPairingAdapter({
|
||||
idLabel: "matrixUserId",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
|
||||
@ -521,7 +521,9 @@ describe("matrix CLI verification commands", () => {
|
||||
|
||||
expect(matrixRuntimeWriteConfigFileMock).toHaveBeenCalled();
|
||||
expect(process.exitCode).toBeUndefined();
|
||||
const jsonOutput = console.log.mock.calls.at(-1)?.[0];
|
||||
const jsonOutput = (console.log as unknown as { mock: { calls: unknown[][] } }).mock.calls.at(
|
||||
-1,
|
||||
)?.[0];
|
||||
expect(typeof jsonOutput).toBe("string");
|
||||
expect(JSON.parse(String(jsonOutput))).toEqual(
|
||||
expect.objectContaining({
|
||||
|
||||
@ -7,7 +7,7 @@ import {
|
||||
resolveMatrixAccount,
|
||||
} from "./accounts.js";
|
||||
|
||||
vi.mock("./credentials.js", () => ({
|
||||
vi.mock("./credentials-read.js", () => ({
|
||||
loadMatrixCredentials: () => null,
|
||||
credentialsMatchConfig: () => false,
|
||||
}));
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -19,6 +19,7 @@ import {
|
||||
listNormalizedMatrixAccountIds,
|
||||
} from "../account-config.js";
|
||||
import { resolveMatrixConfigFieldPath } from "../config-update.js";
|
||||
import { credentialsMatchConfig, loadMatrixCredentials } from "../credentials-read.js";
|
||||
import { MatrixClient } from "../sdk.js";
|
||||
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
|
||||
import type { MatrixAuth, MatrixResolvedConfig } from "./types.js";
|
||||
@ -338,13 +339,11 @@ export async function resolveMatrixAuth(params?: {
|
||||
}): Promise<MatrixAuth> {
|
||||
const { cfg, env, accountId, resolved } = resolveMatrixAuthContext(params);
|
||||
const homeserver = validateMatrixHomeserverUrl(resolved.homeserver);
|
||||
|
||||
const {
|
||||
loadMatrixCredentials,
|
||||
saveMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
touchMatrixCredentials,
|
||||
} = await import("../credentials.js");
|
||||
let credentialsWriter: typeof import("../credentials-write.runtime.js") | undefined;
|
||||
const loadCredentialsWriter = async () => {
|
||||
credentialsWriter ??= await import("../credentials-write.runtime.js");
|
||||
return credentialsWriter;
|
||||
};
|
||||
|
||||
const cached = loadMatrixCredentials(env, accountId);
|
||||
const cachedCredentials =
|
||||
@ -391,6 +390,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
cachedCredentials.userId !== userId ||
|
||||
(cachedCredentials.deviceId || undefined) !== knownDeviceId;
|
||||
if (shouldRefreshCachedCredentials) {
|
||||
const { saveMatrixCredentials } = await loadCredentialsWriter();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver,
|
||||
@ -402,6 +402,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
accountId,
|
||||
);
|
||||
} else if (hasMatchingCachedToken) {
|
||||
const { touchMatrixCredentials } = await loadCredentialsWriter();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
}
|
||||
return {
|
||||
@ -418,6 +419,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
}
|
||||
|
||||
if (cachedCredentials) {
|
||||
const { touchMatrixCredentials } = await loadCredentialsWriter();
|
||||
await touchMatrixCredentials(env, accountId);
|
||||
return {
|
||||
accountId,
|
||||
@ -474,6 +476,7 @@ export async function resolveMatrixAuth(params?: {
|
||||
encryption: resolved.encryption,
|
||||
};
|
||||
|
||||
const { saveMatrixCredentials } = await loadCredentialsWriter();
|
||||
await saveMatrixCredentials(
|
||||
{
|
||||
homeserver: auth.homeserver,
|
||||
|
||||
@ -12,7 +12,7 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
rooms: {
|
||||
join: {
|
||||
"!room:example.org": {
|
||||
summary: {},
|
||||
summary: { "m.heroes": [] },
|
||||
state: { events: [] },
|
||||
timeline: {
|
||||
events: [
|
||||
@ -34,6 +34,9 @@ function createSyncResponse(nextBatch: string): ISyncResponse {
|
||||
unread_notifications: {},
|
||||
},
|
||||
},
|
||||
invite: {},
|
||||
leave: {},
|
||||
knock: {},
|
||||
},
|
||||
account_data: {
|
||||
events: [
|
||||
@ -88,6 +91,50 @@ describe("FileBackedMatrixSyncStore", () => {
|
||||
},
|
||||
]);
|
||||
expect(savedSync?.roomsData.join?.["!room:example.org"]).toBeTruthy();
|
||||
expect(secondStore.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
});
|
||||
|
||||
it("only treats sync state as restart-safe after a clean shutdown persist", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
await firstStore.flush();
|
||||
|
||||
const afterDirtyPersist = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterDirtyPersist.hasSavedSync()).toBe(true);
|
||||
expect(afterDirtyPersist.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const afterCleanShutdown = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterCleanShutdown.hasSavedSync()).toBe(true);
|
||||
expect(afterCleanShutdown.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the clean-shutdown marker once fresh sync data arrives", async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-matrix-sync-store-"));
|
||||
tempDirs.push(tempDir);
|
||||
const storagePath = path.join(tempDir, "bot-storage.json");
|
||||
|
||||
const firstStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
await firstStore.setSyncData(createSyncResponse("s123"));
|
||||
firstStore.markCleanShutdown();
|
||||
await firstStore.flush();
|
||||
|
||||
const restartedStore = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(restartedStore.hasSavedSyncFromCleanShutdown()).toBe(true);
|
||||
|
||||
await restartedStore.setSyncData(createSyncResponse("s456"));
|
||||
await restartedStore.flush();
|
||||
|
||||
const afterNewSync = new FileBackedMatrixSyncStore(storagePath);
|
||||
expect(afterNewSync.hasSavedSync()).toBe(true);
|
||||
expect(afterNewSync.hasSavedSyncFromCleanShutdown()).toBe(false);
|
||||
await expect(afterNewSync.getSavedSyncToken()).resolves.toBe("s456");
|
||||
});
|
||||
|
||||
it("coalesces background persistence until the debounce window elapses", async () => {
|
||||
|
||||
@ -17,6 +17,7 @@ type PersistedMatrixSyncStore = {
|
||||
version: number;
|
||||
savedSync: ISyncData | null;
|
||||
clientOptions?: IStoredClientOpts;
|
||||
cleanShutdown?: boolean;
|
||||
};
|
||||
|
||||
function createAsyncLock() {
|
||||
@ -52,7 +53,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
nextBatch: value.nextBatch,
|
||||
accountData: value.accountData,
|
||||
roomsData: value.roomsData,
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
// Older Matrix state files stored the raw /sync-shaped payload directly.
|
||||
@ -64,7 +65,7 @@ function toPersistedSyncData(value: unknown): ISyncData | null {
|
||||
? value.account_data.events
|
||||
: [],
|
||||
roomsData: isRecord(value.rooms) ? value.rooms : {},
|
||||
} as ISyncData;
|
||||
} as unknown as ISyncData;
|
||||
}
|
||||
|
||||
return null;
|
||||
@ -76,6 +77,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
version?: unknown;
|
||||
savedSync?: unknown;
|
||||
clientOptions?: unknown;
|
||||
cleanShutdown?: unknown;
|
||||
};
|
||||
const savedSync = toPersistedSyncData(parsed.savedSync);
|
||||
if (parsed.version === STORE_VERSION) {
|
||||
@ -85,6 +87,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
clientOptions: isRecord(parsed.clientOptions)
|
||||
? (parsed.clientOptions as IStoredClientOpts)
|
||||
: undefined,
|
||||
cleanShutdown: parsed.cleanShutdown === true,
|
||||
};
|
||||
}
|
||||
|
||||
@ -93,6 +96,7 @@ function readPersistedStore(raw: string): PersistedMatrixSyncStore | null {
|
||||
return {
|
||||
version: STORE_VERSION,
|
||||
savedSync: toPersistedSyncData(parsed),
|
||||
cleanShutdown: false,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@ -119,6 +123,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
private savedSync: ISyncData | null = null;
|
||||
private savedClientOptions: IStoredClientOpts | undefined;
|
||||
private readonly hadSavedSyncOnLoad: boolean;
|
||||
private readonly hadCleanShutdownOnLoad: boolean;
|
||||
private cleanShutdown = false;
|
||||
private dirty = false;
|
||||
private persistTimer: NodeJS.Timeout | null = null;
|
||||
private persistPromise: Promise<void> | null = null;
|
||||
@ -128,11 +134,13 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
|
||||
let restoredSavedSync: ISyncData | null = null;
|
||||
let restoredClientOptions: IStoredClientOpts | undefined;
|
||||
let restoredCleanShutdown = false;
|
||||
try {
|
||||
const raw = readFileSync(this.storagePath, "utf8");
|
||||
const persisted = readPersistedStore(raw);
|
||||
restoredSavedSync = persisted?.savedSync ?? null;
|
||||
restoredClientOptions = persisted?.clientOptions;
|
||||
restoredCleanShutdown = persisted?.cleanShutdown === true;
|
||||
} catch {
|
||||
// Missing or unreadable sync cache should not block startup.
|
||||
}
|
||||
@ -140,6 +148,8 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
this.savedSync = restoredSavedSync;
|
||||
this.savedClientOptions = restoredClientOptions;
|
||||
this.hadSavedSyncOnLoad = restoredSavedSync !== null;
|
||||
this.hadCleanShutdownOnLoad = this.hadSavedSyncOnLoad && restoredCleanShutdown;
|
||||
this.cleanShutdown = this.hadCleanShutdownOnLoad;
|
||||
|
||||
if (this.savedSync) {
|
||||
this.accumulator.accumulate(syncDataToSyncResponse(this.savedSync), true);
|
||||
@ -154,6 +164,10 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
return this.hadSavedSyncOnLoad;
|
||||
}
|
||||
|
||||
hasSavedSyncFromCleanShutdown(): boolean {
|
||||
return this.hadCleanShutdownOnLoad;
|
||||
}
|
||||
|
||||
override getSavedSync(): Promise<ISyncData | null> {
|
||||
return Promise.resolve(this.savedSync ? cloneJson(this.savedSync) : null);
|
||||
}
|
||||
@ -205,9 +219,15 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
await super.deleteAllData();
|
||||
this.savedSync = null;
|
||||
this.savedClientOptions = undefined;
|
||||
this.cleanShutdown = false;
|
||||
await fs.rm(this.storagePath, { force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
markCleanShutdown(): void {
|
||||
this.cleanShutdown = true;
|
||||
this.dirty = true;
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
@ -224,6 +244,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
}
|
||||
|
||||
private markDirtyAndSchedulePersist(): void {
|
||||
this.cleanShutdown = false;
|
||||
this.dirty = true;
|
||||
if (this.persistTimer) {
|
||||
return;
|
||||
@ -242,6 +263,7 @@ export class FileBackedMatrixSyncStore extends MemoryStore {
|
||||
const payload: PersistedMatrixSyncStore = {
|
||||
version: STORE_VERSION,
|
||||
savedSync: this.savedSync ? cloneJson(this.savedSync) : null,
|
||||
cleanShutdown: this.cleanShutdown === true,
|
||||
...(this.savedClientOptions ? { clientOptions: cloneJson(this.savedClientOptions) } : {}),
|
||||
};
|
||||
try {
|
||||
|
||||
150
extensions/matrix/src/matrix/credentials-read.ts
Normal file
150
extensions/matrix/src/matrix/credentials-read.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
} from "../storage-paths.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
|
||||
}
|
||||
|
||||
function resolveLegacyMigrationSourcePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId?: string | null,
|
||||
): string | null {
|
||||
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
|
||||
return null;
|
||||
}
|
||||
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
|
||||
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
|
||||
}
|
||||
|
||||
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const resolvedStateDir = resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const paths = [
|
||||
resolveMatrixCredentialsPath(env, accountId),
|
||||
resolveLegacyMigrationSourcePath(env, accountId),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string; accessToken?: string },
|
||||
): boolean {
|
||||
if (!config.userId) {
|
||||
if (!config.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
18
extensions/matrix/src/matrix/credentials-write.runtime.ts
Normal file
18
extensions/matrix/src/matrix/credentials-write.runtime.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import type {
|
||||
saveMatrixCredentials as saveMatrixCredentialsType,
|
||||
touchMatrixCredentials as touchMatrixCredentialsType,
|
||||
} from "./credentials.js";
|
||||
|
||||
export async function saveMatrixCredentials(
|
||||
...args: Parameters<typeof saveMatrixCredentialsType>
|
||||
): ReturnType<typeof saveMatrixCredentialsType> {
|
||||
const runtime = await import("./credentials.js");
|
||||
return runtime.saveMatrixCredentials(...args);
|
||||
}
|
||||
|
||||
export async function touchMatrixCredentials(
|
||||
...args: Parameters<typeof touchMatrixCredentialsType>
|
||||
): ReturnType<typeof touchMatrixCredentialsType> {
|
||||
const runtime = await import("./credentials.js");
|
||||
return runtime.touchMatrixCredentials(...args);
|
||||
}
|
||||
@ -1,119 +1,15 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
requiresExplicitMatrixDefaultAccount,
|
||||
resolveMatrixDefaultOrOnlyAccountId,
|
||||
} from "../account-selection.js";
|
||||
import { writeJsonFileAtomically } from "../runtime-api.js";
|
||||
import { getMatrixRuntime } from "../runtime.js";
|
||||
import {
|
||||
resolveMatrixCredentialsDir as resolveSharedMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath as resolveSharedMatrixCredentialsPath,
|
||||
} from "../storage-paths.js";
|
||||
import { loadMatrixCredentials, resolveMatrixCredentialsPath } from "./credentials-read.js";
|
||||
import type { MatrixStoredCredentials } from "./credentials-read.js";
|
||||
|
||||
export type MatrixStoredCredentials = {
|
||||
homeserver: string;
|
||||
userId: string;
|
||||
accessToken: string;
|
||||
deviceId?: string;
|
||||
createdAt: string;
|
||||
lastUsedAt?: string;
|
||||
};
|
||||
|
||||
function resolveStateDir(env: NodeJS.ProcessEnv): string {
|
||||
return getMatrixRuntime().state.resolveStateDir(env, os.homedir);
|
||||
}
|
||||
|
||||
function resolveLegacyMatrixCredentialsPath(env: NodeJS.ProcessEnv): string | null {
|
||||
return path.join(resolveMatrixCredentialsDir(env), "credentials.json");
|
||||
}
|
||||
|
||||
function shouldReadLegacyCredentialsForAccount(accountId?: string | null): boolean {
|
||||
const normalizedAccountId = normalizeAccountId(accountId);
|
||||
const cfg = getMatrixRuntime().config.loadConfig();
|
||||
if (!cfg.channels?.matrix || typeof cfg.channels.matrix !== "object") {
|
||||
return normalizedAccountId === DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
if (requiresExplicitMatrixDefaultAccount(cfg)) {
|
||||
return false;
|
||||
}
|
||||
return normalizeAccountId(resolveMatrixDefaultOrOnlyAccountId(cfg)) === normalizedAccountId;
|
||||
}
|
||||
|
||||
function resolveLegacyMigrationSourcePath(
|
||||
env: NodeJS.ProcessEnv,
|
||||
accountId?: string | null,
|
||||
): string | null {
|
||||
if (!shouldReadLegacyCredentialsForAccount(accountId)) {
|
||||
return null;
|
||||
}
|
||||
const legacyPath = resolveLegacyMatrixCredentialsPath(env);
|
||||
return legacyPath === resolveMatrixCredentialsPath(env, accountId) ? null : legacyPath;
|
||||
}
|
||||
|
||||
function parseMatrixCredentialsFile(filePath: string): MatrixStoredCredentials | null {
|
||||
const raw = fs.readFileSync(filePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<MatrixStoredCredentials>;
|
||||
if (
|
||||
typeof parsed.homeserver !== "string" ||
|
||||
typeof parsed.userId !== "string" ||
|
||||
typeof parsed.accessToken !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as MatrixStoredCredentials;
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsDir(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
stateDir?: string,
|
||||
): string {
|
||||
const resolvedStateDir = stateDir ?? resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsDir(resolvedStateDir);
|
||||
}
|
||||
|
||||
export function resolveMatrixCredentialsPath(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): string {
|
||||
const resolvedStateDir = resolveStateDir(env);
|
||||
return resolveSharedMatrixCredentialsPath({ stateDir: resolvedStateDir, accountId });
|
||||
}
|
||||
|
||||
export function loadMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): MatrixStoredCredentials | null {
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
try {
|
||||
if (fs.existsSync(credPath)) {
|
||||
return parseMatrixCredentialsFile(credPath);
|
||||
}
|
||||
|
||||
const legacyPath = resolveLegacyMigrationSourcePath(env, accountId);
|
||||
if (!legacyPath || !fs.existsSync(legacyPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = parseMatrixCredentialsFile(legacyPath);
|
||||
if (!parsed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(credPath), { recursive: true });
|
||||
fs.renameSync(legacyPath, credPath);
|
||||
} catch {
|
||||
// Keep returning the legacy credentials even if migration fails.
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
export {
|
||||
clearMatrixCredentials,
|
||||
credentialsMatchConfig,
|
||||
loadMatrixCredentials,
|
||||
resolveMatrixCredentialsDir,
|
||||
resolveMatrixCredentialsPath,
|
||||
} from "./credentials-read.js";
|
||||
export type { MatrixStoredCredentials } from "./credentials-read.js";
|
||||
|
||||
export async function saveMatrixCredentials(
|
||||
credentials: Omit<MatrixStoredCredentials, "createdAt" | "lastUsedAt">,
|
||||
@ -147,38 +43,3 @@ export async function touchMatrixCredentials(
|
||||
const credPath = resolveMatrixCredentialsPath(env, accountId);
|
||||
await writeJsonFileAtomically(credPath, existing);
|
||||
}
|
||||
|
||||
export function clearMatrixCredentials(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
accountId?: string | null,
|
||||
): void {
|
||||
const paths = [
|
||||
resolveMatrixCredentialsPath(env, accountId),
|
||||
resolveLegacyMigrationSourcePath(env, accountId),
|
||||
];
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function credentialsMatchConfig(
|
||||
stored: MatrixStoredCredentials,
|
||||
config: { homeserver: string; userId: string; accessToken?: string },
|
||||
): boolean {
|
||||
if (!config.userId) {
|
||||
if (!config.accessToken) {
|
||||
return false;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.accessToken === config.accessToken;
|
||||
}
|
||||
return stored.homeserver === config.homeserver && stored.userId === config.userId;
|
||||
}
|
||||
|
||||
1
extensions/matrix/src/matrix/index.ts
Normal file
1
extensions/matrix/src/matrix/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { monitorMatrixProvider } from "./monitor/index.js";
|
||||
@ -62,7 +62,7 @@ function createHarness(params?: {
|
||||
const ensureVerificationDmTracked = vi.fn(
|
||||
params?.ensureVerificationDmTracked ?? (async () => null),
|
||||
);
|
||||
const sendMessage = vi.fn(async () => "$notice");
|
||||
const sendMessage = vi.fn(async (_roomId: string, _payload: { body?: string }) => "$notice");
|
||||
const invalidateRoom = vi.fn();
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
const formatNativeDependencyHint = vi.fn(() => "install hint");
|
||||
|
||||
@ -100,6 +100,7 @@ function createHandlerHarness() {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@ -588,11 +588,13 @@ describe("matrix monitor handler pairing account scope", () => {
|
||||
mediaMaxBytes: 10_000_000,
|
||||
startupMs: 0,
|
||||
startupGraceMs: 0,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: async () => false,
|
||||
},
|
||||
getRoomInfo: async () => ({ altAliases: [] }),
|
||||
getMemberDisplayName: async () => "sender",
|
||||
needsRoomAliasesForConfig: false,
|
||||
});
|
||||
|
||||
await handler(
|
||||
|
||||
@ -115,6 +115,7 @@ describe("createMatrixRoomMessageHandler thread root media", () => {
|
||||
mediaMaxBytes: 5 * 1024 * 1024,
|
||||
startupMs: Date.now() - 120_000,
|
||||
startupGraceMs: 60_000,
|
||||
dropPreStartupMessages: false,
|
||||
directTracker: {
|
||||
isDirectMessage: vi.fn().mockResolvedValue(true),
|
||||
},
|
||||
|
||||
@ -7,7 +7,6 @@ const hoisted = vi.hoisted(() => {
|
||||
hasPersistedSyncState: vi.fn(() => false),
|
||||
};
|
||||
const createMatrixRoomMessageHandler = vi.fn(() => vi.fn());
|
||||
let startClientError: Error | null = null;
|
||||
const resolveTextChunkLimit = vi.fn<
|
||||
(cfg: unknown, channel: unknown, accountId?: unknown) => number
|
||||
>(() => 4000);
|
||||
@ -18,17 +17,17 @@ const hoisted = vi.hoisted(() => {
|
||||
debug: vi.fn(),
|
||||
};
|
||||
const stopThreadBindingManager = vi.fn();
|
||||
const stopSharedClientInstance = vi.fn();
|
||||
const releaseSharedClientInstance = vi.fn(async () => true);
|
||||
const setActiveMatrixClient = vi.fn();
|
||||
return {
|
||||
callOrder,
|
||||
client,
|
||||
createMatrixRoomMessageHandler,
|
||||
logger,
|
||||
releaseSharedClientInstance,
|
||||
resolveTextChunkLimit,
|
||||
setActiveMatrixClient,
|
||||
startClientError,
|
||||
stopSharedClientInstance,
|
||||
startClientError: null as Error | null,
|
||||
stopThreadBindingManager,
|
||||
};
|
||||
});
|
||||
@ -128,7 +127,10 @@ vi.mock("../client.js", () => ({
|
||||
hoisted.callOrder.push("start-client");
|
||||
return hoisted.client;
|
||||
}),
|
||||
stopSharedClientInstance: hoisted.stopSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../client/shared.js", () => ({
|
||||
releaseSharedClientInstance: hoisted.releaseSharedClientInstance,
|
||||
}));
|
||||
|
||||
vi.mock("../config-update.js", () => ({
|
||||
@ -207,8 +209,8 @@ describe("monitorMatrixProvider", () => {
|
||||
hoisted.callOrder.length = 0;
|
||||
hoisted.startClientError = null;
|
||||
hoisted.resolveTextChunkLimit.mockReset().mockReturnValue(4000);
|
||||
hoisted.releaseSharedClientInstance.mockReset().mockResolvedValue(true);
|
||||
hoisted.setActiveMatrixClient.mockReset();
|
||||
hoisted.stopSharedClientInstance.mockReset();
|
||||
hoisted.stopThreadBindingManager.mockReset();
|
||||
hoisted.client.hasPersistedSyncState.mockReset().mockReturnValue(false);
|
||||
hoisted.createMatrixRoomMessageHandler.mockReset().mockReturnValue(vi.fn());
|
||||
@ -252,12 +254,13 @@ describe("monitorMatrixProvider", () => {
|
||||
await expect(monitorMatrixProvider()).rejects.toThrow("start failed");
|
||||
|
||||
expect(hoisted.stopThreadBindingManager).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.stopSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.releaseSharedClientInstance).toHaveBeenCalledWith(hoisted.client, "persist");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(1, hoisted.client, "default");
|
||||
expect(hoisted.setActiveMatrixClient).toHaveBeenNthCalledWith(2, null, "default");
|
||||
});
|
||||
|
||||
it("disables cold-start backlog dropping when sync state already exists", async () => {
|
||||
it("disables cold-start backlog dropping only when sync state is cleanly persisted", async () => {
|
||||
hoisted.client.hasPersistedSyncState.mockReturnValue(true);
|
||||
const { monitorMatrixProvider } = await import("./index.js");
|
||||
const abortController = new AbortController();
|
||||
|
||||
@ -17,8 +17,8 @@ import {
|
||||
resolveMatrixAuth,
|
||||
resolveMatrixAuthContext,
|
||||
resolveSharedMatrixClient,
|
||||
stopSharedClientInstance,
|
||||
} from "../client.js";
|
||||
import { releaseSharedClientInstance } from "../client/shared.js";
|
||||
import { createMatrixThreadBindingManager } from "../thread-bindings.js";
|
||||
import { registerMatrixAutoJoin } from "./auto-join.js";
|
||||
import { resolveMatrixMonitorConfig } from "./config.js";
|
||||
@ -131,7 +131,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
setActiveMatrixClient(client, auth.accountId);
|
||||
let cleanedUp = false;
|
||||
let threadBindingManager: { accountId: string; stop: () => void } | null = null;
|
||||
const cleanup = () => {
|
||||
const cleanup = async () => {
|
||||
if (cleanedUp) {
|
||||
return;
|
||||
}
|
||||
@ -139,7 +139,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
try {
|
||||
threadBindingManager?.stop();
|
||||
} finally {
|
||||
stopSharedClientInstance(client);
|
||||
await releaseSharedClientInstance(client, "persist");
|
||||
setActiveMatrixClient(null, auth.accountId);
|
||||
}
|
||||
};
|
||||
@ -273,19 +273,32 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const onAbort = () => {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
cleanup();
|
||||
resolve();
|
||||
const stopAndResolve = async () => {
|
||||
try {
|
||||
logVerboseMessage("matrix: stopping client");
|
||||
await cleanup();
|
||||
} catch (err) {
|
||||
logger.warn("matrix: failed during monitor shutdown cleanup", {
|
||||
error: String(err),
|
||||
});
|
||||
} finally {
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
if (opts.abortSignal?.aborted) {
|
||||
onAbort();
|
||||
void stopAndResolve();
|
||||
return;
|
||||
}
|
||||
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
opts.abortSignal?.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
void stopAndResolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
await cleanup();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../../../../src/config/config.js";
|
||||
import {
|
||||
__testing as sessionBindingTesting,
|
||||
createTestRegistry,
|
||||
type OpenClawConfig,
|
||||
resolveAgentRoute,
|
||||
registerSessionBindingAdapter,
|
||||
} from "../../../../../src/infra/outbound/session-binding-service.js";
|
||||
import { setActivePluginRegistry } from "../../../../../src/plugins/runtime.js";
|
||||
import { resolveAgentRoute } from "../../../../../src/routing/resolve-route.js";
|
||||
import { createTestRegistry } from "../../../../../src/test-utils/channel-plugins.js";
|
||||
sessionBindingTesting,
|
||||
setActivePluginRegistry,
|
||||
} from "../../../../../test/helpers/extensions/matrix-route-test.js";
|
||||
import { matrixPlugin } from "../../channel.js";
|
||||
import { resolveMatrixInboundRoute } from "./route.js";
|
||||
|
||||
|
||||
@ -222,7 +222,10 @@ describe("MatrixClient request hardening", () => {
|
||||
|
||||
it("prefers authenticated client media downloads", async () => {
|
||||
const payload = Buffer.from([1, 2, 3, 4]);
|
||||
const fetchMock = vi.fn(async () => new Response(payload, { status: 200 }));
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: RequestInfo | URL, _init?: RequestInit) =>
|
||||
new Response(payload, { status: 200 }),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const client = new MatrixClient("https://matrix.example.org", "token");
|
||||
|
||||
@ -4,6 +4,7 @@ import { EventEmitter } from "node:events";
|
||||
import {
|
||||
ClientEvent,
|
||||
MatrixEventEvent,
|
||||
Preset,
|
||||
createClient as createMatrixJsClient,
|
||||
type MatrixClient as MatrixJsClient,
|
||||
type MatrixEvent,
|
||||
@ -349,7 +350,9 @@ export class MatrixClient {
|
||||
}
|
||||
|
||||
hasPersistedSyncState(): boolean {
|
||||
return this.syncStore?.hasSavedSync() === true;
|
||||
// Only trust restart replay when the previous process completed a final
|
||||
// sync-store persist. A stale cursor can make Matrix re-surface old events.
|
||||
return this.syncStore?.hasSavedSyncFromCleanShutdown() === true;
|
||||
}
|
||||
|
||||
private async ensureStartedForCryptoControlPlane(): Promise<void> {
|
||||
@ -366,6 +369,7 @@ export class MatrixClient {
|
||||
}
|
||||
this.decryptBridge.stop();
|
||||
// Final persist on shutdown
|
||||
this.syncStore?.markCleanShutdown();
|
||||
this.stopPersistPromise = Promise.all([
|
||||
persistIdbToDisk({
|
||||
snapshotPath: this.idbSnapshotPath,
|
||||
@ -547,7 +551,7 @@ export class MatrixClient {
|
||||
const result = await this.client.createRoom({
|
||||
invite: [remoteUserId],
|
||||
is_direct: true,
|
||||
preset: "trusted_private_chat",
|
||||
preset: Preset.TrustedPrivateChat,
|
||||
initial_state: initialState,
|
||||
});
|
||||
return result.room_id;
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import {
|
||||
type ChannelSetupDmPolicy,
|
||||
type ChannelSetupWizardAdapter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { type ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup";
|
||||
import { requiresExplicitMatrixDefaultAccount } from "./account-selection.js";
|
||||
import { listMatrixDirectoryGroupsLive } from "./directory-live.js";
|
||||
import {
|
||||
@ -36,6 +33,54 @@ import type { CoreConfig } from "./types.js";
|
||||
|
||||
const channel = "matrix" as const;
|
||||
|
||||
type MatrixOnboardingStatus = {
|
||||
channel: typeof channel;
|
||||
configured: boolean;
|
||||
statusLines: string[];
|
||||
selectionHint?: string;
|
||||
quickstartScore?: number;
|
||||
};
|
||||
|
||||
type MatrixAccountOverrides = Partial<Record<typeof channel, string>>;
|
||||
|
||||
type MatrixOnboardingConfigureContext = {
|
||||
cfg: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
prompter: WizardPrompter;
|
||||
options?: unknown;
|
||||
forceAllowFrom: boolean;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
shouldPromptAccountIds: boolean;
|
||||
};
|
||||
|
||||
type MatrixOnboardingInteractiveContext = MatrixOnboardingConfigureContext & {
|
||||
configured: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
type MatrixOnboardingAdapter = {
|
||||
channel: typeof channel;
|
||||
getStatus: (ctx: {
|
||||
cfg: CoreConfig;
|
||||
options?: unknown;
|
||||
accountOverrides: MatrixAccountOverrides;
|
||||
}) => Promise<MatrixOnboardingStatus>;
|
||||
configure: (
|
||||
ctx: MatrixOnboardingConfigureContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string }>;
|
||||
configureInteractive?: (
|
||||
ctx: MatrixOnboardingInteractiveContext,
|
||||
) => Promise<{ cfg: CoreConfig; accountId?: string } | "skip">;
|
||||
afterConfigWritten?: (ctx: {
|
||||
previousCfg: CoreConfig;
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
runtime: RuntimeEnv;
|
||||
}) => Promise<void> | void;
|
||||
dmPolicy?: ChannelSetupDmPolicy;
|
||||
disable?: (cfg: CoreConfig) => CoreConfig;
|
||||
};
|
||||
|
||||
function resolveMatrixOnboardingAccountId(cfg: CoreConfig, accountId?: string): string {
|
||||
return normalizeAccountId(
|
||||
accountId?.trim() || resolveDefaultMatrixAccountId(cfg) || DEFAULT_ACCOUNT_ID,
|
||||
@ -473,7 +518,7 @@ async function runMatrixConfigure(params: {
|
||||
return { cfg: next, accountId };
|
||||
}
|
||||
|
||||
export const matrixOnboardingAdapter: ChannelSetupWizardAdapter = {
|
||||
export const matrixOnboardingAdapter: MatrixOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg, accountOverrides }) => {
|
||||
const resolvedCfg = cfg as CoreConfig;
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -97,6 +97,27 @@ function readRawParam(params: Record<string, unknown>, key: string): unknown {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readStringAliasParam(
|
||||
params: Record<string, unknown>,
|
||||
keys: string[],
|
||||
options: { required?: boolean } = {},
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const raw = readRawParam(params, key);
|
||||
if (typeof raw !== "string") {
|
||||
continue;
|
||||
}
|
||||
const trimmed = raw.trim();
|
||||
if (trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
if (options.required) {
|
||||
throw new Error(`${keys[0]} required`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumericArrayParam(
|
||||
params: Record<string, unknown>,
|
||||
key: string,
|
||||
@ -169,7 +190,10 @@ export async function handleMatrixAction(
|
||||
|
||||
if (pollActions.has(action)) {
|
||||
const roomId = readRoomId(params);
|
||||
const pollId = readStringParam(params, "pollId", { required: true });
|
||||
const pollId = readStringAliasParam(params, ["pollId", "messageId"], { required: true });
|
||||
if (!pollId) {
|
||||
throw new Error("pollId required");
|
||||
}
|
||||
const optionId = readStringParam(params, "pollOptionId");
|
||||
const optionIndex = readNumberParam(params, "pollOptionIndex", { integer: true });
|
||||
const optionIds = [
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,5 +1,3 @@
|
||||
import { resetSystemEventsForTest } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { resetInboundDedupe } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { MockFn } from "openclaw/plugin-sdk/testing";
|
||||
import { beforeEach, vi } from "vitest";
|
||||
import type { SignalDaemonExitEvent, SignalDaemonHandle } from "./daemon.js";
|
||||
@ -73,6 +71,10 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => config,
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
});
|
||||
|
||||
@ -81,28 +83,51 @@ vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
getReplyFromConfig: (...args: unknown[]) => replyMock(...args),
|
||||
dispatchInboundMessage: async (params: {
|
||||
ctx: unknown;
|
||||
cfg: unknown;
|
||||
dispatcher: {
|
||||
sendFinalReply: (payload: { text: string }) => boolean;
|
||||
markComplete?: () => void;
|
||||
waitForIdle?: () => Promise<void>;
|
||||
};
|
||||
}) => {
|
||||
const resolved = await replyMock(params.ctx, {}, params.cfg);
|
||||
const text = typeof resolved?.text === "string" ? resolved.text.trim() : "";
|
||||
if (text) {
|
||||
params.dispatcher.sendFinalReply({ text });
|
||||
}
|
||||
params.dispatcher.markComplete?.();
|
||||
await params.dispatcher.waitForIdle?.();
|
||||
return { queuedFinal: Boolean(text) };
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveStorePath: vi.fn(() => "/tmp/openclaw-sessions.json"),
|
||||
updateLastRoute: (...args: unknown[]) => updateLastRouteMock(...args),
|
||||
readSessionUpdatedAt: vi.fn(() => undefined),
|
||||
recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined),
|
||||
sendMessageSignal: (...args: unknown[]) => sendMock(...args),
|
||||
sendTypingSignal: vi.fn().mockResolvedValue(true),
|
||||
sendReadReceiptSignal: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readChannelAllowFromStore: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
upsertChannelPairingRequest: (...args: unknown[]) => upsertPairingRequestMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/security-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/security-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
readStoreAllowFromForDmPolicy: (...args: unknown[]) => readAllowFromStoreMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
@ -129,7 +154,11 @@ vi.mock("openclaw/plugin-sdk/infra-runtime", async (importOriginal) => {
|
||||
});
|
||||
|
||||
export function installSignalToolResultTestHooks() {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
const [{ resetInboundDedupe }, { resetSystemEventsForTest }] = await Promise.all([
|
||||
import("openclaw/plugin-sdk/reply-runtime"),
|
||||
import("openclaw/plugin-sdk/infra-runtime"),
|
||||
]);
|
||||
resetInboundDedupe();
|
||||
config = {
|
||||
messages: { responsePrefix: "PFX" },
|
||||
@ -142,7 +171,7 @@ export function installSignalToolResultTestHooks() {
|
||||
replyMock.mockReset();
|
||||
updateLastRouteMock.mockReset();
|
||||
streamMock.mockReset();
|
||||
signalCheckMock.mockReset().mockResolvedValue({});
|
||||
signalCheckMock.mockReset().mockResolvedValue({ ok: true });
|
||||
signalRpcRequestMock.mockReset().mockResolvedValue({});
|
||||
spawnSignalDaemonMock.mockReset().mockReturnValue(createMockSignalDaemonHandle());
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { SignalReactionNotificationMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import type { BackoffPolicy } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { waitForTransportReady } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-runtime";
|
||||
@ -13,13 +13,13 @@ import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import {
|
||||
chunkTextWithMode,
|
||||
resolveChunkMode,
|
||||
resolveTextChunkLimit,
|
||||
} from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { DEFAULT_GROUP_HISTORY_LIMIT, type HistoryEntry } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { createNonExitingRuntime, type RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { normalizeStringEntries } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeE164 } from "openclaw/plugin-sdk/text-runtime";
|
||||
@ -56,6 +56,7 @@ export type MonitorSignalOpts = {
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
reconnectPolicy?: Partial<BackoffPolicy>;
|
||||
waitForTransportReady?: typeof waitForTransportReady;
|
||||
};
|
||||
|
||||
function resolveRuntime(opts: MonitorSignalOpts): RuntimeEnv {
|
||||
@ -217,8 +218,10 @@ async function waitForSignalDaemonReady(params: {
|
||||
logAfterMs: number;
|
||||
logIntervalMs?: number;
|
||||
runtime: RuntimeEnv;
|
||||
waitForTransportReadyFn?: typeof waitForTransportReady;
|
||||
}): Promise<void> {
|
||||
await waitForTransportReady({
|
||||
const waitForTransportReadyFn = params.waitForTransportReadyFn ?? waitForTransportReady;
|
||||
await waitForTransportReadyFn({
|
||||
label: "signal daemon",
|
||||
timeoutMs: params.timeoutMs,
|
||||
logAfterMs: params.logAfterMs,
|
||||
@ -374,6 +377,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
const mediaMaxBytes = (opts.mediaMaxMb ?? accountInfo.config.mediaMaxMb ?? 8) * 1024 * 1024;
|
||||
const ignoreAttachments = opts.ignoreAttachments ?? accountInfo.config.ignoreAttachments ?? false;
|
||||
const sendReadReceipts = Boolean(opts.sendReadReceipts ?? accountInfo.config.sendReadReceipts);
|
||||
const waitForTransportReadyFn = opts.waitForTransportReady ?? waitForTransportReady;
|
||||
|
||||
const autoStart = opts.autoStart ?? accountInfo.config.autoStart ?? !accountInfo.config.httpUrl;
|
||||
const startupTimeoutMs = Math.min(
|
||||
@ -416,6 +420,7 @@ export async function monitorSignalProvider(opts: MonitorSignalOpts = {}): Promi
|
||||
logAfterMs: 10_000,
|
||||
logIntervalMs: 10_000,
|
||||
runtime,
|
||||
waitForTransportReadyFn,
|
||||
});
|
||||
const daemonExitError = daemonLifecycle.getExitError();
|
||||
if (daemonExitError) {
|
||||
|
||||
@ -1,23 +1,6 @@
|
||||
import type { WebClient } from "@slack/web-api";
|
||||
import { vi } from "vitest";
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => ({}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveSlackAccount: () => ({
|
||||
accountId: "default",
|
||||
botToken: "xoxb-test",
|
||||
botTokenSource: "config",
|
||||
config: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
export type SlackEditTestClient = WebClient & {
|
||||
chat: {
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
@ -33,8 +16,35 @@ export type SlackSendTestClient = WebClient & {
|
||||
};
|
||||
};
|
||||
|
||||
const slackBlockTestState = vi.hoisted(() => ({
|
||||
account: {
|
||||
accountId: "default",
|
||||
botToken: "xoxb-test",
|
||||
botTokenSource: "config",
|
||||
config: {},
|
||||
},
|
||||
config: {},
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => slackBlockTestState.config,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./accounts.js")>();
|
||||
return {
|
||||
...actual,
|
||||
resolveSlackAccount: () => slackBlockTestState.account,
|
||||
};
|
||||
});
|
||||
|
||||
// Kept for compatibility with existing tests; mocks install at module evaluation.
|
||||
export function installSlackBlockTestMocks() {
|
||||
// Backward compatible no-op. Mocks are hoisted at module scope.
|
||||
return;
|
||||
}
|
||||
|
||||
export function createSlackEditTestClient(): SlackEditTestClient {
|
||||
|
||||
@ -202,37 +202,30 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/reply-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/reply-runtime")>();
|
||||
const replyResolver: typeof actual.getReplyFromConfig = (...args) =>
|
||||
slackTestState.replyMock(...args) as ReturnType<typeof actual.getReplyFromConfig>;
|
||||
return {
|
||||
...actual,
|
||||
dispatchInboundMessage: async (params: {
|
||||
ctx: unknown;
|
||||
replyOptions?: {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onAssistantMessageStart?: () => Promise<void> | void;
|
||||
};
|
||||
dispatcher: {
|
||||
sendFinalReply: (payload: unknown) => boolean;
|
||||
waitForIdle: () => Promise<void>;
|
||||
markComplete: () => void;
|
||||
};
|
||||
}) => {
|
||||
const reply = await slackTestState.replyMock(params.ctx, {
|
||||
...params.replyOptions,
|
||||
onReplyStart:
|
||||
params.replyOptions?.onReplyStart ?? params.replyOptions?.onAssistantMessageStart,
|
||||
});
|
||||
const queuedFinal = reply ? params.dispatcher.sendFinalReply(reply) : false;
|
||||
params.dispatcher.markComplete();
|
||||
await params.dispatcher.waitForIdle();
|
||||
return {
|
||||
queuedFinal,
|
||||
counts: {
|
||||
tool: 0,
|
||||
block: 0,
|
||||
final: queuedFinal ? 1 : 0,
|
||||
},
|
||||
};
|
||||
},
|
||||
getReplyFromConfig: replyResolver,
|
||||
dispatchInboundMessage: (params: Parameters<typeof actual.dispatchInboundMessage>[0]) =>
|
||||
actual.dispatchInboundMessage({
|
||||
...params,
|
||||
replyResolver,
|
||||
}),
|
||||
dispatchInboundMessageWithBufferedDispatcher: (
|
||||
params: Parameters<typeof actual.dispatchInboundMessageWithBufferedDispatcher>[0],
|
||||
) =>
|
||||
actual.dispatchInboundMessageWithBufferedDispatcher({
|
||||
...params,
|
||||
replyResolver,
|
||||
}),
|
||||
dispatchInboundMessageWithDispatcher: (
|
||||
params: Parameters<typeof actual.dispatchInboundMessageWithDispatcher>[0],
|
||||
) =>
|
||||
actual.dispatchInboundMessageWithDispatcher({
|
||||
...params,
|
||||
replyResolver,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
@ -246,9 +239,13 @@ vi.mock("./resolve-users.js", () => ({
|
||||
entries.map((input) => ({ input, resolved: false })),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
|
||||
}));
|
||||
vi.mock("./send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageSlack: (...args: unknown[]) => slackTestState.sendMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/conversation-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/conversation-runtime")>();
|
||||
@ -265,20 +262,12 @@ vi.mock("@slack/bolt", () => {
|
||||
const { handlers, client: slackClient } = ensureSlackTestRuntime();
|
||||
class App {
|
||||
client = slackClient;
|
||||
receiver = {
|
||||
client: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
},
|
||||
};
|
||||
event(name: string, handler: SlackHandler) {
|
||||
handlers.set(name, handler);
|
||||
}
|
||||
command = vi.fn();
|
||||
action = vi.fn();
|
||||
options = vi.fn();
|
||||
view = vi.fn();
|
||||
shortcut = vi.fn();
|
||||
command() {
|
||||
/* no-op */
|
||||
}
|
||||
start = vi.fn().mockResolvedValue(undefined);
|
||||
stop = vi.fn().mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ const mocks = vi.hoisted(() => ({
|
||||
resolveAgentRouteMock: vi.fn(),
|
||||
finalizeInboundContextMock: vi.fn(),
|
||||
resolveConversationLabelMock: vi.fn(),
|
||||
createChannelReplyPipelineMock: vi.fn(),
|
||||
createReplyPrefixOptionsMock: vi.fn(),
|
||||
recordSessionMetaFromInboundMock: vi.fn(),
|
||||
resolveStorePathMock: vi.fn(),
|
||||
}));
|
||||
@ -43,27 +43,16 @@ vi.mock("openclaw/plugin-sdk/channel-runtime", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
resolveConversationLabel: (...args: unknown[]) => mocks.resolveConversationLabelMock(...args),
|
||||
createReplyPrefixOptions: (...args: unknown[]) => mocks.createReplyPrefixOptionsMock(...args),
|
||||
recordInboundSessionMetaSafe: (...args: unknown[]) =>
|
||||
mocks.recordSessionMetaFromInboundMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/channel-reply-pipeline", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>();
|
||||
return {
|
||||
...actual,
|
||||
createChannelReplyPipeline: (...args: unknown[]) =>
|
||||
mocks.createChannelReplyPipelineMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
recordSessionMetaFromInbound: (...args: unknown[]) =>
|
||||
mocks.recordSessionMetaFromInboundMock(...args),
|
||||
resolveStorePath: (...args: unknown[]) => mocks.resolveStorePathMock(...args),
|
||||
};
|
||||
});
|
||||
@ -75,7 +64,7 @@ type SlashHarnessMocks = {
|
||||
resolveAgentRouteMock: ReturnType<typeof vi.fn>;
|
||||
finalizeInboundContextMock: ReturnType<typeof vi.fn>;
|
||||
resolveConversationLabelMock: ReturnType<typeof vi.fn>;
|
||||
createChannelReplyPipelineMock: ReturnType<typeof vi.fn>;
|
||||
createReplyPrefixOptionsMock: ReturnType<typeof vi.fn>;
|
||||
recordSessionMetaFromInboundMock: ReturnType<typeof vi.fn>;
|
||||
resolveStorePathMock: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
@ -95,7 +84,7 @@ export function resetSlackSlashMocks() {
|
||||
});
|
||||
mocks.finalizeInboundContextMock.mockReset().mockImplementation((ctx: unknown) => ctx);
|
||||
mocks.resolveConversationLabelMock.mockReset().mockReturnValue(undefined);
|
||||
mocks.createChannelReplyPipelineMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
|
||||
mocks.createReplyPrefixOptionsMock.mockReset().mockReturnValue({ onModelSelected: () => {} });
|
||||
mocks.recordSessionMetaFromInboundMock.mockReset().mockResolvedValue(undefined);
|
||||
mocks.resolveStorePathMock.mockReset().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
|
||||
|
||||
vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
|
||||
vi.mock("./slash-commands.runtime.js", () => {
|
||||
const usageCommand = { key: "usage", nativeName: "usage" };
|
||||
const reportCommand = { key: "report", nativeName: "report" };
|
||||
const reportCompactCommand = { key: "reportcompact", nativeName: "reportcompact" };
|
||||
@ -180,21 +180,26 @@ vi.mock("../../../../src/auto-reply/commands-registry.js", () => {
|
||||
});
|
||||
|
||||
type RegisterFn = (params: { ctx: unknown; account: unknown }) => Promise<void>;
|
||||
let registerSlackMonitorSlashCommands: RegisterFn;
|
||||
let registerSlackMonitorSlashCommandsPromise: Promise<RegisterFn> | undefined;
|
||||
|
||||
async function loadRegisterSlackMonitorSlashCommands(): Promise<RegisterFn> {
|
||||
registerSlackMonitorSlashCommandsPromise ??= import("./slash.js").then((module) => {
|
||||
const typed = module as unknown as {
|
||||
registerSlackMonitorSlashCommands: RegisterFn;
|
||||
};
|
||||
return typed.registerSlackMonitorSlashCommands;
|
||||
});
|
||||
return await registerSlackMonitorSlashCommandsPromise;
|
||||
}
|
||||
|
||||
const { dispatchMock } = getSlackSlashMocks();
|
||||
|
||||
beforeAll(async () => {
|
||||
({ registerSlackMonitorSlashCommands } = (await import("./slash.js")) as unknown as {
|
||||
registerSlackMonitorSlashCommands: RegisterFn;
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetSlackSlashMocks();
|
||||
});
|
||||
|
||||
async function registerCommands(ctx: unknown, account: unknown) {
|
||||
const registerSlackMonitorSlashCommands = await loadRegisterSlackMonitorSlashCommands();
|
||||
await registerSlackMonitorSlashCommands({ ctx: ctx as never, account: account as never });
|
||||
}
|
||||
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -59,6 +59,30 @@ const TELEGRAM_TEST_TIMINGS = {
|
||||
textFragmentGapMs: 30,
|
||||
} as const;
|
||||
|
||||
async function withIsolatedStateDirAsync<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-state-"));
|
||||
return await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function withConfigPathAsync<T>(cfg: unknown, fn: () => Promise<T>): Promise<T> {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-telegram-cfg-"));
|
||||
const configPath = path.join(dir, "openclaw.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify(cfg), "utf-8");
|
||||
return await withEnvAsync({ OPENCLAW_CONFIG_PATH: configPath }, async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
describe("createTelegramBot", () => {
|
||||
beforeAll(() => {
|
||||
process.env.TZ = "UTC";
|
||||
@ -250,107 +274,115 @@ describe("createTelegramBot", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "new unknown sender",
|
||||
upsertResults: [{ code: "PAIRME12", created: true }],
|
||||
messages: ["hello"],
|
||||
expectedSendCount: 1,
|
||||
expectPairingText: true,
|
||||
pairingUpsertResults: [{ code: "PAIRCODE", created: true }],
|
||||
},
|
||||
{
|
||||
name: "already pending request",
|
||||
upsertResults: [
|
||||
{ code: "PAIRME12", created: true },
|
||||
{ code: "PAIRME12", created: false },
|
||||
],
|
||||
messages: ["hello", "hello again"],
|
||||
expectedSendCount: 1,
|
||||
expectPairingText: false,
|
||||
pairingUpsertResults: [
|
||||
{ code: "PAIRCODE", created: true },
|
||||
{ code: "PAIRCODE", created: false },
|
||||
],
|
||||
},
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
onSpy.mockClear();
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
await withIsolatedStateDirAsync(async () => {
|
||||
for (const [index, testCase] of cases.entries()) {
|
||||
onSpy.mockClear();
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "pairing" } },
|
||||
});
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockClear();
|
||||
let pairingUpsertCall = 0;
|
||||
upsertChannelPairingRequest.mockImplementation(async () => {
|
||||
const result =
|
||||
testCase.pairingUpsertResults[
|
||||
Math.min(pairingUpsertCall, testCase.pairingUpsertResults.length - 1)
|
||||
];
|
||||
pairingUpsertCall += 1;
|
||||
return result;
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
const senderId = Number(`${Date.now()}${index}`.slice(-9));
|
||||
for (const text of testCase.messages) {
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
text,
|
||||
date: 1736380800,
|
||||
from: { id: senderId, username: "random" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
}
|
||||
|
||||
expect(replySpy, testCase.name).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
|
||||
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
|
||||
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
|
||||
expect(pairingText, testCase.name).toContain(`Your Telegram user id: ${senderId}`);
|
||||
expect(pairingText, testCase.name).toContain("Pairing code:");
|
||||
const code = pairingText.match(/Pairing code:\s*([A-Z2-9]{8})/)?.[1];
|
||||
expect(code, testCase.name).toBeDefined();
|
||||
expect(pairingText, testCase.name).toContain(`openclaw pairing approve telegram ${code}`);
|
||||
expect(pairingText, testCase.name).not.toContain("<code>");
|
||||
}
|
||||
});
|
||||
});
|
||||
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
|
||||
await withIsolatedStateDirAsync(async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "pairing" } },
|
||||
});
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockClear();
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRCODE", created: true });
|
||||
for (const result of testCase.upsertResults) {
|
||||
upsertChannelPairingRequest.mockResolvedValueOnce(result);
|
||||
}
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
const senderId = Number(`${Date.now()}01`.slice(-9));
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
}),
|
||||
);
|
||||
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
|
||||
|
||||
try {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
for (const text of testCase.messages) {
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
text,
|
||||
message_id: 410,
|
||||
date: 1736380800,
|
||||
from: { id: 999, username: "random" },
|
||||
photo: [{ file_id: "p1" }],
|
||||
from: { id: senderId, username: "random" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
getFile: getFileSpy,
|
||||
});
|
||||
}
|
||||
|
||||
expect(replySpy, testCase.name).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy, testCase.name).toHaveBeenCalledTimes(testCase.expectedSendCount);
|
||||
if (testCase.expectPairingText) {
|
||||
expect(sendMessageSpy.mock.calls[0]?.[0], testCase.name).toBe(1234);
|
||||
const pairingText = String(sendMessageSpy.mock.calls[0]?.[1]);
|
||||
expect(pairingText, testCase.name).toContain("Your Telegram user id: 999");
|
||||
expect(pairingText, testCase.name).toContain("Pairing code:");
|
||||
expect(pairingText, testCase.name).toContain("PAIRME12");
|
||||
expect(pairingText, testCase.name).toContain("openclaw pairing approve telegram PAIRME12");
|
||||
expect(pairingText, testCase.name).not.toContain("<code>");
|
||||
expect(getFileSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
}
|
||||
});
|
||||
it("blocks unauthorized DM media before download and sends pairing reply", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "pairing" } },
|
||||
});
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
}),
|
||||
);
|
||||
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
|
||||
|
||||
try {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 410,
|
||||
date: 1736380800,
|
||||
photo: [{ file_id: "p1" }],
|
||||
from: { id: 999, username: "random" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: getFileSpy,
|
||||
});
|
||||
|
||||
expect(getFileSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
it("blocks DM media downloads completely when dmPolicy is disabled", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
@ -393,48 +425,51 @@ describe("createTelegramBot", () => {
|
||||
}
|
||||
});
|
||||
it("blocks unauthorized DM media groups before any photo download", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "pairing" } },
|
||||
});
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
}),
|
||||
);
|
||||
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
|
||||
|
||||
try {
|
||||
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 412,
|
||||
media_group_id: "dm-album-1",
|
||||
date: 1736380800,
|
||||
photo: [{ file_id: "p1" }],
|
||||
from: { id: 999, username: "random" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: getFileSpy,
|
||||
await withIsolatedStateDirAsync(async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
channels: { telegram: { dmPolicy: "pairing" } },
|
||||
});
|
||||
readChannelAllowFromStore.mockResolvedValue([]);
|
||||
upsertChannelPairingRequest.mockResolvedValue({ code: "PAIRME12", created: true });
|
||||
sendMessageSpy.mockClear();
|
||||
replySpy.mockClear();
|
||||
const senderId = Number(`${Date.now()}02`.slice(-9));
|
||||
|
||||
expect(getFileSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async () =>
|
||||
new Response(new Uint8Array([0xff, 0xd8, 0xff, 0x00]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/jpeg" },
|
||||
}),
|
||||
);
|
||||
const getFileSpy = vi.fn(async () => ({ file_path: "photos/p1.jpg" }));
|
||||
|
||||
try {
|
||||
createTelegramBot({ token: "tok", testTimings: TELEGRAM_TEST_TIMINGS });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 1234, type: "private" },
|
||||
message_id: 412,
|
||||
media_group_id: "dm-album-1",
|
||||
date: 1736380800,
|
||||
photo: [{ file_id: "p1" }],
|
||||
from: { id: senderId, username: "random" },
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: getFileSpy,
|
||||
});
|
||||
|
||||
expect(getFileSpy).not.toHaveBeenCalled();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
|
||||
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("Pairing code:");
|
||||
expect(replySpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
fetchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
it("triggers typing cue via onReplyStart", async () => {
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(
|
||||
@ -851,13 +886,15 @@ describe("createTelegramBot", () => {
|
||||
});
|
||||
|
||||
it("routes DMs by telegram accountId binding", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: {
|
||||
allowFrom: ["*"],
|
||||
accounts: {
|
||||
opie: {
|
||||
botToken: "tok-opie",
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -868,27 +905,30 @@ describe("createTelegramBot", () => {
|
||||
match: { channel: "telegram", accountId: "opie" },
|
||||
},
|
||||
],
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
|
||||
await withConfigPathAsync(config, async () => {
|
||||
createTelegramBot({ token: "tok", accountId: "opie" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.AccountId).toBe("opie");
|
||||
expect(payload.SessionKey).toBe("agent:opie:main");
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok", accountId: "opie" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "private" },
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.AccountId).toBe("opie");
|
||||
expect(payload.SessionKey).toBe("agent:opie:main");
|
||||
});
|
||||
|
||||
it("reloads DM routing bindings between messages without recreating the bot", async () => {
|
||||
@ -1192,26 +1232,28 @@ describe("createTelegramBot", () => {
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
resetHarnessSpies();
|
||||
loadConfig.mockReturnValue(testCase.config);
|
||||
await dispatchMessage({
|
||||
message: {
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
await withConfigPathAsync(testCase.config, async () => {
|
||||
resetHarnessSpies();
|
||||
loadConfig.mockReturnValue(testCase.config);
|
||||
await dispatchMessage({
|
||||
message: {
|
||||
chat: {
|
||||
id: -1001234567890,
|
||||
type: "supergroup",
|
||||
title: "Forum Group",
|
||||
is_forum: true,
|
||||
},
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: testCase.text,
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
from: { id: 999, username: "testuser" },
|
||||
text: testCase.text,
|
||||
date: 1736380800,
|
||||
message_id: 42,
|
||||
message_thread_id: 99,
|
||||
},
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
|
||||
});
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
const payload = replySpy.mock.calls[0][0];
|
||||
expect(payload.SessionKey).toContain(testCase.expectedSessionKeyFragment);
|
||||
}
|
||||
});
|
||||
|
||||
@ -1907,7 +1949,7 @@ describe("createTelegramBot", () => {
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
loadConfig.mockReturnValue({
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: {
|
||||
groupPolicy: "open",
|
||||
@ -1924,23 +1966,26 @@ describe("createTelegramBot", () => {
|
||||
},
|
||||
],
|
||||
session: { store: storePath },
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
|
||||
await withConfigPathAsync(config, async () => {
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Routing" },
|
||||
from: { id: 999, username: "ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
await handler({
|
||||
message: {
|
||||
chat: { id: 123, type: "group", title: "Routing" },
|
||||
from: { id: 999, username: "ops" },
|
||||
text: "hello",
|
||||
date: 1736380800,
|
||||
},
|
||||
me: { username: "openclaw_bot" },
|
||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||
});
|
||||
|
||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies topic skill filters and system prompts", async () => {
|
||||
|
||||
@ -13,10 +13,10 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/infra-runtime";
|
||||
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { getGlobalHookRunner } from "openclaw/plugin-sdk/plugin-runtime";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { danger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { loadWebMedia } from "openclaw/plugin-sdk/web-media";
|
||||
import type { TelegramInlineButtons } from "../button-types.js";
|
||||
import { splitTelegramCaption } from "../caption.js";
|
||||
@ -238,6 +238,7 @@ async function deliverMediaReply(params: {
|
||||
tableMode?: MarkdownTableMode;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
chunkText: ChunkTextFn;
|
||||
mediaLoader: typeof loadWebMedia;
|
||||
onVoiceRecording?: () => Promise<void> | void;
|
||||
linkPreview?: boolean;
|
||||
silent?: boolean;
|
||||
@ -252,7 +253,7 @@ async function deliverMediaReply(params: {
|
||||
let pendingFollowUpText: string | undefined;
|
||||
for (const mediaUrl of params.mediaList) {
|
||||
const isFirstMedia = first;
|
||||
const media = await loadWebMedia(
|
||||
const media = await params.mediaLoader(
|
||||
mediaUrl,
|
||||
buildOutboundMediaLoadOptions({ mediaLocalRoots: params.mediaLocalRoots }),
|
||||
);
|
||||
@ -569,12 +570,15 @@ export async function deliverReplies(params: {
|
||||
silent?: boolean;
|
||||
/** Optional quote text for Telegram reply_parameters. */
|
||||
replyQuoteText?: string;
|
||||
/** Override media loader (tests). */
|
||||
mediaLoader?: typeof loadWebMedia;
|
||||
}): Promise<{ delivered: boolean }> {
|
||||
const progress: DeliveryProgress = {
|
||||
hasReplied: false,
|
||||
hasDelivered: false,
|
||||
deliveredCount: 0,
|
||||
};
|
||||
const mediaLoader = params.mediaLoader ?? loadWebMedia;
|
||||
const hookRunner = getGlobalHookRunner();
|
||||
const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false;
|
||||
const hasMessageSentHooks = hookRunner?.hasHooks("message_sent") ?? false;
|
||||
@ -663,6 +667,7 @@ export async function deliverReplies(params: {
|
||||
tableMode: params.tableMode,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
chunkText,
|
||||
mediaLoader,
|
||||
onVoiceRecording: params.onVoiceRecording,
|
||||
linkPreview: params.linkPreview,
|
||||
silent: params.silent,
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { Bot } from "grammy";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../../../src/runtime.js";
|
||||
import { deliverReplies } from "./delivery.js";
|
||||
|
||||
const loadWebMedia = vi.fn();
|
||||
const { loadWebMedia } = vi.hoisted(() => ({
|
||||
loadWebMedia: vi.fn(),
|
||||
}));
|
||||
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const messageHookRunner = vi.hoisted(() => ({
|
||||
hasHooks: vi.fn<(name: string) => boolean>(() => false),
|
||||
@ -21,12 +22,15 @@ type DeliverWithParams = Omit<
|
||||
DeliverRepliesParams,
|
||||
"chatId" | "token" | "replyToMode" | "textLimit"
|
||||
> &
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit">>;
|
||||
Partial<Pick<DeliverRepliesParams, "replyToMode" | "textLimit" | "mediaLoader">>;
|
||||
type RuntimeStub = Pick<RuntimeEnv, "error" | "log" | "exit">;
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/web-media", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
vi.mock("openclaw/plugin-sdk/web-media.js", () => ({
|
||||
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({
|
||||
getGlobalHookRunner: () => messageHookRunner,
|
||||
@ -42,6 +46,9 @@ vi.mock("../../../../src/hooks/internal-hooks.js", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const { deliverReplies } = await import("./delivery.js");
|
||||
|
||||
vi.mock("grammy", () => ({
|
||||
InputFile: class {
|
||||
constructor(
|
||||
@ -70,6 +77,7 @@ async function deliverWith(params: DeliverWithParams) {
|
||||
await deliverReplies({
|
||||
...baseDeliveryParams,
|
||||
...params,
|
||||
mediaLoader: params.mediaLoader ?? loadWebMedia,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { tagTelegramNetworkError } from "./network-errors.js";
|
||||
|
||||
type MonitorTelegramOpts = import("./monitor.js").MonitorTelegramOpts;
|
||||
|
||||
@ -110,7 +109,8 @@ function makeRecoverableFetchError() {
|
||||
});
|
||||
}
|
||||
|
||||
function makeTaggedPollingFetchError() {
|
||||
async function makeTaggedPollingFetchError() {
|
||||
const { tagTelegramNetworkError } = await import("./network-errors.js");
|
||||
const err = makeRecoverableFetchError();
|
||||
tagTelegramNetworkError(err, {
|
||||
method: "getUpdates",
|
||||
@ -180,24 +180,41 @@ async function runMonitorAndCaptureStartupOrder(params?: { persistedOffset?: num
|
||||
|
||||
function mockRunOnceWithStalledPollingRunner(): {
|
||||
stop: ReturnType<typeof vi.fn<() => void | Promise<void>>>;
|
||||
waitForTaskStart: () => Promise<void>;
|
||||
} {
|
||||
let running = true;
|
||||
let releaseTask: (() => void) | undefined;
|
||||
let releaseBeforeTaskStart = false;
|
||||
let signalTaskStarted: (() => void) | undefined;
|
||||
const taskStarted = new Promise<void>((resolve) => {
|
||||
signalTaskStarted = resolve;
|
||||
});
|
||||
const stop = vi.fn(async () => {
|
||||
running = false;
|
||||
releaseTask?.();
|
||||
if (releaseTask) {
|
||||
releaseTask();
|
||||
return;
|
||||
}
|
||||
releaseBeforeTaskStart = true;
|
||||
});
|
||||
runSpy.mockImplementationOnce(() =>
|
||||
makeRunnerStub({
|
||||
task: () =>
|
||||
new Promise<void>((resolve) => {
|
||||
signalTaskStarted?.();
|
||||
releaseTask = resolve;
|
||||
if (releaseBeforeTaskStart) {
|
||||
resolve();
|
||||
}
|
||||
}),
|
||||
stop,
|
||||
isRunning: () => running,
|
||||
}),
|
||||
);
|
||||
return { stop };
|
||||
return {
|
||||
stop,
|
||||
waitForTaskStart: () => taskStarted,
|
||||
};
|
||||
}
|
||||
|
||||
function expectRecoverableRetryState(
|
||||
@ -533,16 +550,17 @@ describe("monitorTelegramProvider (grammY)", () => {
|
||||
it("force-restarts polling when unhandled network rejection stalls runner", async () => {
|
||||
const { monitorTelegramProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const { stop } = mockRunOnceWithStalledPollingRunner();
|
||||
mockRunOnceAndAbort(abort);
|
||||
const firstCycle = mockRunOnceWithStalledPollingRunner();
|
||||
mockRunOnceWithStalledPollingRunner();
|
||||
|
||||
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
|
||||
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(1));
|
||||
|
||||
emitUnhandledRejection(makeTaggedPollingFetchError());
|
||||
expect(emitUnhandledRejection(await makeTaggedPollingFetchError())).toBe(true);
|
||||
expect(firstCycle.stop).toHaveBeenCalledTimes(1);
|
||||
await vi.waitFor(() => expect(runSpy).toHaveBeenCalledTimes(2));
|
||||
abort.abort();
|
||||
await monitor;
|
||||
|
||||
expect(stop.mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
expectRecoverableRetryState(2);
|
||||
});
|
||||
|
||||
@ -578,16 +596,17 @@ describe("monitorTelegramProvider (grammY)", () => {
|
||||
it("aborts the active Telegram fetch when unhandled network rejection forces restart", async () => {
|
||||
const { monitorTelegramProvider } = await import("./monitor.js");
|
||||
const abort = new AbortController();
|
||||
const { stop } = mockRunOnceWithStalledPollingRunner();
|
||||
const { stop, waitForTaskStart } = mockRunOnceWithStalledPollingRunner();
|
||||
mockRunOnceAndAbort(abort);
|
||||
|
||||
const monitor = monitorTelegramProvider({ token: "tok", abortSignal: abort.signal });
|
||||
await vi.waitFor(() => expect(createTelegramBotCalls.length).toBeGreaterThanOrEqual(1));
|
||||
await waitForTaskStart();
|
||||
const firstSignal = createTelegramBotCalls[0]?.fetchAbortSignal;
|
||||
expect(firstSignal).toBeInstanceOf(AbortSignal);
|
||||
expect((firstSignal as AbortSignal).aborted).toBe(false);
|
||||
|
||||
emitUnhandledRejection(makeTaggedPollingFetchError());
|
||||
emitUnhandledRejection(await makeTaggedPollingFetchError());
|
||||
await monitor;
|
||||
|
||||
expect((firstSignal as AbortSignal).aborted).toBe(true);
|
||||
|
||||
@ -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"
|
||||
|
||||
36
extensions/whatsapp/src/active-listener.test.ts
Normal file
36
extensions/whatsapp/src/active-listener.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type ActiveListenerModule = typeof import("./active-listener.js");
|
||||
|
||||
const activeListenerModuleUrl = new URL("./active-listener.ts", import.meta.url).href;
|
||||
|
||||
async function importActiveListenerModule(cacheBust: string): Promise<ActiveListenerModule> {
|
||||
return (await import(`${activeListenerModuleUrl}?t=${cacheBust}`)) as ActiveListenerModule;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const mod = await importActiveListenerModule(`cleanup-${Date.now()}`);
|
||||
mod.setActiveWebListener(null);
|
||||
mod.setActiveWebListener("work", null);
|
||||
});
|
||||
|
||||
describe("active WhatsApp listener singleton", () => {
|
||||
it("shares listeners across duplicate module instances", async () => {
|
||||
const first = await importActiveListenerModule(`first-${Date.now()}`);
|
||||
const second = await importActiveListenerModule(`second-${Date.now()}`);
|
||||
const listener = {
|
||||
sendMessage: vi.fn(async () => ({ messageId: "msg-1" })),
|
||||
sendPoll: vi.fn(async () => ({ messageId: "poll-1" })),
|
||||
sendReaction: vi.fn(async () => {}),
|
||||
sendComposingTo: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
first.setActiveWebListener("work", listener);
|
||||
|
||||
expect(second.getActiveWebListener("work")).toBe(listener);
|
||||
expect(second.requireActiveWebListener("work")).toEqual({
|
||||
accountId: "work",
|
||||
listener,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -28,27 +28,22 @@ export type ActiveWebListener = {
|
||||
close?: () => Promise<void>;
|
||||
};
|
||||
|
||||
// Use a process-level singleton to survive bundler code-splitting.
|
||||
// Rolldown duplicates this module across multiple output chunks, each with its
|
||||
// own module-scoped `listeners` Map. The WhatsApp provider writes to one chunk's
|
||||
// Map via setActiveWebListener(), but the outbound send path reads from a
|
||||
// different chunk's Map via requireActiveWebListener() — so the listener is
|
||||
// never found. Pinning the Map to globalThis ensures all chunks share one
|
||||
// instance. See: https://github.com/openclaw/openclaw/issues/14406
|
||||
const GLOBAL_KEY = "__openclaw_wa_listeners" as const;
|
||||
const GLOBAL_CURRENT_KEY = "__openclaw_wa_current_listener" as const;
|
||||
// Use process-global symbol keys to survive bundler code-splitting and loader
|
||||
// cache splits without depending on fragile string property names.
|
||||
const GLOBAL_LISTENERS_KEY = Symbol.for("openclaw.whatsapp.activeListeners");
|
||||
const GLOBAL_CURRENT_KEY = Symbol.for("openclaw.whatsapp.currentListener");
|
||||
|
||||
type GlobalWithListeners = typeof globalThis & {
|
||||
[GLOBAL_KEY]?: Map<string, ActiveWebListener>;
|
||||
[GLOBAL_LISTENERS_KEY]?: Map<string, ActiveWebListener>;
|
||||
[GLOBAL_CURRENT_KEY]?: ActiveWebListener | null;
|
||||
};
|
||||
|
||||
const _global = globalThis as GlobalWithListeners;
|
||||
|
||||
_global[GLOBAL_KEY] ??= new Map<string, ActiveWebListener>();
|
||||
_global[GLOBAL_LISTENERS_KEY] ??= new Map<string, ActiveWebListener>();
|
||||
_global[GLOBAL_CURRENT_KEY] ??= null;
|
||||
|
||||
const listeners = _global[GLOBAL_KEY];
|
||||
const listeners = _global[GLOBAL_LISTENERS_KEY];
|
||||
|
||||
function getCurrentListener(): ActiveWebListener | null {
|
||||
return _global[GLOBAL_CURRENT_KEY] ?? null;
|
||||
|
||||
@ -71,12 +71,23 @@ vi.mock("../../../../src/infra/heartbeat-events.js", () => ({
|
||||
resolveIndicatorType: (status: string) => `indicator:${status}`,
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/logging.js", () => ({
|
||||
getChildLogger: () => ({
|
||||
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
|
||||
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../../../src/logging.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../../src/logging.js")>();
|
||||
const createStubLogger = () => ({
|
||||
info: () => undefined,
|
||||
warn: () => undefined,
|
||||
error: () => undefined,
|
||||
child: createStubLogger,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
getChildLogger: () => ({
|
||||
info: (...args: unknown[]) => state.loggerInfoCalls.push(args),
|
||||
warn: (...args: unknown[]) => state.loggerWarnCalls.push(args),
|
||||
}),
|
||||
createSubsystemLogger: () => createStubLogger(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/state-paths", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/state-paths")>();
|
||||
@ -125,10 +136,14 @@ vi.mock("../reconnect.js", () => ({
|
||||
newConnectionId: () => "run-1",
|
||||
}));
|
||||
|
||||
vi.mock("../send.js", () => ({
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
|
||||
sendReactionWhatsApp: vi.fn(async () => undefined),
|
||||
}));
|
||||
vi.mock("../send.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../send.js")>();
|
||||
return {
|
||||
...actual,
|
||||
sendMessageWhatsApp: vi.fn(async () => ({ messageId: "m1" })),
|
||||
sendReactionWhatsApp: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../session.js", () => ({
|
||||
formatError: (err: unknown) => `ERR:${String(err)}`,
|
||||
|
||||
@ -34,15 +34,21 @@ export function resetLoadConfigMock() {
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => {
|
||||
const mockModule = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
|
||||
Object.defineProperty(mockModule, "loadConfig", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: () => {
|
||||
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
|
||||
if (typeof getter === "function") {
|
||||
return getter();
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
},
|
||||
});
|
||||
Object.assign(mockModule, {
|
||||
updateLastRoute: async (params: {
|
||||
storePath: string;
|
||||
sessionKey: string;
|
||||
@ -68,7 +74,8 @@ vi.mock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => {
|
||||
},
|
||||
recordSessionMetaFromInbound: async () => undefined,
|
||||
resolveStorePath: actual.resolveStorePath,
|
||||
};
|
||||
});
|
||||
return mockModule;
|
||||
});
|
||||
|
||||
// Some web modules live under `src/web/auto-reply/*` and import config via a different
|
||||
@ -79,16 +86,21 @@ vi.mock("../../config/config.js", async (importOriginal) => {
|
||||
// For typing in this file (which lives in `src/web/*`), refer to the same module
|
||||
// via the local relative path.
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/config-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
loadConfig: () => {
|
||||
const mockModule = Object.create(null) as Record<string, unknown>;
|
||||
Object.defineProperties(mockModule, Object.getOwnPropertyDescriptors(actual));
|
||||
Object.defineProperty(mockModule, "loadConfig", {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: () => {
|
||||
const getter = (globalThis as Record<symbol, unknown>)[CONFIG_KEY];
|
||||
if (typeof getter === "function") {
|
||||
return getter();
|
||||
}
|
||||
return DEFAULT_CONFIG;
|
||||
},
|
||||
};
|
||||
});
|
||||
return mockModule;
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
|
||||
|
||||
340
infra/azure/templates/azuredeploy.json
Normal file
340
infra/azure/templates/azuredeploy.json
Normal file
@ -0,0 +1,340 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"location": {
|
||||
"type": "string",
|
||||
"defaultValue": "westus2",
|
||||
"metadata": {
|
||||
"description": "Azure region for all resources. Any valid Azure region is allowed (no allowedValues restriction)."
|
||||
}
|
||||
},
|
||||
"vmName": {
|
||||
"type": "string",
|
||||
"defaultValue": "vm-openclaw",
|
||||
"metadata": {
|
||||
"description": "OpenClaw VM name."
|
||||
}
|
||||
},
|
||||
"vmSize": {
|
||||
"type": "string",
|
||||
"defaultValue": "Standard_B2as_v2",
|
||||
"metadata": {
|
||||
"description": "Azure VM size for OpenClaw host."
|
||||
}
|
||||
},
|
||||
"adminUsername": {
|
||||
"type": "string",
|
||||
"defaultValue": "openclaw",
|
||||
"minLength": 1,
|
||||
"maxLength": 32,
|
||||
"metadata": {
|
||||
"description": "Linux admin username."
|
||||
}
|
||||
},
|
||||
"sshPublicKey": {
|
||||
"type": "string",
|
||||
"metadata": {
|
||||
"description": "SSH public key content (for example ssh-ed25519 ...)."
|
||||
}
|
||||
},
|
||||
"vnetName": {
|
||||
"type": "string",
|
||||
"defaultValue": "vnet-openclaw",
|
||||
"metadata": {
|
||||
"description": "Virtual network name."
|
||||
}
|
||||
},
|
||||
"vnetAddressPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.0.0/16",
|
||||
"metadata": {
|
||||
"description": "Address space for the virtual network."
|
||||
}
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"type": "string",
|
||||
"defaultValue": "snet-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "Subnet name for OpenClaw VM."
|
||||
}
|
||||
},
|
||||
"vmSubnetPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.2.0/24",
|
||||
"metadata": {
|
||||
"description": "Address prefix for VM subnet."
|
||||
}
|
||||
},
|
||||
"bastionSubnetPrefix": {
|
||||
"type": "string",
|
||||
"defaultValue": "10.40.1.0/26",
|
||||
"metadata": {
|
||||
"description": "Address prefix for AzureBastionSubnet (must be /26 or larger)."
|
||||
}
|
||||
},
|
||||
"nsgName": {
|
||||
"type": "string",
|
||||
"defaultValue": "nsg-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "Network security group for VM subnet."
|
||||
}
|
||||
},
|
||||
"nicName": {
|
||||
"type": "string",
|
||||
"defaultValue": "nic-openclaw-vm",
|
||||
"metadata": {
|
||||
"description": "NIC for OpenClaw VM."
|
||||
}
|
||||
},
|
||||
"bastionName": {
|
||||
"type": "string",
|
||||
"defaultValue": "bas-openclaw",
|
||||
"metadata": {
|
||||
"description": "Azure Bastion host name."
|
||||
}
|
||||
},
|
||||
"bastionPublicIpName": {
|
||||
"type": "string",
|
||||
"defaultValue": "pip-openclaw-bastion",
|
||||
"metadata": {
|
||||
"description": "Public IP used by Bastion."
|
||||
}
|
||||
},
|
||||
"osDiskSizeGb": {
|
||||
"type": "int",
|
||||
"defaultValue": 64,
|
||||
"minValue": 30,
|
||||
"maxValue": 1024,
|
||||
"metadata": {
|
||||
"description": "OS disk size in GiB."
|
||||
}
|
||||
}
|
||||
},
|
||||
"variables": {
|
||||
"bastionSubnetName": "AzureBastionSubnet"
|
||||
},
|
||||
"resources": [
|
||||
{
|
||||
"type": "Microsoft.Network/networkSecurityGroups",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('nsgName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"properties": {
|
||||
"securityRules": [
|
||||
{
|
||||
"name": "AllowSshFromAzureBastionSubnet",
|
||||
"properties": {
|
||||
"priority": 100,
|
||||
"access": "Allow",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "[parameters('bastionSubnetPrefix')]",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DenyInternetSsh",
|
||||
"properties": {
|
||||
"priority": 110,
|
||||
"access": "Deny",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "Internet",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "DenyVnetSsh",
|
||||
"properties": {
|
||||
"priority": 120,
|
||||
"access": "Deny",
|
||||
"direction": "Inbound",
|
||||
"protocol": "Tcp",
|
||||
"sourcePortRange": "*",
|
||||
"destinationPortRange": "22",
|
||||
"sourceAddressPrefix": "VirtualNetwork",
|
||||
"destinationAddressPrefix": "*"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/virtualNetworks",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('vnetName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"properties": {
|
||||
"addressSpace": {
|
||||
"addressPrefixes": ["[parameters('vnetAddressPrefix')]"]
|
||||
},
|
||||
"subnets": [
|
||||
{
|
||||
"name": "[variables('bastionSubnetName')]",
|
||||
"properties": {
|
||||
"addressPrefix": "[parameters('bastionSubnetPrefix')]"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "[parameters('vmSubnetName')]",
|
||||
"properties": {
|
||||
"addressPrefix": "[parameters('vmSubnetPrefix')]",
|
||||
"networkSecurityGroup": {
|
||||
"id": "[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"dependsOn": [
|
||||
"[resourceId('Microsoft.Network/networkSecurityGroups', parameters('nsgName'))]"
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/publicIPAddresses",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('bastionPublicIpName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"sku": {
|
||||
"name": "Standard"
|
||||
},
|
||||
"properties": {
|
||||
"publicIPAllocationMethod": "Static"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/bastionHosts",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('bastionName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"sku": {
|
||||
"name": "Standard"
|
||||
},
|
||||
"dependsOn": [
|
||||
"[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]",
|
||||
"[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
|
||||
],
|
||||
"properties": {
|
||||
"enableTunneling": true,
|
||||
"ipConfigurations": [
|
||||
{
|
||||
"name": "bastionIpConfig",
|
||||
"properties": {
|
||||
"subnet": {
|
||||
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), variables('bastionSubnetName'))]"
|
||||
},
|
||||
"publicIPAddress": {
|
||||
"id": "[resourceId('Microsoft.Network/publicIPAddresses', parameters('bastionPublicIpName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Network/networkInterfaces",
|
||||
"apiVersion": "2023-11-01",
|
||||
"name": "[parameters('nicName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"dependsOn": ["[resourceId('Microsoft.Network/virtualNetworks', parameters('vnetName'))]"],
|
||||
"properties": {
|
||||
"ipConfigurations": [
|
||||
{
|
||||
"name": "ipconfig1",
|
||||
"properties": {
|
||||
"privateIPAllocationMethod": "Dynamic",
|
||||
"subnet": {
|
||||
"id": "[resourceId('Microsoft.Network/virtualNetworks/subnets', parameters('vnetName'), parameters('vmSubnetName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "Microsoft.Compute/virtualMachines",
|
||||
"apiVersion": "2023-09-01",
|
||||
"name": "[parameters('vmName')]",
|
||||
"location": "[parameters('location')]",
|
||||
"dependsOn": ["[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"],
|
||||
"properties": {
|
||||
"hardwareProfile": {
|
||||
"vmSize": "[parameters('vmSize')]"
|
||||
},
|
||||
"osProfile": {
|
||||
"computerName": "[parameters('vmName')]",
|
||||
"adminUsername": "[parameters('adminUsername')]",
|
||||
"linuxConfiguration": {
|
||||
"disablePasswordAuthentication": true,
|
||||
"ssh": {
|
||||
"publicKeys": [
|
||||
{
|
||||
"path": "[concat('/home/', parameters('adminUsername'), '/.ssh/authorized_keys')]",
|
||||
"keyData": "[parameters('sshPublicKey')]"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"storageProfile": {
|
||||
"imageReference": {
|
||||
"publisher": "Canonical",
|
||||
"offer": "ubuntu-24_04-lts",
|
||||
"sku": "server",
|
||||
"version": "latest"
|
||||
},
|
||||
"osDisk": {
|
||||
"createOption": "FromImage",
|
||||
"diskSizeGB": "[parameters('osDiskSizeGb')]",
|
||||
"managedDisk": {
|
||||
"storageAccountType": "StandardSSD_LRS"
|
||||
}
|
||||
}
|
||||
},
|
||||
"networkProfile": {
|
||||
"networkInterfaces": [
|
||||
{
|
||||
"id": "[resourceId('Microsoft.Network/networkInterfaces', parameters('nicName'))]"
|
||||
}
|
||||
]
|
||||
},
|
||||
"diagnosticsProfile": {
|
||||
"bootDiagnostics": {
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"outputs": {
|
||||
"vmName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vmName')]"
|
||||
},
|
||||
"vmPrivateIp": {
|
||||
"type": "string",
|
||||
"value": "[reference(resourceId('Microsoft.Network/networkInterfaces', parameters('nicName')), '2023-11-01').ipConfigurations[0].properties.privateIPAddress]"
|
||||
},
|
||||
"vnetName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vnetName')]"
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('vmSubnetName')]"
|
||||
},
|
||||
"bastionName": {
|
||||
"type": "string",
|
||||
"value": "[parameters('bastionName')]"
|
||||
},
|
||||
"bastionResourceId": {
|
||||
"type": "string",
|
||||
"value": "[resourceId('Microsoft.Network/bastionHosts', parameters('bastionName'))]"
|
||||
}
|
||||
}
|
||||
}
|
||||
48
infra/azure/templates/azuredeploy.parameters.json
Normal file
48
infra/azure/templates/azuredeploy.parameters.json
Normal file
@ -0,0 +1,48 @@
|
||||
{
|
||||
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#",
|
||||
"contentVersion": "1.0.0.0",
|
||||
"parameters": {
|
||||
"location": {
|
||||
"value": "westus2"
|
||||
},
|
||||
"vmName": {
|
||||
"value": "vm-openclaw"
|
||||
},
|
||||
"vmSize": {
|
||||
"value": "Standard_B2as_v2"
|
||||
},
|
||||
"adminUsername": {
|
||||
"value": "openclaw"
|
||||
},
|
||||
"vnetName": {
|
||||
"value": "vnet-openclaw"
|
||||
},
|
||||
"vnetAddressPrefix": {
|
||||
"value": "10.40.0.0/16"
|
||||
},
|
||||
"vmSubnetName": {
|
||||
"value": "snet-openclaw-vm"
|
||||
},
|
||||
"vmSubnetPrefix": {
|
||||
"value": "10.40.2.0/24"
|
||||
},
|
||||
"bastionSubnetPrefix": {
|
||||
"value": "10.40.1.0/26"
|
||||
},
|
||||
"nsgName": {
|
||||
"value": "nsg-openclaw-vm"
|
||||
},
|
||||
"nicName": {
|
||||
"value": "nic-openclaw-vm"
|
||||
},
|
||||
"bastionName": {
|
||||
"value": "bas-openclaw"
|
||||
},
|
||||
"bastionPublicIpName": {
|
||||
"value": "pip-openclaw-bastion"
|
||||
},
|
||||
"osDiskSizeGb": {
|
||||
"value": 64
|
||||
}
|
||||
}
|
||||
}
|
||||
35
openclaw.mjs
35
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#<ref>` instead of a raw `/archive/<ref>.tar.gz` URL.",
|
||||
);
|
||||
lines.push("For releases, use `npm install -g openclaw@latest`.");
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
if (await tryImport("./dist/entry.js")) {
|
||||
// OK
|
||||
} else if (await tryImport("./dist/entry.mjs")) {
|
||||
// OK
|
||||
} else {
|
||||
throw new Error("openclaw: missing dist/entry.(m)js (build output).");
|
||||
throw new Error(await buildMissingEntryErrorMessage());
|
||||
}
|
||||
|
||||
24
package.json
24
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"
|
||||
|
||||
1069
pnpm-lock.yaml
generated
1069
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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",
|
||||
|
||||
@ -12,6 +12,7 @@ import * as heartbeatWake from "../infra/heartbeat-wake.js";
|
||||
import {
|
||||
__testing as sessionBindingServiceTesting,
|
||||
registerSessionBindingAdapter,
|
||||
type SessionBindingPlacement,
|
||||
type SessionBindingRecord,
|
||||
} from "../infra/outbound/session-binding-service.js";
|
||||
import * as acpSpawnParentStream from "./acp-spawn-parent-stream.js";
|
||||
@ -104,7 +105,7 @@ function createSessionBindingCapabilities() {
|
||||
adapterAvailable: true,
|
||||
bindSupported: true,
|
||||
unbindSupported: true,
|
||||
placements: ["current", "child"] as const,
|
||||
placements: ["current", "child"] satisfies SessionBindingPlacement[],
|
||||
};
|
||||
}
|
||||
|
||||
@ -179,8 +180,8 @@ describe("spawnAcpDirect", () => {
|
||||
metaCleared: false,
|
||||
});
|
||||
getAcpSessionManagerSpy.mockReset().mockReturnValue({
|
||||
initializeSession: async (params) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params) => await hoisted.closeSessionMock(params),
|
||||
initializeSession: async (params: unknown) => await hoisted.initializeSessionMock(params),
|
||||
closeSession: async (params: unknown) => await hoisted.closeSessionMock(params),
|
||||
} as unknown as ReturnType<typeof acpSessionManager.getAcpSessionManager>);
|
||||
hoisted.initializeSessionMock.mockReset().mockImplementation(async (argsUnknown: unknown) => {
|
||||
const args = argsUnknown as {
|
||||
@ -1039,7 +1040,7 @@ describe("spawnAcpDirect", () => {
|
||||
...hoisted.state.cfg.channels,
|
||||
telegram: {
|
||||
threadBindings: {
|
||||
spawnAcpSessions: true,
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@ -11,20 +11,12 @@ function createFlushOnParagraphChunker(params: { minChars: number; maxChars: num
|
||||
});
|
||||
}
|
||||
|
||||
function drainChunks(chunker: EmbeddedBlockChunker) {
|
||||
function drainChunks(chunker: EmbeddedBlockChunker, force = false) {
|
||||
const chunks: string[] = [];
|
||||
chunker.drain({ force: false, emit: (chunk) => chunks.push(chunk) });
|
||||
chunker.drain({ force, emit: (chunk) => chunks.push(chunk) });
|
||||
return chunks;
|
||||
}
|
||||
|
||||
function expectFlushAtFirstParagraphBreak(text: string) {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
chunker.append(text);
|
||||
const chunks = drainChunks(chunker);
|
||||
expect(chunks).toEqual(["First paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Second paragraph.");
|
||||
}
|
||||
|
||||
describe("EmbeddedBlockChunker", () => {
|
||||
it("breaks at paragraph boundary right after fence close", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
@ -54,12 +46,25 @@ describe("EmbeddedBlockChunker", () => {
|
||||
expect(chunker.bufferedText).toMatch(/^After/);
|
||||
});
|
||||
|
||||
it("flushes paragraph boundaries before minChars when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n\nSecond paragraph.");
|
||||
it("waits until minChars before flushing paragraph boundaries when flushOnParagraph is set", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 30, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n\nSecond paragraph.\n\nThird paragraph.");
|
||||
|
||||
const chunks = drainChunks(chunker);
|
||||
|
||||
expect(chunks).toEqual(["First paragraph.\n\nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("Third paragraph.");
|
||||
});
|
||||
|
||||
it("treats blank lines with whitespace as paragraph boundaries when flushOnParagraph is set", () => {
|
||||
expectFlushAtFirstParagraphBreak("First paragraph.\n \nSecond paragraph.");
|
||||
it("still force flushes buffered paragraphs below minChars at the end", () => {
|
||||
const chunker = createFlushOnParagraphChunker({ minChars: 100, maxChars: 200 });
|
||||
|
||||
chunker.append("First paragraph.\n \nSecond paragraph.");
|
||||
|
||||
expect(drainChunks(chunker)).toEqual([]);
|
||||
expect(drainChunks(chunker, true)).toEqual(["First paragraph.\n \nSecond paragraph."]);
|
||||
expect(chunker.bufferedText).toBe("");
|
||||
});
|
||||
|
||||
it("falls back to maxChars when flushOnParagraph is set and no paragraph break exists", () => {
|
||||
@ -97,7 +102,7 @@ describe("EmbeddedBlockChunker", () => {
|
||||
|
||||
it("ignores paragraph breaks inside fences when flushOnParagraph is set", () => {
|
||||
const chunker = new EmbeddedBlockChunker({
|
||||
minChars: 100,
|
||||
minChars: 10,
|
||||
maxChars: 200,
|
||||
breakPreference: "paragraph",
|
||||
flushOnParagraph: true,
|
||||
|
||||
@ -5,7 +5,7 @@ export type BlockReplyChunking = {
|
||||
minChars: number;
|
||||
maxChars: number;
|
||||
breakPreference?: "paragraph" | "newline" | "sentence";
|
||||
/** When true, flush eagerly on \n\n paragraph boundaries regardless of minChars. */
|
||||
/** When true, prefer \n\n paragraph boundaries once minChars has been satisfied. */
|
||||
flushOnParagraph?: boolean;
|
||||
};
|
||||
|
||||
@ -129,7 +129,7 @@ export class EmbeddedBlockChunker {
|
||||
const minChars = Math.max(1, Math.floor(this.#chunking.minChars));
|
||||
const maxChars = Math.max(minChars, Math.floor(this.#chunking.maxChars));
|
||||
|
||||
if (this.#buffer.length < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (this.#buffer.length < minChars && !force) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -150,12 +150,12 @@ export class EmbeddedBlockChunker {
|
||||
const reopenPrefix = reopenFence ? `${reopenFence.openLine}\n` : "";
|
||||
const remainingLength = reopenPrefix.length + (source.length - start);
|
||||
|
||||
if (!force && !this.#chunking.flushOnParagraph && remainingLength < minChars) {
|
||||
if (!force && remainingLength < minChars) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.#chunking.flushOnParagraph && !force) {
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start);
|
||||
const paragraphBreak = findNextParagraphBreak(source, fenceSpans, start, minChars);
|
||||
const paragraphLimit = Math.max(1, maxChars - reopenPrefix.length);
|
||||
if (paragraphBreak && paragraphBreak.index - start <= paragraphLimit) {
|
||||
const chunk = `${reopenPrefix}${source.slice(start, paragraphBreak.index)}`;
|
||||
@ -175,12 +175,7 @@ export class EmbeddedBlockChunker {
|
||||
const breakResult =
|
||||
force && remainingLength <= maxChars
|
||||
? this.#pickSoftBreakIndex(view, fenceSpans, 1, start)
|
||||
: this.#pickBreakIndex(
|
||||
view,
|
||||
fenceSpans,
|
||||
force || this.#chunking.flushOnParagraph ? 1 : undefined,
|
||||
start,
|
||||
);
|
||||
: this.#pickBreakIndex(view, fenceSpans, force ? 1 : undefined, start);
|
||||
if (breakResult.index <= 0) {
|
||||
if (force) {
|
||||
emit(`${reopenPrefix}${source.slice(start)}`);
|
||||
@ -205,7 +200,7 @@ export class EmbeddedBlockChunker {
|
||||
|
||||
const nextLength =
|
||||
(reopenFence ? `${reopenFence.openLine}\n`.length : 0) + (source.length - start);
|
||||
if (nextLength < minChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
if (nextLength < minChars && !force) {
|
||||
break;
|
||||
}
|
||||
if (nextLength < maxChars && !force && !this.#chunking.flushOnParagraph) {
|
||||
@ -401,6 +396,7 @@ function findNextParagraphBreak(
|
||||
buffer: string,
|
||||
fenceSpans: FenceSpan[],
|
||||
startIndex = 0,
|
||||
minCharsFromStart = 1,
|
||||
): ParagraphBreak | null {
|
||||
if (startIndex < 0) {
|
||||
return null;
|
||||
@ -413,6 +409,9 @@ function findNextParagraphBreak(
|
||||
if (index < 0) {
|
||||
continue;
|
||||
}
|
||||
if (index - startIndex < minCharsFromStart) {
|
||||
continue;
|
||||
}
|
||||
if (!isSafeFenceBreak(fenceSpans, index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -154,10 +154,23 @@ function shouldStripResponsesStore(
|
||||
return OPENAI_RESPONSES_APIS.has(model.api) && model.compat?.supportsStore === false;
|
||||
}
|
||||
|
||||
function shouldStripResponsesPromptCache(model: { api?: unknown; baseUrl?: unknown }): boolean {
|
||||
if (typeof model.api !== "string" || !OPENAI_RESPONSES_APIS.has(model.api)) {
|
||||
return false;
|
||||
}
|
||||
// Missing baseUrl means pi-ai will use the default OpenAI endpoint, so keep
|
||||
// prompt cache fields for that direct path.
|
||||
if (typeof model.baseUrl !== "string" || !model.baseUrl.trim()) {
|
||||
return false;
|
||||
}
|
||||
return !isDirectOpenAIBaseUrl(model.baseUrl);
|
||||
}
|
||||
|
||||
function applyOpenAIResponsesPayloadOverrides(params: {
|
||||
payloadObj: Record<string, unknown>;
|
||||
forceStore: boolean;
|
||||
stripStore: boolean;
|
||||
stripPromptCache: boolean;
|
||||
useServerCompaction: boolean;
|
||||
compactThreshold: number;
|
||||
}): void {
|
||||
@ -167,6 +180,10 @@ function applyOpenAIResponsesPayloadOverrides(params: {
|
||||
if (params.stripStore) {
|
||||
delete params.payloadObj.store;
|
||||
}
|
||||
if (params.stripPromptCache) {
|
||||
delete params.payloadObj.prompt_cache_key;
|
||||
delete params.payloadObj.prompt_cache_retention;
|
||||
}
|
||||
if (params.useServerCompaction && params.payloadObj.context_management === undefined) {
|
||||
params.payloadObj.context_management = [
|
||||
{
|
||||
@ -297,7 +314,8 @@ export function createOpenAIResponsesContextManagementWrapper(
|
||||
const forceStore = shouldForceResponsesStore(model);
|
||||
const useServerCompaction = shouldEnableOpenAIResponsesServerCompaction(model, extraParams);
|
||||
const stripStore = shouldStripResponsesStore(model, forceStore);
|
||||
if (!forceStore && !useServerCompaction && !stripStore) {
|
||||
const stripPromptCache = shouldStripResponsesPromptCache(model);
|
||||
if (!forceStore && !useServerCompaction && !stripStore && !stripPromptCache) {
|
||||
return underlying(model, context, options);
|
||||
}
|
||||
|
||||
@ -313,6 +331,7 @@ export function createOpenAIResponsesContextManagementWrapper(
|
||||
payloadObj: payload as Record<string, unknown>,
|
||||
forceStore,
|
||||
stripStore,
|
||||
stripPromptCache,
|
||||
useServerCompaction,
|
||||
compactThreshold,
|
||||
});
|
||||
|
||||
@ -68,8 +68,8 @@ const readLatestAssistantReplyMock = vi.fn(
|
||||
const embeddedRunMock = {
|
||||
isEmbeddedPiRunActive: vi.fn(() => false),
|
||||
isEmbeddedPiRunStreaming: vi.fn(() => false),
|
||||
queueEmbeddedPiMessage: vi.fn(() => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async () => true),
|
||||
queueEmbeddedPiMessage: vi.fn((_: string, __: string) => false),
|
||||
waitForEmbeddedPiRunEnd: vi.fn(async (_: string, __?: number) => true),
|
||||
};
|
||||
const { subagentRegistryMock } = vi.hoisted(() => ({
|
||||
subagentRegistryMock: {
|
||||
@ -131,11 +131,17 @@ function setConfigOverride(next: OpenClawConfig): void {
|
||||
setRuntimeConfigSnapshot(configOverride);
|
||||
}
|
||||
|
||||
function loadSessionStoreFixture(): Record<string, Record<string, unknown>> {
|
||||
return new Proxy(sessionStore, {
|
||||
function loadSessionStoreFixture(): ReturnType<typeof configSessions.loadSessionStore> {
|
||||
return new Proxy(sessionStore as ReturnType<typeof configSessions.loadSessionStore>, {
|
||||
get(target, key: string | symbol) {
|
||||
if (typeof key === "string" && !(key in target) && key.includes(":subagent:")) {
|
||||
return { inputTokens: 1, outputTokens: 1, totalTokens: 2 };
|
||||
return {
|
||||
sessionId: key,
|
||||
updatedAt: Date.now(),
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
totalTokens: 2,
|
||||
};
|
||||
}
|
||||
return target[key as keyof typeof target];
|
||||
},
|
||||
@ -207,7 +213,11 @@ describe("subagent announce formatting", () => {
|
||||
resolveAgentIdFromSessionKeySpy.mockReset().mockImplementation(() => "main");
|
||||
resolveStorePathSpy.mockReset().mockImplementation(() => "/tmp/sessions.json");
|
||||
resolveMainSessionKeySpy.mockReset().mockImplementation(() => "agent:main:main");
|
||||
getGlobalHookRunnerSpy.mockReset().mockImplementation(() => hookRunnerMock);
|
||||
getGlobalHookRunnerSpy
|
||||
.mockReset()
|
||||
.mockImplementation(
|
||||
() => hookRunnerMock as unknown as ReturnType<typeof hookRunnerGlobal.getGlobalHookRunner>,
|
||||
);
|
||||
readLatestAssistantReplySpy
|
||||
.mockReset()
|
||||
.mockImplementation(async (params) => await readLatestAssistantReplyMock(params?.sessionKey));
|
||||
|
||||
@ -102,7 +102,7 @@ function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEn
|
||||
return explicit ? { mode: "iana", timeZone: explicit } : { mode: "utc" };
|
||||
}
|
||||
|
||||
function formatTimestamp(
|
||||
export function formatEnvelopeTimestamp(
|
||||
ts: number | Date | undefined,
|
||||
options?: EnvelopeFormatOptions,
|
||||
): string | undefined {
|
||||
@ -179,7 +179,7 @@ export function formatAgentEnvelope(params: AgentEnvelopeParams): string {
|
||||
if (params.ip?.trim()) {
|
||||
parts.push(sanitizeEnvelopeHeaderPart(params.ip.trim()));
|
||||
}
|
||||
const ts = formatTimestamp(params.timestamp, resolved);
|
||||
const ts = formatEnvelopeTimestamp(params.timestamp, resolved);
|
||||
if (ts) {
|
||||
parts.push(ts);
|
||||
}
|
||||
|
||||
@ -89,8 +89,8 @@ export function createBlockReplyCoalescer(params: {
|
||||
return;
|
||||
}
|
||||
|
||||
// When flushOnEnqueue is set (chunkMode="newline"), each enqueued payload is treated
|
||||
// as a separate paragraph and flushed immediately so delivery matches streaming boundaries.
|
||||
// When flushOnEnqueue is set, treat each enqueued payload as its own outbound block
|
||||
// and flush immediately instead of waiting for coalescing thresholds.
|
||||
if (flushOnEnqueue) {
|
||||
if (bufferText) {
|
||||
void flush({ force: true });
|
||||
|
||||
@ -44,6 +44,34 @@ describe("resolveEffectiveBlockStreamingConfig", () => {
|
||||
expect(resolved.coalescing.idleMs).toBe(0);
|
||||
});
|
||||
|
||||
it("honors newline chunkMode for plugin channels even before the plugin registry is loaded", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
bluebubbles: {
|
||||
chunkMode: "newline",
|
||||
},
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
blockStreamingChunk: {
|
||||
minChars: 1,
|
||||
maxChars: 4000,
|
||||
breakPreference: "paragraph",
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const resolved = resolveEffectiveBlockStreamingConfig({
|
||||
cfg,
|
||||
provider: "bluebubbles",
|
||||
});
|
||||
|
||||
expect(resolved.chunking.flushOnParagraph).toBe(true);
|
||||
expect(resolved.coalescing.flushOnEnqueue).toBeUndefined();
|
||||
expect(resolved.coalescing.joiner).toBe("\n\n");
|
||||
});
|
||||
|
||||
it("allows ACP maxChunkChars overrides above base defaults up to provider text limits", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@ -3,26 +3,22 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { BlockStreamingCoalesceConfig } from "../../config/types.js";
|
||||
import { resolveAccountEntry } from "../../routing/account-lookup.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
import {
|
||||
INTERNAL_MESSAGE_CHANNEL,
|
||||
listDeliverableMessageChannels,
|
||||
} from "../../utils/message-channel.js";
|
||||
import { normalizeMessageChannel } from "../../utils/message-channel.js";
|
||||
import { resolveChunkMode, resolveTextChunkLimit, type TextChunkProvider } from "../chunk.js";
|
||||
|
||||
const DEFAULT_BLOCK_STREAM_MIN = 800;
|
||||
const DEFAULT_BLOCK_STREAM_MAX = 1200;
|
||||
const DEFAULT_BLOCK_STREAM_COALESCE_IDLE_MS = 1000;
|
||||
const getBlockChunkProviders = () =>
|
||||
new Set<TextChunkProvider>([...listDeliverableMessageChannels(), INTERNAL_MESSAGE_CHANNEL]);
|
||||
|
||||
function normalizeChunkProvider(provider?: string): TextChunkProvider | undefined {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
const cleaned = provider.trim().toLowerCase();
|
||||
return getBlockChunkProviders().has(cleaned as TextChunkProvider)
|
||||
? (cleaned as TextChunkProvider)
|
||||
: undefined;
|
||||
const normalized = normalizeMessageChannel(provider);
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
return normalized as TextChunkProvider;
|
||||
}
|
||||
|
||||
function resolveProviderChunkContext(
|
||||
@ -70,7 +66,7 @@ export type BlockStreamingCoalescing = {
|
||||
maxChars: number;
|
||||
idleMs: number;
|
||||
joiner: string;
|
||||
/** When true, the coalescer flushes the buffer on each enqueue (paragraph-boundary flush). */
|
||||
/** Internal escape hatch for transports that truly need per-enqueue flushing. */
|
||||
flushOnEnqueue?: boolean;
|
||||
};
|
||||
|
||||
@ -151,7 +147,7 @@ export function resolveEffectiveBlockStreamingConfig(params: {
|
||||
: chunking.breakPreference === "newline"
|
||||
? "\n"
|
||||
: "\n\n"),
|
||||
flushOnEnqueue: coalescingDefaults?.flushOnEnqueue ?? chunking.flushOnParagraph === true,
|
||||
...(coalescingDefaults?.flushOnEnqueue === true ? { flushOnEnqueue: true } : {}),
|
||||
};
|
||||
|
||||
return { chunking, coalescing };
|
||||
@ -165,9 +161,9 @@ export function resolveBlockStreamingChunking(
|
||||
const { providerKey, textLimit } = resolveProviderChunkContext(cfg, provider, accountId);
|
||||
const chunkCfg = cfg?.agents?.defaults?.blockStreamingChunk;
|
||||
|
||||
// When chunkMode="newline", the outbound delivery splits on paragraph boundaries.
|
||||
// The block chunker should flush eagerly on \n\n boundaries during streaming,
|
||||
// regardless of minChars, so each paragraph is sent as its own message.
|
||||
// When chunkMode="newline", outbound delivery prefers paragraph boundaries.
|
||||
// Keep the chunker paragraph-aware during streaming, but still let minChars
|
||||
// control when a buffered paragraph is ready to flush.
|
||||
const chunkMode = resolveChunkMode(cfg, providerKey, accountId);
|
||||
|
||||
const maxRequested = Math.max(1, Math.floor(chunkCfg?.maxChars ?? DEFAULT_BLOCK_STREAM_MAX));
|
||||
@ -196,7 +192,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars: number;
|
||||
breakPreference: "paragraph" | "newline" | "sentence";
|
||||
},
|
||||
opts?: { chunkMode?: "length" | "newline" },
|
||||
): BlockStreamingCoalescing | undefined {
|
||||
const { providerKey, providerId, textLimit } = resolveProviderChunkContext(
|
||||
cfg,
|
||||
@ -204,9 +199,6 @@ export function resolveBlockStreamingCoalescing(
|
||||
accountId,
|
||||
);
|
||||
|
||||
// Resolve the outbound chunkMode so the coalescer can flush on paragraph boundaries
|
||||
// when chunkMode="newline", matching the delivery-time splitting behavior.
|
||||
const chunkMode = opts?.chunkMode ?? resolveChunkMode(cfg, providerKey, accountId);
|
||||
const providerDefaults = providerId
|
||||
? getChannelPlugin(providerId)?.streaming?.blockStreamingCoalesceDefaults
|
||||
: undefined;
|
||||
@ -241,6 +233,5 @@ export function resolveBlockStreamingCoalescing(
|
||||
maxChars,
|
||||
idleMs,
|
||||
joiner,
|
||||
flushOnEnqueue: chunkMode === "newline",
|
||||
};
|
||||
}
|
||||
|
||||
@ -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 ??
|
||||
|
||||
@ -120,7 +120,7 @@ type FakeBinding = {
|
||||
targetSessionKey: string;
|
||||
targetKind: "subagent" | "session";
|
||||
conversation: {
|
||||
channel: "discord" | "telegram" | "feishu";
|
||||
channel: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
@ -245,9 +245,10 @@ function createSessionBindingCapabilities() {
|
||||
type AcpBindInput = {
|
||||
targetSessionKey: string;
|
||||
conversation: {
|
||||
channel?: "discord" | "telegram" | "feishu";
|
||||
channel?: "discord" | "matrix" | "telegram" | "feishu";
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
};
|
||||
placement: "current" | "child";
|
||||
metadata?: Record<string, unknown>;
|
||||
@ -266,17 +267,27 @@ function createAcpThreadBinding(input: AcpBindInput): FakeBinding {
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId: "parent-1",
|
||||
}
|
||||
: channel === "feishu"
|
||||
: channel === "matrix"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
channel: "matrix" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
parentConversationId:
|
||||
input.placement === "child"
|
||||
? input.conversation.conversationId
|
||||
: input.conversation.parentConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
: channel === "feishu"
|
||||
? {
|
||||
channel: "feishu" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
}
|
||||
: {
|
||||
channel: "telegram" as const,
|
||||
accountId: input.conversation.accountId,
|
||||
conversationId: nextConversationId,
|
||||
};
|
||||
return createSessionBinding({
|
||||
targetSessionKey: input.targetSessionKey,
|
||||
conversation,
|
||||
@ -359,6 +370,32 @@ async function runTelegramDmAcpCommand(commandBody: string, cfg: OpenClawConfig
|
||||
return handleAcpCommand(createTelegramDmParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createMatrixRoomParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "matrix",
|
||||
Surface: "matrix",
|
||||
OriginatingChannel: "matrix",
|
||||
OriginatingTo: "room:!room:example.org",
|
||||
AccountId: "default",
|
||||
});
|
||||
params.command.senderId = "user-1";
|
||||
return params;
|
||||
}
|
||||
|
||||
function createMatrixThreadParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = createMatrixRoomParams(commandBody, cfg);
|
||||
params.ctx.MessageThreadId = "$thread-root";
|
||||
return params;
|
||||
}
|
||||
|
||||
async function runMatrixAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixRoomParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
async function runMatrixThreadAcpCommand(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
return handleAcpCommand(createMatrixThreadParams(commandBody, cfg), true);
|
||||
}
|
||||
|
||||
function createFeishuDmParams(commandBody: string, cfg: OpenClawConfig = baseCfg) {
|
||||
const params = buildCommandTestParams(commandBody, cfg, {
|
||||
Provider: "feishu",
|
||||
@ -598,6 +635,63 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates Matrix thread-bound ACP spawns from top-level rooms when enabled", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Created thread thread-created and bound it");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "child",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Matrix thread ACP spawns to the current thread with the parent room id", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
spawnAcpSessions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixThreadAcpCommand("/acp spawn codex --thread here", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("Bound this thread to");
|
||||
expect(hoisted.sessionBindingBindMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
placement: "current",
|
||||
conversation: expect.objectContaining({
|
||||
channel: "matrix",
|
||||
accountId: "default",
|
||||
conversationId: "$thread-root",
|
||||
parentConversationId: "!room:example.org",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("binds Feishu DM ACP spawns to the current DM conversation", async () => {
|
||||
const result = await runFeishuDmAcpCommand("/acp spawn codex --thread here");
|
||||
|
||||
@ -654,6 +748,24 @@ describe("/acp command", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects Matrix thread-bound ACP spawn when spawnAcpSessions is unset", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
channels: {
|
||||
matrix: {
|
||||
threadBindings: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies OpenClawConfig;
|
||||
|
||||
const result = await runMatrixAcpCommand("/acp spawn codex", cfg);
|
||||
|
||||
expect(result?.reply?.text).toContain("spawnAcpSessions=true");
|
||||
expect(hoisted.sessionBindingBindMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forbids /acp spawn from sandboxed requester sessions", async () => {
|
||||
const cfg = {
|
||||
...baseCfg,
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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) ??
|
||||
|
||||
@ -157,12 +157,17 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const senderId = commandParams.command.senderId?.trim() || "";
|
||||
const parentConversationId = bindingContext.parentConversationId?.trim() || undefined;
|
||||
const conversationRef = {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
...(parentConversationId && parentConversationId !== currentConversationId
|
||||
? { parentConversationId }
|
||||
: {}),
|
||||
};
|
||||
if (placement === "current") {
|
||||
const existingBinding = bindingService.resolveByConversation({
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId: currentConversationId,
|
||||
});
|
||||
const existingBinding = bindingService.resolveByConversation(conversationRef);
|
||||
const boundBy =
|
||||
typeof existingBinding?.metadata?.boundBy === "string"
|
||||
? existingBinding.metadata.boundBy.trim()
|
||||
@ -176,17 +181,12 @@ async function bindSpawnedAcpSessionToThread(params: {
|
||||
}
|
||||
|
||||
const label = params.label || params.agentId;
|
||||
const conversationId = currentConversationId;
|
||||
|
||||
try {
|
||||
const binding = await bindingService.bind({
|
||||
targetSessionKey: params.sessionKey,
|
||||
targetKind: "session",
|
||||
conversation: {
|
||||
channel: spawnPolicy.channel,
|
||||
accountId: spawnPolicy.accountId,
|
||||
conversationId,
|
||||
},
|
||||
conversation: conversationRef,
|
||||
placement,
|
||||
metadata: {
|
||||
threadName: resolveThreadBindingThreadName({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user