From b5f067aabe92f8f1a673e9ca3b16bd3f85ae6ce0 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 12 Jun 2026 19:05:34 +0700 Subject: [PATCH 01/23] refactor(messaging): migrate channel lifecycle hooks --- scripts/nemoclaw-start.sh | 490 ++++++++----- src/lib/actions/sandbox/channel-status.ts | 81 ++- src/lib/actions/sandbox/doctor.ts | 88 ++- .../sandbox/policy-channel-conflict.test.ts | 86 ++- src/lib/actions/sandbox/policy-channel.ts | 444 +++++++++--- ...legram-channel-bridge-verification.test.ts | 62 -- .../telegram-channel-bridge-verification.ts | 39 -- src/lib/channel-runtime-status.test.ts | 7 +- src/lib/channel-runtime-status.ts | 108 +-- src/lib/inventory/index.test.ts | 14 +- src/lib/inventory/index.ts | 25 +- src/lib/messaging/MIGRATION.md | 645 ++++++++++++++++++ src/lib/messaging/applier/hook-phases.test.ts | 279 ++++++++ src/lib/messaging/applier/hook-phases.ts | 168 +++++ src/lib/messaging/applier/index.ts | 1 + .../messaging/applier/setup-applier.test.ts | 5 + src/lib/messaging/applier/setup-applier.ts | 14 + .../messaging/channels/discord/manifest.ts | 44 ++ src/lib/messaging/channels/manifests.test.ts | 158 ++++- .../messaging/channels/slack/hooks/index.ts | 15 +- .../socket-mode-gateway-conflict.test.ts | 100 +++ .../hooks/socket-mode-gateway-conflict.ts | 137 ++++ src/lib/messaging/channels/slack/manifest.ts | 119 ++++ .../messaging/channels/telegram/manifest.ts | 103 +++ src/lib/messaging/channels/wechat/manifest.ts | 45 ++ .../messaging/channels/whatsapp/manifest.ts | 44 ++ .../compiler/manifest-compiler.test.ts | 17 + src/lib/messaging/diagnostics.test.ts | 35 + src/lib/messaging/diagnostics.ts | 52 ++ src/lib/messaging/hooks/hook-runner.test.ts | 1 + src/lib/messaging/index.ts | 4 +- src/lib/messaging/manifest/types.ts | 7 +- src/lib/messaging/status-outputs.ts | 129 ++++ .../onboard/messaging-conflict-guard.test.ts | 18 +- src/lib/onboard/messaging-conflict-guard.ts | 119 ++-- .../sandbox-messaging-preflight.test.ts | 15 +- src/lib/status-command-deps.test.ts | 11 +- src/lib/status-command-deps.ts | 160 ++++- test/nemoclaw-start.test.ts | 338 ++++++--- 39 files changed, 3546 insertions(+), 681 deletions(-) delete mode 100644 src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts delete mode 100644 src/lib/actions/sandbox/telegram-channel-bridge-verification.ts create mode 100644 src/lib/messaging/MIGRATION.md create mode 100644 src/lib/messaging/applier/hook-phases.test.ts create mode 100644 src/lib/messaging/applier/hook-phases.ts create mode 100644 src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts create mode 100644 src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts create mode 100644 src/lib/messaging/diagnostics.test.ts create mode 100644 src/lib/messaging/diagnostics.ts create mode 100644 src/lib/messaging/status-outputs.ts diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 7b70d6b4ed..1847fff85c 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1340,150 +1340,324 @@ PYPLACEHOLDERS [ "$_write_rc" -eq 0 ] || return "$_write_rc" } -# ── Slack runtime env normalization (Bolt-compatible placeholder) ── -# OpenShell injects messaging-provider credentials into the sandbox process -# environment as canonical resolve placeholders, e.g. -# SLACK_BOT_TOKEN=openshell:resolve:env:v51_SLACK_BOT_TOKEN -# Unlike the canonical OpenClaw config values (handled by -# refresh_openclaw_provider_placeholders), Slack Bolt validates token *shape* -# at startup and rejects anything that does not begin with xoxb-/xapp-. After a -# messaging-provider rebuild the gateway therefore inherits a placeholder it -# cannot parse and Slack auth fails even though the provider attached -# successfully (NVIDIA/NemoClaw#4274). The L7 egress proxy rewrites the -# Bolt-aliased form (xoxb-/xapp-OPENSHELL-RESOLVE-ENV-*) at request time — the -# same alias the config generator bakes into openclaw.json — so normalize the -# runtime env to that alias before launching OpenClaw. -# -# This runs in the *main* shell (never a subshell / command substitution) so -# the exported values are inherited by the gateway and any one-shot -# "${NEMOCLAW_CMD[@]}" child. Real xoxb-/xapp- tokens and already-aliased values -# are left untouched, so it is safe to call unconditionally and is idempotent. -# -# OpenShell injects self-referential placeholders (the SLACK_BOT_TOKEN env var -# resolves to "openshell:resolve:env:SLACK_BOT_TOKEN" or its revision-scoped -# form "openshell:resolve:env:v_SLACK_BOT_TOKEN"). The match is anchored to -# exactly those two shapes so a placeholder that resolves some *other* key -# (including a suffix collision like ...v1_NOT_SLACK_BOT_TOKEN) is left alone -# rather than silently rebound to the Slack secret. -normalize_slack_runtime_env() { - local bot_re='^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$' - local app_re='^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$' +# ── Messaging runtime preloads from manifest hooks ─────────────── +# Channel-owned runtime-preload hooks are serialized into +# NEMOCLAW_MESSAGING_PLAN_B64 at image build time. The entrypoint only consumes +# generic declarations: envAliases, preloads, and secretScans. +_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json" +_MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" + +write_messaging_runtime_preload_plan() { + python3 - <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_PRELOAD_PLAN" +import base64 +import json +import os +import re +import sys - if [[ "${SLACK_BOT_TOKEN-}" =~ $bot_re ]]; then - export SLACK_BOT_TOKEN="xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN" - printf '[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias\n' >&2 - fi +EMPTY = {"preloads": [], "envAliases": [], "secretScans": []} +PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" +PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" +ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") - if [[ "${SLACK_APP_TOKEN-}" =~ $app_re ]]; then - export SLACK_APP_TOKEN="xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN" - printf '[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias\n' >&2 - fi -} -# ── Slack secrets-on-disk tripwire ──────────────────────────────── -# Defense-in-depth: refuse to serve if a real Slack token (anything -# starting with xoxb- or xapp- that is NOT the OPENSHELL-RESOLVE-ENV- -# placeholder) ever appears in openclaw.json. This catches a regression -# where someone re-introduces inline token mutation, or a bug in the -# config generator that emits raw env values. Runs once at startup, -# after configure_messaging_channels has finalized the config. -verify_no_slack_secrets_on_disk() { - local config="/sandbox/.openclaw/openclaw.json" - [ -f "$config" ] || return 0 - if python3 - "$config" <<'PYSLACKSECRET'; then -import re -import sys +def fail(message): + print(f"[channels] Invalid messaging runtime preload plan: {message}", file=sys.stderr) + raise SystemExit(1) -with open(sys.argv[1], "r", encoding="utf-8", errors="ignore") as f: - content = f.read() -sys.exit(0 if re.search(r"(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", content) else 1) -PYSLACKSECRET - printf '[SECURITY] Slack token leaked into %s — refusing to serve\n' "$config" >&2 - exit 78 # EX_CONFIG - fi -} -# ── Slack channel guard (unhandled-rejection safety net) ───────── -# Prevents the gateway from crashing when a Slack channel fails to -# initialize (e.g., invalid_auth, token_revoked, unresolved placeholder -# tokens). Instead of modifying openclaw.json (which is Landlock -# read-only at runtime), this injects a Node.js preload via -# NODE_OPTIONS that catches unhandled promise rejections originating -# from Slack channel initialization and logs them as warnings instead -# of letting Node v22 treat them as fatal. -# -# Same pattern as the HTTP proxy fix (_PROXY_FIX_SCRIPT) and the -# WebSocket CONNECT fix (_WS_FIX_SCRIPT). -# -# Ref: https://github.com/NVIDIA/NemoClaw/issues/2340 -_SLACK_GUARD_SCRIPT="/tmp/nemoclaw-slack-channel-guard.js" -_SLACK_GUARD_SOURCE="/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js" +def clean_string(value, field, *, allow_empty=False): + if not isinstance(value, str): + fail(f"{field} must be a string") + if not allow_empty and not value: + fail(f"{field} must not be empty") + if any(ch in value for ch in "\x00\r\n\t"): + fail(f"{field} contains a control character") + return value -install_slack_channel_guard() { - local config_file="/sandbox/.openclaw/openclaw.json" - # Only install if a Slack channel is configured - if ! grep -q '"slack"' "$config_file" 2>/dev/null; then - return 0 - fi +def clean_message(value, field): + if value is None: + return "" + if not isinstance(value, str): + fail(f"{field} must be a string") + if any(ch in value for ch in "\x00\r\n\t"): + fail(f"{field} contains a control character") + return value - printf '[channels] Installing Slack channel guard (unhandled-rejection safety net)\n' >&2 - emit_sandbox_sourced_file "$_SLACK_GUARD_SCRIPT" <"$_SLACK_GUARD_SOURCE" +def clean_preload(entry, index): + if not isinstance(entry, dict): + fail(f"preloads[{index}] must be an object") + source = clean_string(entry.get("source"), f"preloads[{index}].source") + target = clean_string(entry.get("target"), f"preloads[{index}].target") + if not source.startswith(PRELOAD_SOURCE_PREFIX) or not source.endswith(".js"): + fail(f"preloads[{index}].source must be a preload JavaScript file under {PRELOAD_SOURCE_PREFIX}") + if not target.startswith(PRELOAD_TARGET_PREFIX) or not target.endswith(".js"): + fail(f"preloads[{index}].target must be a JavaScript file under {PRELOAD_TARGET_PREFIX}*") + node_options = entry.get("nodeOptions", []) + if not isinstance(node_options, list): + fail(f"preloads[{index}].nodeOptions must be a list") + normalized_options = [] + for option in node_options: + if option not in ("boot", "connect"): + fail(f"preloads[{index}].nodeOptions contains unsupported value {option!r}") + if option not in normalized_options: + normalized_options.append(option) + optional = entry.get("optional", False) + if not isinstance(optional, bool): + fail(f"preloads[{index}].optional must be a boolean") + return { + "source": source, + "target": target, + "nodeOptions": normalized_options, + "optional": optional, + "installMessage": clean_message(entry.get("installMessage"), f"preloads[{index}].installMessage"), + "installedMessage": clean_message(entry.get("installedMessage"), f"preloads[{index}].installedMessage"), + } - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT" - printf '[channels] Slack channel guard installed (NODE_OPTIONS updated)\n' >&2 -} -# ── Telegram diagnostics (provider-ready + inference-failure clarity) ─ -_TELEGRAM_DIAGNOSTICS_SCRIPT="/tmp/nemoclaw-telegram-diagnostics.js" -_TELEGRAM_DIAGNOSTICS_SOURCE="/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js" +def clean_env_alias(entry, index): + if not isinstance(entry, dict): + fail(f"envAliases[{index}] must be an object") + env_key = clean_string(entry.get("envKey"), f"envAliases[{index}].envKey") + if not ENV_KEY_RE.match(env_key): + fail(f"envAliases[{index}].envKey is not a safe environment key") + pattern = clean_string(entry.get("match"), f"envAliases[{index}].match") + try: + re.compile(pattern) + except re.error as exc: + fail(f"envAliases[{index}].match is not a valid regex: {exc}") + return { + "envKey": env_key, + "match": pattern, + "value": clean_string(entry.get("value"), f"envAliases[{index}].value", allow_empty=True), + "message": clean_message(entry.get("message"), f"envAliases[{index}].message"), + } -install_telegram_diagnostics() { - local config_file="/sandbox/.openclaw/openclaw.json" - # Only install when Telegram is configured in the baked OpenClaw config. - if ! grep -q '"telegram"' "$config_file" 2>/dev/null; then - return 0 - fi +def clean_secret_scan(entry, index): + if not isinstance(entry, dict): + fail(f"secretScans[{index}] must be an object") + path = clean_string(entry.get("path"), f"secretScans[{index}].path") + if not path.startswith("/sandbox/"): + fail(f"secretScans[{index}].path must be under /sandbox") + pattern = clean_string(entry.get("pattern"), f"secretScans[{index}].pattern") + try: + re.compile(pattern) + except re.error as exc: + fail(f"secretScans[{index}].pattern is not a valid regex: {exc}") + exit_code = entry.get("exitCode", 78) + if not isinstance(exit_code, int) or exit_code < 1 or exit_code > 255: + fail(f"secretScans[{index}].exitCode must be an integer from 1 to 255") + return { + "path": path, + "pattern": pattern, + "message": clean_message(entry.get("message"), f"secretScans[{index}].message") or "[SECURITY] Runtime secret scan failed for {path}", + "exitCode": exit_code, + } - printf '[channels] Installing Telegram diagnostics (provider readiness + inference errors)\n' >&2 - emit_sandbox_sourced_file "$_TELEGRAM_DIAGNOSTICS_SCRIPT" <"$_TELEGRAM_DIAGNOSTICS_SOURCE" +raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() +if not raw_plan: + print(json.dumps(EMPTY, sort_keys=True)) + raise SystemExit(0) - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT" - printf '[channels] Telegram diagnostics installed (NODE_OPTIONS updated)\n' >&2 +try: + plan = json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) +except Exception as exc: + fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") +if not isinstance(plan, dict): + fail("decoded plan must be an object") + +preloads = [] +env_aliases = [] +secret_scans = [] +seen_preloads = set() +seen_aliases = set() +seen_scans = set() + +for channel in plan.get("channels", []): + if not isinstance(channel, dict): + continue + if channel.get("active") is not True or channel.get("disabled") is True: + continue + for hook in channel.get("hooks", []): + if not isinstance(hook, dict) or hook.get("phase") != "runtime-preload": + continue + for output in hook.get("outputs", []): + if not isinstance(output, dict) or output.get("kind") != "runtime-preload": + continue + value = output.get("value") + if not isinstance(value, dict): + fail(f"{channel.get('channelId', '')}.{hook.get('id', '')}.{output.get('id', '')} value must be an object") + for entry in value.get("preloads", []): + preload = clean_preload(entry, len(preloads)) + preload_key = (preload["source"], preload["target"]) + if preload_key not in seen_preloads: + seen_preloads.add(preload_key) + preloads.append(preload) + for entry in value.get("envAliases", []): + alias = clean_env_alias(entry, len(env_aliases)) + alias_key = (alias["envKey"], alias["match"], alias["value"]) + if alias_key not in seen_aliases: + seen_aliases.add(alias_key) + env_aliases.append(alias) + for entry in value.get("secretScans", []): + scan = clean_secret_scan(entry, len(secret_scans)) + scan_key = (scan["path"], scan["pattern"]) + if scan_key not in seen_scans: + seen_scans.add(scan_key) + secret_scans.append(scan) + +print(json.dumps({"preloads": preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) +PYMESSAGINGRUNTIME } -# ── WhatsApp compact-QR preload (scan-friendly in-sandbox pairing) ─── -# The upstream @openclaw/whatsapp QR renders at full size (~56 rows) and -# overflows DGX Spark terminals (NemoClaw#4522). The plugin renders through -# `renderQrTerminal()` → the `qrcode` package's toString(text,{type:"terminal"}) -# WITHOUT a `small` flag, so it defaults to full size. This preload patches the -# qrcode package to force `{ small: true }` half-block rendering for terminal -# output, roughly quartering the area without changing the payload. -# It is NOT added to the global boot NODE_OPTIONS (the gateway never renders the -# pairing QR); instead it is wired into the connect-session NODE_OPTIONS (so any -# openclaw invocation in the session gets it, not just the openclaw() shell -# function) and the openclaw() guard injects it as defense-in-depth. -_WHATSAPP_QR_COMPACT_SCRIPT="/tmp/nemoclaw-whatsapp-qr-compact.js" -_WHATSAPP_QR_COMPACT_SOURCE="/usr/local/lib/nemoclaw/preloads/whatsapp-qr-compact.js" - -install_whatsapp_qr_compact() { - local config_file="/sandbox/.openclaw/openclaw.json" +apply_messaging_runtime_env_aliases() { + [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGALIASES' +import json +import sys - # Only install when WhatsApp is configured in the baked OpenClaw config. - if ! grep -q '"whatsapp"' "$config_file" 2>/dev/null; then - return 0 +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for alias in plan.get("envAliases", []): + print("\t".join([ + alias["envKey"], + alias["match"], + alias["value"], + alias.get("message", ""), + ])) +PYMESSAGINGALIASES + )" || return $? + [ -n "$_rows" ] || return 0 + + local _env_key _match _value _message _current + while IFS=$'\t' read -r _env_key _match _value _message; do + _current="$(printenv "$_env_key" 2>/dev/null || true)" + if [[ "$_current" =~ $_match ]]; then + export "$_env_key=$_value" + [ -n "$_message" ] && printf '%s\n' "$_message" >&2 + fi + done <<<"$_rows" +} + +install_messaging_runtime_preloads() { + [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 + local _rows + _rows="$( + python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGPRELOADS' +import json +import sys + +with open(sys.argv[1], encoding="utf-8") as handle: + plan = json.load(handle) +for preload in plan.get("preloads", []): + print("\t".join([ + preload["source"], + preload["target"], + ",".join(preload.get("nodeOptions", [])), + "1" if preload.get("optional") else "0", + preload.get("installMessage", ""), + preload.get("installedMessage", ""), + ])) +PYMESSAGINGPRELOADS + )" || return $? + + local _connect_preloads=() + if [ -n "$_rows" ]; then + local _source _target _node_options _optional _install_message _installed_message + while IFS=$'\t' read -r _source _target _node_options _optional _install_message _installed_message; do + if [ ! -f "$_source" ]; then + [ "$_optional" = "1" ] && continue + printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 + return 1 + fi + [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 + emit_sandbox_sourced_file "$_target" <"$_source" + case ",$_node_options," in + *,boot,*) + export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_target" + ;; + esac + case ",$_node_options," in + *,connect,*) + _connect_preloads+=("$_target") + ;; + esac + [ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2 + done <<<"$_rows" fi - # Source file is absent on older base images; skip rather than fail the boot. - if [ ! -f "$_WHATSAPP_QR_COMPACT_SOURCE" ]; then - return 0 + if [ "${#_connect_preloads[@]}" -gt 0 ]; then + printf '%s\n' "${_connect_preloads[@]}" | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" + else + : | emit_sandbox_sourced_file "$_MESSAGING_CONNECT_PRELOADS_FILE" fi +} - printf '[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)\n' >&2 - emit_sandbox_sourced_file "$_WHATSAPP_QR_COMPACT_SCRIPT" <"$_WHATSAPP_QR_COMPACT_SOURCE" +emit_messaging_connect_runtime_preload_exports() { + cat </dev/null || true } +_nemoclaw_messaging_connect_node_options() { + local _nemoclaw_preload _nemoclaw_options="" + [ -f "/tmp/nemoclaw-messaging-connect-preloads.list" ] || return 0 + while IFS= read -r _nemoclaw_preload; do + [ -n "$_nemoclaw_preload" ] || continue + [ -f "$_nemoclaw_preload" ] || continue + _nemoclaw_options="${_nemoclaw_options:+$_nemoclaw_options }--require $_nemoclaw_preload" + done < "/tmp/nemoclaw-messaging-connect-preloads.list" + printf '%s' "$_nemoclaw_options" +} openclaw() { # NemoClaw#4462: keep user-initiated device approval usable from an # interactive sandbox shell until upstream OpenClaw can approve scope @@ -2521,16 +2705,13 @@ PYAPPROVEAFTER esac echo "[whatsapp] Pairing via gateway ${OPENCLAW_GATEWAY_URL}." >&2 echo "[whatsapp] On your phone: WhatsApp > Linked devices > Link a device, then scan the QR below." >&2 - # Defense-in-depth: the connect-session NODE_OPTIONS already wires - # this preload in for every openclaw invocation; injecting it again - # here covers non-connect shells (e.g. `openshell sandbox exec`). - # The preload is idempotent, so a double --require is harmless. - # Literal path: this guard body is emitted inside a single-quoted - # heredoc, so shell variables are intentionally not expanded here. - # Keep in sync with _WHATSAPP_QR_COMPACT_SCRIPT above. - _whatsapp_qr_compact="/tmp/nemoclaw-whatsapp-qr-compact.js" - if [ -f "$_whatsapp_qr_compact" ]; then - NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_whatsapp_qr_compact" command openclaw "$@" + # Defense-in-depth: connect-session NODE_OPTIONS already wires + # manifest-declared connect preloads for every openclaw invocation; + # injecting them again here covers non-connect shells. Runtime + # preload modules are idempotent, so a double --require is harmless. + _nemoclaw_connect_node_options="$(_nemoclaw_messaging_connect_node_options)" + if [ -n "$_nemoclaw_connect_node_options" ]; then + NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }$_nemoclaw_connect_node_options" command openclaw "$@" else command openclaw "$@" fi @@ -2618,21 +2799,8 @@ GUARDENVEOF echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SECCOMP_GUARD_SCRIPT\"" # ciao network guard for connect sessions. echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_CIAO_GUARD_SCRIPT\"" - # Telegram diagnostics for connect sessions — same conditional pattern. - echo "[ -f \"$_TELEGRAM_DIAGNOSTICS_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT\"" - # Slack channel guard for connect sessions. The guard file is installed later - # by install_slack_channel_guard() — conditional on the file existing at - # source-time so connect sessions started before Slack is configured are safe. - echo "[ -f \"$_SLACK_GUARD_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT\"" - # WhatsApp compact-QR preload for connect sessions (NemoClaw#4522). The - # in-sandbox `openclaw channels login --channel whatsapp` QR renders full - # size (~56 rows) and overflows the terminal. Wiring the preload into the - # connect-session NODE_OPTIONS forces compact rendering for ANY openclaw - # invocation in the session — not only the openclaw() shell-function path, - # which a direct binary call would bypass. The file is installed by - # install_whatsapp_qr_compact() only for WhatsApp sandboxes, so the - # source-time `[ -f ]` check leaves non-WhatsApp connect sessions untouched. - echo "[ -f \"$_WHATSAPP_QR_COMPACT_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_WHATSAPP_QR_COMPACT_SCRIPT\"" + # Manifest-declared messaging preloads for connect sessions. + emit_messaging_connect_runtime_preload_exports # Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth) echo '# Tool cache redirects — keep transient tool state under /tmp' for _redir in "${_TOOL_REDIRECTS[@]}"; do @@ -3316,12 +3484,13 @@ if [ "$(id -u)" -ne 0 ]; then # actually runs with. write_openclaw_config_baseline export_gateway_token + write_messaging_runtime_preload_plan write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" || true - # Normalize Slack provider placeholders before any child inherits the env — - # covers both the one-shot "${NEMOCLAW_CMD[@]}" exec and the gateway launch. - normalize_slack_runtime_env + # Apply manifest-declared runtime env aliases before any child inherits the + # env. This covers both one-shot commands and the gateway launch. + apply_messaging_runtime_env_aliases if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then exec "${NEMOCLAW_CMD[@]}" @@ -3331,10 +3500,8 @@ if [ "$(id -u)" -ne 0 ]; then refresh_openclaw_provider_placeholders ensure_mutable_openclaw_config_hash write_openclaw_config_baseline - install_telegram_diagnostics - install_slack_channel_guard - install_whatsapp_qr_compact - verify_no_slack_secrets_on_disk + install_messaging_runtime_preloads + verify_messaging_runtime_secret_scans # Ensure writable state directories exist and are owned by the current user. # The Docker build (Dockerfile) sets this up correctly, but the native curl @@ -3378,7 +3545,7 @@ if [ "$(id -u)" -ne 0 ]; then # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). - validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" "$_WHATSAPP_QR_COMPACT_SCRIPT" + validate_nemoclaw_tmp_permissions # Start gateway in background, auto-pair, then wait. Mark the in-container # gateway path so the Docker HEALTHCHECK probes it rather than short-circuiting @@ -3470,21 +3637,20 @@ prepare_gateway_token_for_current_command # actually runs with. write_openclaw_config_baseline export_gateway_token +write_messaging_runtime_preload_plan write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" -# Normalize Slack provider placeholders before any child (the one-shot +# Apply manifest-declared runtime env aliases before any child (the one-shot # "${NEMOCLAW_CMD[@]}" exec or the stepped-down gateway) inherits the env. # gosu/setpriv preserve the environment, so the export reaches the gateway user. -normalize_slack_runtime_env +apply_messaging_runtime_env_aliases # Messaging channel config was announced before placeholder refresh so the # baseline captures the same provider placeholders the gateway will use. -# Install channel-specific preloads before starting OpenClaw. -install_telegram_diagnostics -install_slack_channel_guard -install_whatsapp_qr_compact -verify_no_slack_secrets_on_disk +# Install manifest-declared runtime preloads before starting OpenClaw. +install_messaging_runtime_preloads +verify_messaging_runtime_secret_scans # Write auth profile as sandbox user and recursively re-tighten any # auth-profiles.json files under ~/.openclaw. See @@ -3601,7 +3767,7 @@ seed_default_workspace_templates_as_sandbox # Pass the HTTP proxy-fix path so it is validated alongside proxy-env.sh # (both are trust-boundary files; tampering would let the sandbox user # inject code into any Node process via NODE_OPTIONS). -validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "$_TELEGRAM_DIAGNOSTICS_SCRIPT" "$_SLACK_GUARD_SCRIPT" "$_WHATSAPP_QR_COMPACT_SCRIPT" +validate_nemoclaw_tmp_permissions # Start the gateway as the 'gateway' user. # SECURITY: The sandbox user cannot kill this process because it runs diff --git a/src/lib/actions/sandbox/channel-status.ts b/src/lib/actions/sandbox/channel-status.ts index 1eda2d43fc..c1514b5901 100644 --- a/src/lib/actions/sandbox/channel-status.ts +++ b/src/lib/actions/sandbox/channel-status.ts @@ -11,17 +11,20 @@ * registry list. The diagnostic below has to fail loud for paired-but-idle. */ -import { loadAgent, type AgentDefinition } from "../../agent/defs"; +import { type AgentDefinition, loadAgent } from "../../agent/defs"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../../cli/branding"; import { B, D, G, R, RD, YW } from "../../cli/terminal-style"; +import { + collectBuiltInMessagingChannelDiagnostics, + type MessagingChannelDiagnosticSpec, +} from "../../messaging/diagnostics"; import * as policies from "../../policy"; -import { KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; import { + type DiagnosticSeverity, + type DiagnosticSignal, evaluateWhatsappDiagnostics, parseWhatsappHeartbeat, summarizeWhatsappLogLines, - type DiagnosticSeverity, - type DiagnosticSignal, type WhatsappDiagnosticReport, type WhatsappHeartbeat, type WhatsappProbeInput, @@ -77,7 +80,7 @@ export type ChannelStatusOptions = { }; export type ChannelStatusReport = - | { schemaVersion: 1; sandbox: string; channel: "whatsapp"; report: WhatsappDiagnosticReport } + | { schemaVersion: 1; sandbox: string; channel: string; report: WhatsappDiagnosticReport } | { schemaVersion: 1; sandbox: string; @@ -91,6 +94,7 @@ export type ChannelStatusReport = // unresponsive when the Noise WebSocket is stuck; a fast hard cap keeps // channels status from inheriting that hang. const WHATSAPP_PROBE_TIMEOUT_MS = 8_000; +const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics(); const SHELL_OK = "NEMOCLAW_WA_DIAG_OK"; const HEARTBEAT_BEGIN = "NEMOCLAW_WA_HEARTBEAT_BEGIN"; @@ -133,6 +137,29 @@ function defaultDeps(deps: StatusDeps | undefined): Required { }; } +function getChannelStatusDiagnostic(channelName: string): MessagingChannelDiagnosticSpec | null { + return ( + CHANNEL_STATUS_DIAGNOSTICS.find((diagnostic) => diagnostic.channelId === channelName) ?? null + ); +} + +function diagnosticChannelNames(): string[] { + return CHANNEL_STATUS_DIAGNOSTICS.map((diagnostic) => diagnostic.channelId); +} + +function selectDefaultChannel(configuredChannels: readonly string[]): string { + const preferredConfigured = configuredChannels.find( + (channel) => getChannelStatusDiagnostic(channel)?.preferredDefault === true, + ); + if (preferredConfigured) return preferredConfigured; + if (configuredChannels.length > 0) return configuredChannels[0]; + return ( + CHANNEL_STATUS_DIAGNOSTICS.find((diagnostic) => diagnostic.preferredDefault)?.channelId ?? + CHANNEL_STATUS_DIAGNOSTICS[0]?.channelId ?? + "" + ); +} + function resolveStateDirs(agent: AgentDefinition): string[] { const configDir = agent.configPaths?.dir; if (!configDir) return []; @@ -440,12 +467,16 @@ function buildBasicChannelReport( channelName: string, agent: AgentDefinition, deps: Required, + diagnostic: MessagingChannelDiagnosticSpec, ): ChannelStatusReport { const entry = deps.getSandbox(sandboxName); const enabled = registry.getConfiguredMessagingChannelsFromEntry(entry).includes(channelName); const disabled = registry.getDisabledMessagingChannelsFromEntry(entry).includes(channelName); const appliedPresets = deps.getAppliedPresets(sandboxName); - const presetInRegistry = appliedPresets.includes(channelName); + const policyPresets = + diagnostic.policyPresets.length > 0 ? diagnostic.policyPresets : [channelName]; + const presetInRegistry = policyPresets.some((preset) => appliedPresets.includes(preset)); + const policyLabel = policyPresets.join(", "); const signals: DiagnosticSignal[] = []; signals.push({ label: "Channel registration", @@ -463,11 +494,11 @@ function buildBasicChannelReport( label: "Policy coverage", severity: presetInRegistry ? "ok" : enabled ? "warn" : "info", detail: presetInRegistry - ? `${channelName} preset applied` - : `${channelName} preset not applied`, + ? `${policyLabel} preset applied` + : `${policyLabel} preset not applied`, hint: presetInRegistry ? undefined - : `run \`${CLI_NAME} ${sandboxName} policy-add ${channelName}\``, + : `run \`${CLI_NAME} ${sandboxName} policy-add ${policyPresets[0]}\``, }); signals.push({ label: "Deep diagnostics", @@ -526,18 +557,12 @@ export async function showSandboxChannelStatus( let channelName = channelArg; if (!channelName) { const configuredChannels = registry.getConfiguredMessagingChannelsFromEntry(entry); - const enabled = configuredChannels.filter((name: string) => name === "whatsapp"); - if (enabled.length > 0) { - channelName = "whatsapp"; - } else if (configuredChannels.length > 0) { - channelName = configuredChannels[0]; - } else { - channelName = "whatsapp"; - } + channelName = selectDefaultChannel(configuredChannels); } - if (!channelName || !knownChannelNames().includes(channelName)) { - const known = knownChannelNames().join(", "); + const diagnostic = channelName ? getChannelStatusDiagnostic(channelName) : null; + if (!channelName || !diagnostic) { + const known = diagnosticChannelNames().join(", "); if (asJson) { deps.out( JSON.stringify( @@ -558,29 +583,17 @@ export async function showSandboxChannelStatus( const channelIsPaused = disabledChannels.has(channelName); let report: ChannelStatusReport; - if (channelName === "whatsapp" && channelIsPaused) { - // The operator stopped this channel with `channels stop whatsapp`; the - // bridge and policy are intentionally absent after the rebuild. Skip - // the deep probe so the diagnostic does not flag the deliberate gap as - // an unhealthy bridge. The non-WhatsApp path already covers paused - // channels via buildBasicChannelReport, so route through it. - report = buildBasicChannelReport(sandboxName, channelName, agent, deps); - } else if (channelName === "whatsapp") { + if (diagnostic.deepProbe === "in-sandbox-qr" && !channelIsPaused) { const input = buildWhatsappProbeInput(sandboxName, agent, deps); const whatsappReport = evaluateWhatsappDiagnostics(input); report = { schemaVersion: 1, sandbox: sandboxName, - channel: "whatsapp", + channel: channelName, report: whatsappReport, }; } else { - if (!KNOWN_CHANNELS[channelName]) { - // Defensive — already validated above, but keeps type narrowing happy. - report = buildBasicChannelReport(sandboxName, channelName, agent, deps); - } else { - report = buildBasicChannelReport(sandboxName, channelName, agent, deps); - } + report = buildBasicChannelReport(sandboxName, channelName, agent, deps, diagnostic); } if (!(asJson && quietJson)) { diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index de4547b7bd..77c5d74915 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -17,6 +17,10 @@ import { GATEWAY_PORT, OLLAMA_PORT } from "../../core/ports"; import { recoverNamedGatewayRuntime } from "../../gateway-runtime-action"; import { parseGatewayInference } from "../../inference/config"; import { type ProviderHealthStatus, probeProviderHealth } from "../../inference/health"; +import { + collectBuiltInMessagingChannelDiagnostics, + type MessagingChannelDiagnosticSpec, +} from "../../messaging/diagnostics"; import { isLinuxDockerDriverGatewayEnabled } from "../../onboard/docker-driver-platform"; import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec"; import { ROOT } from "../../runner"; @@ -38,6 +42,7 @@ import { buildToolScopeChecks } from "./doctor-tool-scope"; import { probeSandboxInferenceGatewayHealth } from "./process-recovery"; const NEMOCLAW_GATEWAY_NAME = "nemoclaw"; +const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics(); type DoctorStatus = "ok" | "warn" | "fail" | "info"; @@ -448,23 +453,44 @@ function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorChec }; } - const degraded = - buildStatusCommandDeps(ROOT).checkMessagingBridgeHealth?.(sandboxName, channels) || []; + const statusDeps = buildStatusCommandDeps(ROOT); + const degraded = statusDeps.checkMessagingBridgeHealth?.(sandboxName, channels) || []; + const overlaps = (statusDeps.findMessagingOverlaps?.() ?? []).filter( + (overlap) => channels.includes(overlap.channel) && overlap.sandboxes.includes(sandboxName), + ); const pausedSuffix = pausedChannels.length > 0 ? `; paused channels skipped: ${pausedChannels.join(", ")}` : ""; - if (degraded.length === 0) { - // WhatsApp's inbound delivery cannot be inferred from the conflict-signature - // heuristic — issue #4386 showed a paired channel with a live Noise - // WebSocket that never delivered inbound events, while this check rendered - // "ok". Downgrade to "info" with a pointer to `channels status` so doctor - // never claims WhatsApp is healthy without running the deep probe. - if (channels.includes("whatsapp")) { + const warningDetails = [ + ...degraded.map( + (item: { channel: string; conflicts: number }) => + `${item.channel}: ${item.conflicts} conflict(s)`, + ), + ...overlaps.map(formatMessagingOverlapDoctorDetail), + ]; + if (warningDetails.length === 0) { + const deepProbeDiagnostic = channels + .map(getChannelStatusDiagnostic) + .find((diagnostic) => diagnostic?.doctorWhenNoHealthSignals); + if (deepProbeDiagnostic?.doctorWhenNoHealthSignals) { + const templateContext = { + channel: deepProbeDiagnostic.channelId, + channels: channels.join(", "), + cli: CLI_NAME, + pausedSuffix, + sandbox: sandboxName, + }; return { group: "Messaging", label: "Channels", status: "info", - detail: `${channels.join(", ")} enabled; whatsapp inbound delivery is not inferred from conflict signatures${pausedSuffix}`, - hint: `run \`${CLI_NAME} ${sandboxName} channels status --channel whatsapp\` to probe inbound delivery`, + detail: formatDiagnosticTemplate( + deepProbeDiagnostic.doctorWhenNoHealthSignals.detail, + templateContext, + ), + hint: formatDiagnosticTemplate( + deepProbeDiagnostic.doctorWhenNoHealthSignals.hint, + templateContext, + ), }; } return { @@ -479,17 +505,43 @@ function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorChec group: "Messaging", label: "Channels", status: "warn", - detail: - degraded - .map( - (item: { channel: string; conflicts: number }) => - `${item.channel}: ${item.conflicts} conflict(s)`, - ) - .join("; ") + pausedSuffix, + detail: warningDetails.join("; ") + pausedSuffix, hint: `run \`${CLI_NAME} ${sandboxName} logs --follow\` for enabled bridge details`, }; } +function getChannelStatusDiagnostic(channelName: string): MessagingChannelDiagnosticSpec | null { + return ( + CHANNEL_STATUS_DIAGNOSTICS.find((diagnostic) => diagnostic.channelId === channelName) ?? null + ); +} + +function formatMessagingOverlapDoctorDetail(overlap: { + readonly channel: string; + readonly sandboxes: readonly [string, string]; + readonly message?: string; +}): string { + const detail = overlap.message + ? formatDiagnosticTemplate(overlap.message, { + channel: overlap.channel, + first: overlap.sandboxes[0], + second: overlap.sandboxes[1], + }) + : `'${overlap.sandboxes[0]}' and '${overlap.sandboxes[1]}' overlap`; + return `${overlap.channel}: ${detail}`; +} + +function formatDiagnosticTemplate( + template: string, + values: Readonly>, +): string { + let result = template; + for (const [key, value] of Object.entries(values)) { + result = result.replaceAll(`{${key}}`, value); + } + return result; +} + /** * Decide whether to inspect the legacy k3s gateway container * (`openshell-cluster-`). That container only exists for the legacy diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 45eb6fa40f..43a2deaaca 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -210,12 +210,12 @@ beforeEach(() => { // Downstream rebuild is not under test. vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); - // After a successful interactive add, verifyChannelBridgeAfterRebuild probes + // After a successful interactive add, manifest health-check hooks can probe // the sandbox via executeSandboxExecCommand, which calls getOpenshellBinary() // -> process.exit(1) when the openshell binary is absent (e.g. the CI // unit-test runner; locally it is installed, so this only bites in CI). Stub // the exec seam so the post-add verification never shells out and never trips - // the exit spy. The bridge verification is downstream and not under test here. + // the exit spy unless a test explicitly overrides it. vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue(null); vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue(null); @@ -772,8 +772,88 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { await addSandboxChannel("alpha", { channel: "slack" }); - expect(loggedText()).toContain("Could not verify Slack Socket Mode gateway conflicts"); + expect(loggedText()).toContain("Could not verify messaging pre-enable checks"); expect(exitMock).not.toHaveBeenCalled(); expect(upsertMock).toHaveBeenCalledTimes(1); }); + + it("runs Telegram post-rebuild health through manifest hook output", async () => { + arrangeRegistry({ current: { name: "alpha" } as SandboxEntry }); + getCredentialMock.mockImplementation((key: string) => + key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null, + ); + mockBridgeHealthExec({ + config: { + channels: { + telegram: { + enabled: true, + accounts: { + default: { + dmPolicy: "allowlist", + allowFrom: [], + }, + }, + }, + }, + }, + log: "[telegram] [default] starting provider\n", + }); + + await addSandboxChannel("alpha", { channel: "telegram" }); + + const text = loggedText(); + expect(text).toContain("'telegram' bridge startup detected"); + expect(text).toContain("Telegram direct-message allowlist is empty"); + const execCommands = vi + .mocked(processRecovery.executeSandboxExecCommand) + .mock.calls.map((call: unknown[]) => String(call[1])); + expect(execCommands.some((cmd: string) => cmd.includes("grep"))).toBe(false); + expect( + execCommands.some( + (cmd: string) => cmd.includes("tail -n 400") && cmd.includes("gateway.log"), + ), + ).toBe(true); + }); + + it("runs Slack post-rebuild warning detection through manifest hook output", async () => { + arrangeRegistry({ current: { name: "alpha" } as SandboxEntry }); + getCredentialMock.mockImplementation((key: string) => + key === "SLACK_BOT_TOKEN" + ? "xoxb-alpha-bot" + : key === "SLACK_APP_TOKEN" + ? "xapp-alpha-app" + : null, + ); + mockBridgeHealthExec({ + config: { + channels: { + slack: { + enabled: true, + }, + }, + }, + log: "[channels] [slack] provider failed to start: invalid_auth\n", + }); + + await addSandboxChannel("alpha", { channel: "slack" }); + + const text = loggedText(); + expect(text).toContain("'slack' bridge logged credential/startup warnings"); + expect(text).toContain("invalid_auth"); + expect(exitMock).not.toHaveBeenCalled(); + }); }); + +function mockBridgeHealthExec(options: { config: unknown; log: string }): void { + vi.mocked(processRecovery.executeSandboxExecCommand).mockImplementation( + (_sandboxName: string, command: string) => { + if (command.includes("cat") && command.includes("openclaw.json")) { + return { status: 0, stdout: JSON.stringify(options.config), stderr: "" }; + } + if (command.includes("tail -n 400") && command.includes("gateway.log")) { + return { status: 0, stdout: options.log, stderr: "" }; + } + return null; + }, + ); +} diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 026be1b456..d0b05832f5 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -13,10 +13,14 @@ import { createBuiltInChannelManifestRegistry, createBuiltInMessagingHookRegistry, createBuiltInRenderTemplateResolver, + createMessagingPreEnableHookInputs, getMessagingManifestAvailabilityContext, MessagingHostStateApplier, MessagingSetupApplier, MessagingWorkflowPlanner, + runMessagingHook, + type MessagingHookOutputValue, + type MessagingSerializableValue, type SandboxMessagingChannelPlan, type SandboxMessagingPlan, toMessagingAgentId, @@ -27,6 +31,7 @@ const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () const onboardProviders = require("../../onboard/providers"); import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets"; +import { BASE_GATEWAY_NAME } from "../../onboard/gateway-binding"; import * as policies from "../../policy"; const onboardSession = @@ -54,7 +59,6 @@ import { isDockerRuntimeDown, printDockerRuntimeDownGuidance } from "./gateway-f import { refreshSandboxPolicyContextFile } from "./policy-context-refresh"; import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery"; import { rebuildSandbox } from "./rebuild"; -import { printTelegramDirectMessageAllowlistWarning } from "./telegram-channel-bridge-verification"; type ChannelMutationOptions = { channel?: string; @@ -62,6 +66,28 @@ type ChannelMutationOptions = { force?: boolean; }; +type BridgeStartupHealthSpec = { + type: "openclaw-bridge-startup"; + configFile: string; + channelConfigPath: string; + enabledPath: string; + logFile: string; + maxLogLines: number; + logLinePattern: string; + warningPattern: string; + positivePattern: string; + allowlistWarning?: AllowlistHealthWarningSpec; +}; + +type AllowlistHealthWarningSpec = { + accountContainerPath: string; + preferredAccountKey?: string; + policyPath: string; + requiredPolicy: string; + allowListPath: string; + messages: readonly string[]; +}; + const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; @@ -438,63 +464,77 @@ async function checkChannelAddConflict( return false; } -// Gateway-scoped Slack Socket Mode conflict (#4953): even with a distinct Slack -// app/token, only one sandbox per OpenShell gateway reliably receives Socket -// Mode events. Runs AFTER `checkChannelAddConflict` so the credential axis — -// which catches a *shared* token and stays accurate across gateways — is -// reported first; this axis then catches the distinct-token, same-gateway case -// instead of letting it become a silent black hole. Returns true to PROCEED, -// false to abort. Fail-soft: a detection error must not crash the add or bypass -// `--force`, so it is swallowed (the credential axis already ran its guarded -// check). Only meaningful for Slack; other channels proceed unchanged. -async function checkSlackSocketModeGatewayConflict( +// Channel-owned pre-enable checks run after `checkChannelAddConflict` so the +// shared credential axis is reported first. Registry read failures stay +// fail-soft: they warn and proceed instead of crashing the add path. +async function checkMessagingPreEnableHooks( sandboxName: string, channelName: string, + plan: SandboxMessagingPlan, force: boolean, ): Promise { - if (channelName !== "slack") return true; - let conflictMessages: string[] = []; + const requests = MessagingSetupApplier.listHookRequests(plan, "pre-enable"); + if (requests.length === 0) return true; + + let registryEntries: ReturnType["sandboxes"]; try { - const applier = require("../../messaging/applier") as typeof import("../../messaging/applier"); - const { BASE_GATEWAY_NAME } = - require("../../onboard/gateway-binding") as typeof import("../../onboard/gateway-binding"); - // `channels add` registers the Slack provider on the default `nemoclaw` - // gateway — applyChannelAddToGatewayAndRegistry → recoverNamedGatewayRuntime - // selects `nemoclaw` regardless of the sandbox's recorded gateway. Detect - // conflicts on the gateway the add actually mutates so the check matches the - // provider registration and cannot leave a false negative (#4953). - const gatewayName = BASE_GATEWAY_NAME; - conflictMessages = applier - .findSlackSocketModeGatewayConflicts( - sandboxName, - gatewayName, - registry.listSandboxes().sandboxes, - ) - .map(({ sandbox }) => applier.formatSlackSocketModeConflictMessage(sandbox)); + registryEntries = registry.listSandboxes().sandboxes; } catch (err) { const message = err instanceof Error ? err.message : String(err); - console.log(` ${YW}⚠${R} Could not verify Slack Socket Mode gateway conflicts: ${message}`); + console.log(` ${YW}⚠${R} Could not verify messaging pre-enable checks: ${message}`); return true; } - if (conflictMessages.length === 0) return true; - for (const message of conflictMessages) { - console.log(` ${YW}⚠${R} ${message}`); - } - if (force) { - console.log(" --force: proceeding despite the Slack Socket Mode gateway conflict above."); - return true; - } - if (isNonInteractive()) { - console.error( - ` Aborting: only one sandbox per gateway can receive Slack Socket Mode events. Run \`${CLI_NAME} channels remove slack\` on the other sandbox, onboard this sandbox on a separate gateway (set NEMOCLAW_GATEWAY_PORT), or re-run with --force.`, - ); - process.exit(1); + const hookRegistry = createBuiltInMessagingHookRegistry(); + const additionalInputs = createMessagingPreEnableHookInputs({ + currentSandbox: sandboxName, + currentGatewayName: BASE_GATEWAY_NAME, + registryEntries, + }); + + try { + await MessagingSetupApplier.applyHooksForPhase(plan, "pre-enable", { + additionalInputs, + runHook: (request) => + runMessagingHook( + { + id: request.hookId, + phase: request.phase, + handler: request.handler, + inputs: request.inputKeys, + outputs: request.outputs, + onFailure: request.onFailure, + }, + hookRegistry, + { + channelId: request.channelId, + isInteractive: !isNonInteractive(), + inputs: request.inputs, + }, + ), + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + for (const line of message.split("\n").filter((line) => line.trim().length > 0)) { + console.log(` ${YW}⚠${R} ${line}`); + } + if (force) { + console.log(" --force: proceeding despite the messaging pre-enable conflict above."); + return true; + } + if (isNonInteractive()) { + console.error( + ` Aborting: resolve the messaging pre-enable conflict above, run \`${CLI_NAME} channels remove ${channelName}\` on the other sandbox, or re-run with --force.`, + ); + process.exit(1); + } + const answer = (await askPrompt(" Continue anyway? [y/N]: ")).trim().toLowerCase(); + if (answer === "y" || answer === "yes") return true; + console.log(" Aborting channel add."); + return false; } - const answer = (await askPrompt(" Continue anyway? [y/N]: ")).trim().toLowerCase(); - if (answer === "y" || answer === "yes") return true; - console.log(" Aborting channel add."); - return false; + + return true; } // Push channel tokens to the OpenShell gateway. Durable channel state is @@ -652,86 +692,136 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis return true; } -// Channels that share the canonical OpenClaw `channels..enabled` shape -// and emit `[] [default]` startup breadcrumbs in /tmp/gateway.log. -// WhatsApp is QR-only (no host-side bridge process at this point), and WeChat -// is recorded under the `openclaw-weixin` channel id with its own per-account -// metadata flow seeded by the manifest post-agent-install hook — neither match -// the probe shape and would produce false-negative warnings here. -const OPENCLAW_BRIDGE_VERIFIABLE_CHANNELS = new Set(["telegram", "discord", "slack"]); - -// Probe OpenClaw runtime state for a freshly added messaging channel. Runs -// after `channels add ` triggers a successful rebuild. Reads the -// baked openclaw.json and tails the gateway log to confirm the bridge module -// is enabled and emitted a startup breadcrumb. Failures here are best-effort -// warnings — the rebuild has already succeeded; the goal is to surface -// "bridge did not spawn" so the user does not discover it from radio silence -// hours later (#4314, #4390). Restricted to the OpenClaw agent because Hermes -// sandboxes use /sandbox/.hermes with a different config layout. -function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: string): void { - if (!OPENCLAW_BRIDGE_VERIFIABLE_CHANNELS.has(channelName)) return; - const agent = resolveAgentForSandbox(sandboxName); - if (agent.name !== "openclaw") return; +// Run post-rebuild messaging health checks through the manifest hook phase. +// Failures remain best-effort warnings because the rebuild has already +// succeeded; the phase surfaces likely channel startup issues without making +// channel ownership leak back into this action. +async function runMessagingHealthChecksAfterRebuild( + sandboxName: string, + plan: SandboxMessagingPlan, +): Promise { + if (MessagingSetupApplier.listHookRequests(plan, "health-check").length === 0) return; + + const hookRegistry = createBuiltInMessagingHookRegistry(); + let result: Awaited>; + try { + result = await MessagingSetupApplier.applyHooksForPhase(plan, "health-check", { + runHook: (request) => + runMessagingHook( + { + id: request.hookId, + phase: request.phase, + handler: request.handler, + inputs: request.inputKeys, + outputs: request.outputs, + onFailure: request.onFailure, + }, + hookRegistry, + { + channelId: request.channelId, + isInteractive: !isNonInteractive(), + inputs: request.inputs, + }, + ), + }); + } catch (err) { + console.log(` ${YW}⚠${R} Messaging health check failed: ${formatErrorMessage(err)}`); + return; + } + + for (const hookResult of result.hookResults) { + const request = result.hookRequests.find( + (entry) => entry.hookId === hookResult.hookId && entry.handler === hookResult.handlerId, + ); + if (!request) continue; + for (const [outputId, output] of Object.entries(hookResult.outputs)) { + if (output.kind !== "health-check") continue; + runDeclaredMessagingHealthCheck(sandboxName, request.channelId, outputId, output); + } + } +} + +function runDeclaredMessagingHealthCheck( + sandboxName: string, + channelName: string, + outputId: string, + output: MessagingHookOutputValue, +): void { + const spec = parseBridgeStartupHealthSpec(output.value); + if (!spec) { + console.log( + ` ${YW}⚠${R} Ignoring unsupported messaging health-check output '${outputId}' for '${channelName}'.`, + ); + return; + } + verifyOpenClawBridgeStartupFromSpec(sandboxName, channelName, spec); +} + +function verifyOpenClawBridgeStartupFromSpec( + sandboxName: string, + channelName: string, + spec: BridgeStartupHealthSpec, +): void { const configProbe = executeSandboxExecCommand( sandboxName, - "cat /sandbox/.openclaw/openclaw.json 2>/dev/null || true", + `cat ${shellQuote(spec.configFile)} 2>/dev/null || true`, 10000, ); if (!configProbe || configProbe.status !== 0 || !configProbe.stdout) { console.log( - ` ${YW}⚠${R} Could not read /sandbox/.openclaw/openclaw.json to verify '${channelName}' bridge startup.`, + ` ${YW}⚠${R} Could not read ${spec.configFile} to verify '${channelName}' bridge startup.`, ); console.log( ` Run '${CLI_NAME} ${sandboxName} status' to inspect the sandbox once it is fully running.`, ); return; } + + let channelBlock: unknown = null; let channelEnabled = false; - let channelBlock: any = null; try { - const cfg = JSON.parse(configProbe.stdout); - channelBlock = cfg?.channels?.[channelName]; - channelEnabled = Boolean(channelBlock?.enabled); + const cfg = JSON.parse(String(configProbe.stdout)); + channelBlock = getObjectPath(cfg, spec.channelConfigPath); + channelEnabled = Boolean(getObjectPath(channelBlock, spec.enabledPath)); } catch { - // Malformed config — fall through to the log probe to capture context. + // Malformed config: continue to a clear disabled warning. } + if (!channelEnabled) { console.log( - ` ${YW}⚠${R} '${channelName}' channel was not marked enabled in baked openclaw.json after rebuild.`, + ` ${YW}⚠${R} '${channelName}' channel was not marked enabled in baked ${spec.configFile} after rebuild.`, ); console.log( ` The bridge will not start. Re-run '${CLI_NAME} ${sandboxName} rebuild' or 'channels remove ${channelName}' and add again.`, ); return; } - // Match both the channel module's own breadcrumbs (`[] [default]`) - // and the channel-guard preloads' aggregated form (`[channels] []`). - // The Slack guard writes "[channels] [slack] provider failed to start..." - // when a token is rejected; ignoring that line here would leave the user - // with a generic "no breadcrumb" warning instead of the actionable cause. + + const logLineRegex = compileHealthRegex(spec.logLinePattern, "log line", channelName); + const warningRegex = compileHealthRegex(spec.warningPattern, "warning", channelName, "i"); + const positiveRegex = compileHealthRegex(spec.positivePattern, "startup", channelName); + if (!logLineRegex || !warningRegex || !positiveRegex) return; + const logProbe = executeSandboxExecCommand( sandboxName, - `tail -n 400 /tmp/gateway.log 2>/dev/null | grep -E "^\\[${channelName}\\] |^\\[channels\\] \\[${channelName}\\]" || true`, + `tail -n ${spec.maxLogLines} ${shellQuote(spec.logFile)} 2>/dev/null || true`, 10000, ); - const lines = (logProbe?.stdout || "") + const lines = String(logProbe?.stdout || "") .split(/\r?\n/) .map((line) => line.trim()) - .filter(Boolean); + .filter((line) => line.length > 0 && logLineRegex.test(line)); if (lines.length === 0) { console.log( - ` ${YW}⚠${R} '${channelName}' bridge did not log a startup breadcrumb in /tmp/gateway.log yet.`, + ` ${YW}⚠${R} '${channelName}' bridge did not log a startup breadcrumb in ${spec.logFile} yet.`, ); console.log( - ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f /tmp/gateway.log' if the channel stays silent.`, + ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${spec.logFile}' if the channel stays silent.`, ); return; } - const credentialWarnings = lines.filter((line) => - /credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired/i.test( - line, - ), - ); + + const credentialWarnings = lines.filter((line) => warningRegex.test(line)); if (credentialWarnings.length > 0) { console.log(` ${YW}⚠${R} '${channelName}' bridge logged credential/startup warnings:`); for (const line of credentialWarnings.slice(0, 3)) { @@ -742,29 +832,169 @@ function verifyChannelBridgeAfterRebuild(sandboxName: string, channelName: strin ); return; } - // Treat the channel as observably started only when we see a positive - // startup signal from the bridge module itself ("starting provider" / - // "provider ready"). Otherwise the grep above matched a tangential - // breadcrumb (e.g. a stale "no startup detected" line) and a green - // "startup detected" message would be misleading. - const positiveStartup = lines.some((line) => - /\bstarting provider\b|\bprovider ready\b/.test(line), - ); - if (positiveStartup) { + + if (lines.some((line) => positiveRegex.test(line))) { console.log(` ${G}✓${R} '${channelName}' bridge startup detected in sandbox runtime log.`); - if (channelName === "telegram") { - printTelegramDirectMessageAllowlistWarning(channelBlock, console.log, `${YW}⚠${R}`); + if (spec.allowlistWarning) { + printDeclaredAllowlistWarning(channelBlock, spec.allowlistWarning); } return; } + console.log( ` ${YW}⚠${R} '${channelName}' bridge log lines found but no startup confirmation yet.`, ); console.log( - ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f /tmp/gateway.log' if the channel stays silent.`, + ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${spec.logFile}' if the channel stays silent.`, ); } +function parseBridgeStartupHealthSpec( + value: MessagingSerializableValue, +): BridgeStartupHealthSpec | null { + if (!isObjectRecord(value) || value.type !== "openclaw-bridge-startup") return null; + const configFile = getStringField(value, "configFile"); + const channelConfigPath = getStringField(value, "channelConfigPath"); + const enabledPath = getStringField(value, "enabledPath") ?? "enabled"; + const logFile = getStringField(value, "logFile"); + const logLinePattern = getStringField(value, "logLinePattern"); + const warningPattern = getStringField(value, "warningPattern"); + const positivePattern = getStringField(value, "positivePattern"); + if ( + !configFile || + !channelConfigPath || + !enabledPath || + !logFile || + !logLinePattern || + !warningPattern || + !positivePattern + ) { + return null; + } + + return { + type: "openclaw-bridge-startup", + configFile, + channelConfigPath, + enabledPath, + logFile, + maxLogLines: normalizeMaxLogLines(value.maxLogLines), + logLinePattern, + warningPattern, + positivePattern, + allowlistWarning: parseAllowlistHealthWarning(value.allowlistWarning), + }; +} + +function parseAllowlistHealthWarning(value: unknown): AllowlistHealthWarningSpec | undefined { + if (!isObjectRecord(value)) return undefined; + const accountContainerPath = getStringField(value, "accountContainerPath"); + const policyPath = getStringField(value, "policyPath"); + const requiredPolicy = getStringField(value, "requiredPolicy"); + const allowListPath = getStringField(value, "allowListPath"); + const messages = Array.isArray(value.messages) + ? value.messages.filter((entry): entry is string => typeof entry === "string") + : []; + if ( + !accountContainerPath || + !policyPath || + !requiredPolicy || + !allowListPath || + messages.length === 0 + ) { + return undefined; + } + const preferredAccountKey = getStringField(value, "preferredAccountKey"); + return { + accountContainerPath, + preferredAccountKey, + policyPath, + requiredPolicy, + allowListPath, + messages, + }; +} + +function printDeclaredAllowlistWarning( + channelBlock: unknown, + spec: AllowlistHealthWarningSpec, +): boolean { + const account = selectDeclaredAccount(channelBlock, spec); + const allowList = getObjectPath(account, spec.allowListPath); + const allowedCount = Array.isArray(allowList) ? allowList.length : 0; + if (getObjectPath(account, spec.policyPath) !== spec.requiredPolicy || allowedCount > 0) { + return false; + } + + const [firstLine, ...rest] = spec.messages; + if (!firstLine) return false; + console.log(` ${YW}⚠${R} ${firstLine}`); + for (const line of rest) { + console.log(` ${line}`); + } + return true; +} + +function selectDeclaredAccount( + channelBlock: unknown, + spec: AllowlistHealthWarningSpec, +): Record | null { + const accountContainer = getObjectPath(channelBlock, spec.accountContainerPath); + if (!isObjectRecord(accountContainer)) return null; + const preferredAccount = spec.preferredAccountKey + ? accountContainer[spec.preferredAccountKey] + : null; + if (isObjectRecord(preferredAccount)) { + return preferredAccount; + } + const firstKey = Object.keys(accountContainer)[0]; + const firstAccount = firstKey ? accountContainer[firstKey] : null; + return isObjectRecord(firstAccount) ? firstAccount : null; +} + +function compileHealthRegex( + pattern: string, + label: string, + channelName: string, + flags?: string, +): RegExp | null { + try { + return new RegExp(pattern, flags); + } catch (err) { + console.log( + ` ${YW}⚠${R} Invalid ${label} health pattern for '${channelName}': ${formatErrorMessage(err)}`, + ); + return null; + } +} + +function getObjectPath(value: unknown, dottedPath: string): unknown { + let current = value; + for (const segment of dottedPath.split(".").filter(Boolean)) { + if (!isObjectRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function getStringField(value: Record, field: string): string | undefined { + const entry = value[field]; + return typeof entry === "string" && entry.trim().length > 0 ? entry : undefined; +} + +function normalizeMaxLogLines(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 400; + return Math.min(Math.max(Math.trunc(value), 1), 2000); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function formatErrorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + async function planSandboxChannelAdd( sandboxName: string, channelId: string, @@ -1023,9 +1253,9 @@ export async function addSandboxChannel( if (!(await checkChannelAddConflict(sandboxName, canonical, acquired, force))) { return; // user aborted; nothing registered or widened } - // Credential axis passed; now the gateway-scoped Slack Socket Mode axis (#4953) - // catches the distinct-token, same-gateway case the credential check cannot. - if (!(await checkSlackSocketModeGatewayConflict(sandboxName, canonical, force))) { + // Credential axis passed; now channel-owned pre-enable hooks can catch + // channel-specific conflicts before provider/policy mutation. + if (!(await checkMessagingPreEnableHooks(sandboxName, canonical, plan, force))) { return; // user aborted; nothing registered or widened } assertAddChannelPlanActive(sandboxName, manifest, plan); @@ -1056,7 +1286,7 @@ export async function addSandboxChannel( console.log(` ${line}`); } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); - if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); + if (rebuilt) await runMessagingHealthChecksAfterRebuild(sandboxName, plan); return; } @@ -1102,7 +1332,7 @@ export async function addSandboxChannel( } const rebuilt = await promptAndRebuild(sandboxName, `add '${canonical}'`); - if (rebuilt) verifyChannelBridgeAfterRebuild(sandboxName, canonical); + if (rebuilt) await runMessagingHealthChecksAfterRebuild(sandboxName, plan); } async function rollbackChannelAdd( diff --git a/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts b/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts deleted file mode 100644 index 5db67c1b3e..0000000000 --- a/src/lib/actions/sandbox/telegram-channel-bridge-verification.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it, vi } from "vitest"; - -import { - getDefaultChannelAccount, - printTelegramDirectMessageAllowlistWarning, -} from "./telegram-channel-bridge-verification"; - -describe("telegram channel bridge verification", () => { - it("selects the default account when present", () => { - const account = { dmPolicy: "allowlist", allowFrom: ["123"] }; - - expect(getDefaultChannelAccount({ accounts: { other: {}, default: account } })).toBe(account); - }); - - it("falls back to the first account when default is absent", () => { - const account = { dmPolicy: "allowlist", allowFrom: ["123"] }; - - expect(getDefaultChannelAccount({ accounts: { main: account } })).toBe(account); - }); - - it("warns only when allowlist mode is active and no senders are configured", () => { - const log = vi.fn(); - - const emitted = printTelegramDirectMessageAllowlistWarning( - { accounts: { default: { dmPolicy: "allowlist", allowFrom: [] } } }, - log, - "WARN", - ); - - expect(emitted).toBe(true); - expect(log.mock.calls.map(([line]) => line).join("\n")).toContain( - "Telegram direct-message allowlist is empty", - ); - }); - - it("does not warn for pairing/default policy accounts", () => { - const log = vi.fn(); - - const emitted = printTelegramDirectMessageAllowlistWarning( - { accounts: { default: { allowFrom: [] } } }, - log, - ); - - expect(emitted).toBe(false); - expect(log).not.toHaveBeenCalled(); - }); - - it("does not warn when allowlist mode has senders", () => { - const log = vi.fn(); - - const emitted = printTelegramDirectMessageAllowlistWarning( - { accounts: { default: { dmPolicy: "allowlist", allowFrom: ["8388960805"] } } }, - log, - ); - - expect(emitted).toBe(false); - expect(log).not.toHaveBeenCalled(); - }); -}); diff --git a/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts b/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts deleted file mode 100644 index b402e711f4..0000000000 --- a/src/lib/actions/sandbox/telegram-channel-bridge-verification.ts +++ /dev/null @@ -1,39 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -type ChannelAccount = { - dmPolicy?: unknown; - allowFrom?: unknown; -}; - -function isRecord(value: unknown): value is Record { - return !!value && typeof value === "object"; -} - -export function getDefaultChannelAccount(channelBlock: unknown): ChannelAccount | null { - if (!isRecord(channelBlock) || !isRecord(channelBlock.accounts)) return null; - const accounts = channelBlock.accounts; - if (isRecord(accounts.default)) return accounts.default; - const firstKey = Object.keys(accounts)[0]; - const firstAccount = firstKey ? accounts[firstKey] : null; - return isRecord(firstAccount) ? firstAccount : null; -} - -export function printTelegramDirectMessageAllowlistWarning( - channelBlock: unknown, - log: (message: string) => void = console.log, - warningMarker = "!", -): boolean { - const account = getDefaultChannelAccount(channelBlock); - const allowFrom = Array.isArray(account?.allowFrom) ? account.allowFrom : []; - if (account?.dmPolicy !== "allowlist" || allowFrom.length > 0) return false; - - log(` ${warningMarker} Telegram direct-message allowlist is empty in baked openclaw.json.`); - log( - " Set TELEGRAM_ALLOWED_IDS before rebuild, or complete OpenClaw pairing before expecting DM replies.", - ); - log( - " Telegram Bot API sendMessage tests outbound delivery only; send from a Telegram client to test inbound agent replies.", - ); - return true; -} diff --git a/src/lib/channel-runtime-status.test.ts b/src/lib/channel-runtime-status.test.ts index 1e09fbc856..3bf795656d 100644 --- a/src/lib/channel-runtime-status.test.ts +++ b/src/lib/channel-runtime-status.test.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { buildGatewayLogScanScript, compareChannelSets, @@ -150,7 +150,10 @@ describe("buildGatewayLogScanScript", () => { const script = buildGatewayLogScanScript("/tmp/gateway.log"); expect(script).toContain("(launched|respawning)"); expect(script).toContain('buf=""'); - expect(script).toContain("grep -iwoE 'telegram|discord|slack|whatsapp|wechat|openclaw-weixin'"); + expect(script).toContain("grep -iwoE '"); + for (const token of ["telegram", "discord", "slack", "whatsapp", "wechat", "openclaw-weixin"]) { + expect(script).toContain(token); + } expect(script).not.toContain("tail -n"); expect(script).not.toContain("grep -m 1 -iwF 'telegram'"); }); diff --git a/src/lib/channel-runtime-status.ts b/src/lib/channel-runtime-status.ts index 6ab594bbb4..262565a056 100644 --- a/src/lib/channel-runtime-status.ts +++ b/src/lib/channel-runtime-status.ts @@ -21,10 +21,9 @@ * * 2. **Runtime layer** (`probeChannelRuntimeStatus`) tails the gateway * log at `/tmp/gateway.log` and checks each channel name. The log is - * where the OpenClaw process records its own boot events (the - * existing `getUpdates conflict` detection in `status-command-deps.ts` - * relies on the same file). If a channel never appears in the log, - * the runtime never tried to start it — the exact symptom behind + * where the OpenClaw process records its own boot events. If a + * manifest-declared channel never appears in the log, the runtime + * never tried to start it — the exact symptom behind * "No channels found" in the dashboard. * * The two signals combine: a channel is "runtime-visible" only when both @@ -38,20 +37,20 @@ * comparison logic stays unit-testable without touching a sandbox. */ -// OpenClaw's openclaw.json uses one key per channel under `channels.*`. -// Some channels are exposed under their canonical NemoClaw name (telegram, -// discord, slack, whatsapp); WeChat is bridged through the -// openclaw-weixin plugin, so the runtime key differs from the registry -// name. Keep the map narrow on purpose — an unknown channel key under -// `channels.*` is left out of the visible set rather than guessed at, -// because the registry side is authoritative for naming. -const CHANNEL_KEY_TO_NAME: Record = { - telegram: "telegram", - discord: "discord", - slack: "slack", - whatsapp: "whatsapp", - "openclaw-weixin": "wechat", -}; +import { + collectBuiltInMessagingStatusOutputs, + type OpenClawRuntimeChannelStatusOutput, +} from "./messaging/status-outputs"; + +const DEFAULT_RUNTIME_STATUS_OUTPUTS = collectBuiltInMessagingStatusOutputs({ + agent: "openclaw", +}).filter(isOpenClawRuntimeChannelStatusOutput); + +function isOpenClawRuntimeChannelStatusOutput( + output: ReturnType[number], +): output is OpenClawRuntimeChannelStatusOutput { + return output.type === "openclaw-runtime-channel"; +} export type RuntimeChannelStatus = { /** @@ -98,10 +97,9 @@ export interface ChannelRuntimeStatusDeps { configFilePath: string; /** * Path to the in-sandbox gateway log. Defaults to `/tmp/gateway.log` - * (the path OpenClaw's gateway writes when the agent starts — same - * file the existing Telegram-conflict probe in - * `src/lib/status-command-deps.ts` reads). Override only when running - * an alternate agent layout that ships logs elsewhere. + * (the path OpenClaw's gateway writes when the agent starts). + * Override only when running an alternate agent layout that ships logs + * elsewhere. */ gatewayLogPath?: string; /** Sandbox shell exec — returns `null` when the exec itself failed. */ @@ -113,16 +111,17 @@ export interface ChannelRuntimeStatusDeps { /** * Extract the set of channels with at least one enabled account from a parsed * OpenClaw config. Returns a sorted, deduplicated list of canonical channel - * names (telegram, discord, slack, whatsapp, wechat). Unknown keys under - * `channels.*` are ignored — registry-side names are authoritative. + * names. Unknown keys under `channels.*` are ignored — manifest-side + * channel names are authoritative. */ export function extractEnabledChannelsFromOpenclawConfig(json: unknown): string[] { if (!json || typeof json !== "object") return []; const channels = (json as Record).channels; if (!channels || typeof channels !== "object") return []; + const channelKeyToName = runtimeConfigKeyToChannelName(DEFAULT_RUNTIME_STATUS_OUTPUTS); const visible = new Set(); for (const [key, value] of Object.entries(channels as Record)) { - const canonical = CHANNEL_KEY_TO_NAME[key]; + const canonical = channelKeyToName.get(key); if (!canonical) continue; if (!value || typeof value !== "object") continue; const accounts = (value as Record).accounts; @@ -183,7 +182,9 @@ const GATEWAY_BOOT_MARKER_REGEX = "\\[gateway\\].*(launched|respawning)"; */ export function buildGatewayLogScanScript(gatewayLogPath: string): string { const quotedPath = shellQuote(gatewayLogPath); - const patternAlternation = RUNTIME_LOG_PATTERNS.map((entry) => entry.pattern).join("|"); + const patternAlternation = runtimeLogPatterns(DEFAULT_RUNTIME_STATUS_OUTPUTS) + .map(escapeExtendedRegexLiteral) + .join("|"); // The awk program uses single-quoted strings inside the shell single- // quote context, so we escape the embedded single quotes the same way // `shellQuote` does — '\'' ends the outer quote, injects a literal, @@ -212,34 +213,53 @@ export function buildGatewayLogScanScript(gatewayLogPath: string): string { */ export function parseGatewayLogScanOutput(stdout: string): Set { const found = new Set(); + const patternToChannel = runtimeLogPatternToChannelName(DEFAULT_RUNTIME_STATUS_OUTPUTS); for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed.startsWith(LOG_FOUND_PREFIX)) continue; const pattern = trimmed.slice(LOG_FOUND_PREFIX.length).toLowerCase(); - for (const entry of RUNTIME_LOG_PATTERNS) { - if (entry.pattern === pattern) { - found.add(entry.channel); - } - } + const channel = patternToChannel.get(pattern); + if (channel) found.add(channel); } return found; } -// Patterns to search the gateway log for. The first column is the literal -// token the OpenClaw runtime writes; the second is the canonical channel -// name the registry uses. WeChat boots through the openclaw-weixin plugin -// name, so we accept either token. Keep this list tight — the probe greps -// once per pattern so cost scales with the array length, not log size. -const RUNTIME_LOG_PATTERNS: readonly { pattern: string; channel: string }[] = [ - { pattern: "telegram", channel: "telegram" }, - { pattern: "discord", channel: "discord" }, - { pattern: "slack", channel: "slack" }, - { pattern: "whatsapp", channel: "whatsapp" }, - { pattern: "wechat", channel: "wechat" }, - { pattern: "openclaw-weixin", channel: "wechat" }, -]; const DEFAULT_GATEWAY_LOG_PATH = "/tmp/gateway.log"; +function runtimeConfigKeyToChannelName( + outputs: readonly OpenClawRuntimeChannelStatusOutput[], +): ReadonlyMap { + const aliases = new Map(); + for (const output of outputs) { + for (const key of output.configKeys) { + aliases.set(key, output.channelId); + } + } + return aliases; +} + +function runtimeLogPatterns(outputs: readonly OpenClawRuntimeChannelStatusOutput[]): string[] { + return [ + ...new Set(outputs.flatMap((output) => output.logPatterns).filter((entry) => entry.length > 0)), + ]; +} + +function runtimeLogPatternToChannelName( + outputs: readonly OpenClawRuntimeChannelStatusOutput[], +): ReadonlyMap { + const aliases = new Map(); + for (const output of outputs) { + for (const pattern of output.logPatterns) { + aliases.set(pattern.toLowerCase(), output.channelId); + } + } + return aliases; +} + +function escapeExtendedRegexLiteral(value: string): string { + return value.replace(/[\\^$.*+?()[\]{}|]/g, "\\$&"); +} + /** * Read the in-sandbox agent config AND the gateway log to determine which * channels the runtime exposes to the dashboard. Returns: diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index 148b9de957..e178ba1e66 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -485,11 +485,15 @@ describe("inventory commands", () => { it("marks a shared-gateway Slack Socket Mode overlap as conflicted (#4953)", () => { const lines: string[] = []; - const findMessagingOverlaps = vi - .fn() - .mockReturnValue([ - { channel: "slack", sandboxes: ["alice", "bob"], reason: "slack-socket-mode-gateway" }, - ]); + const findMessagingOverlaps = vi.fn().mockReturnValue([ + { + channel: "slack", + sandboxes: ["alice", "bob"], + reason: "socket-mode-gateway", + message: + "'{first}' and '{second}' both have Slack Socket Mode enabled on the same gateway; only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.", + }, + ]); showStatusCommand({ listSandboxes: () => ({ sandboxes: [ diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index 74bb7d51b4..bc3efc33a3 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -87,10 +87,8 @@ export interface SandboxInventoryResult { export interface MessagingOverlap { channel: string; sandboxes: [string, string]; - // "slack-socket-mode-gateway": both sandboxes have Slack Socket Mode active on - // the same OpenShell gateway, so only one receives events (#4953) — distinct - // from the credential-sharing reasons, which catch a *shared* token. - reason?: "matching-token" | "unknown-token" | "slack-socket-mode-gateway"; + reason?: "matching-token" | "unknown-token" | string; + message?: string; } export interface GatewayHealth { @@ -478,11 +476,9 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const overlaps = deps.findMessagingOverlaps(); if (overlaps.length > 0) { log(""); - for (const { channel, sandboxes: pair, reason } of overlaps) { - if (reason === "slack-socket-mode-gateway") { - log( - ` ⚠ '${pair[0]}' and '${pair[1]}' both have Slack Socket Mode enabled on the same gateway; only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.`, - ); + for (const { channel, sandboxes: pair, reason, message } of overlaps) { + if (message) { + log(` ⚠ ${formatMessagingOverlapMessage(message, channel, pair)}`); continue; } const detail = @@ -529,3 +525,14 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { } } } + +function formatMessagingOverlapMessage( + template: string, + channel: string, + pair: readonly [string, string], +): string { + return template + .replaceAll("{channel}", channel) + .replaceAll("{first}", pair[0]) + .replaceAll("{second}", pair[1]); +} diff --git a/src/lib/messaging/MIGRATION.md b/src/lib/messaging/MIGRATION.md new file mode 100644 index 0000000000..8ae217768b --- /dev/null +++ b/src/lib/messaging/MIGRATION.md @@ -0,0 +1,645 @@ + + +# Messaging Channel Manifest Migration + +This plan tracks the remaining migration from concrete channel logic in core code +to manifest-owned metadata and hooks under `src/lib/messaging/channels/*`. + +Required channels for this pass: + +- Telegram +- WeChat +- Slack +- Discord + +WhatsApp is optional for this pass, but shared helpers must remain generic so +WhatsApp can continue using the same framework. + +## Goals + +- Keep all channel-specific metadata in channel manifests. +- Keep all channel-specific behavior in channel hook implementations. +- Keep common orchestration, persistence, provider binding, policy application, + and conflict detection in shared messaging framework code. +- Treat `SandboxEntry.messaging.plan` as the durable messaging source of truth. +- Do not introduce another durable state source for channel config. +- Do not persist raw messaging secrets in host-side state. +- Preserve current CLI behavior while replacing hard-coded channel branches. + +## Non-Goals + +- Do not rewrite the full messaging compiler/applier architecture. +- Do not migrate unrelated inference, gateway, or policy behavior. +- Do not require WhatsApp behavior changes unless a generic helper naturally + affects it. +- Do not remove legacy compatibility fields until the registry-backed plan path + is verified for existing sandboxes. + +## Hook-First Migration Model + +The migration should define the complete hook phase contract first, then migrate +each concrete behavior into either a common hook or a channel-specific hook. Core +code should call `src/lib/messaging/applier` phase runners instead of directly +calling Telegram, WeChat, Slack, or Discord implementation details. + +Execution model: + +1. The compiler produces a serializable `SandboxMessagingPlan`. +2. The caller invokes a phase runner from `src/lib/messaging/applier`. +3. The applier selects enabled channel hooks for the requested phase. +4. Common hooks run where the behavior is shared across channels. +5. Channel-specific hooks run only from the owning channel directory. +6. The applier returns structured results; CLI code handles prompts, output, and + exit behavior at the edge. + +Direct calls from core to concrete channel implementation should disappear. A +core call site may call `MessagingSetupApplier`, `MessagingHostStateApplier`, or +another applier entrypoint, but should not import channel-specific hook files. + +## Required Hook Phases + +### Existing Phases To Keep + +- `enroll` + - collects channel inputs and secrets + - already used by token-paste and WeChat QR login hooks +- `reachability-check` + - validates freshly collected enrollment inputs + - already used by Telegram getMe-style checks +- `agent-install` + - produces build-time package install steps + - already used by OpenClaw channel package installation +- `render` + - produces agent config render fragments + - keep as a compiler/render phase +- `apply` + - applies config into an existing sandbox + - already partially handled by `applyAgentConfigAtOpenShell` +- `post-agent-install` + - runs after package install/config render when a channel needs generated + files or final config patching + - already used by WeChat account seeding +- `health-check` + - runs after create/rebuild and before lifecycle success is reported +- `status` + - cheap bounded status checks for `status` and `channels status` +- `diagnostic` + - deeper or slower diagnostics used by `doctor` or explicit channel checks + +### New Phases To Add + +- `pre-enable` + - host-side checks after plan compilation and before provider, policy, + registry, or sandbox mutation + - used for channel-specific blocking/warning checks such as Slack Socket Mode + gateway conflict +- `runtime-preload` + - sandbox-side startup behavior that must be installed before OpenClaw starts + - used for Telegram diagnostics preload, Slack channel guard, Slack runtime + normalization/tripwire, and WeChat diagnostics preload + +State replay is not a hook phase. `SandboxEntry.messaging.plan` remains the +durable source of truth, and `plan.stateUpdates` should be applied by common +applier code. Legacy session fields such as `telegramConfig` and `wechatConfig` +can remain read-only compatibility fallbacks while the plan path is completed. + +## Phase Ownership + +### `pre-enable` + +Applier responsibilities: + +- preserve common credential conflict detection as shared guard logic, not as a + hook +- run channel-specific `pre-enable` hooks +- normalize failure into a structured result: + - proceed + - warn and ask + - abort + - skipped because phase is not relevant + +Call sites: + +- `src/lib/onboard/sandbox-messaging-preflight.ts` + - after reading the staged `SandboxMessagingPlan` + - before create/recreate continues into provider/policy setup +- `src/lib/actions/sandbox/policy-channel.ts` + - `channels add`: after `planSandboxChannelAdd()`, before provider/policy + registration and before `MessagingHostStateApplier.applyPlanToRegistry()` + - `channels start`: before persisting the re-enabled plan + +Concrete behavior to migrate: + +- common credential conflict checks from: + - `src/lib/onboard/messaging-conflict-guard.ts` + - `src/lib/actions/sandbox/policy-channel.ts` + - keep this as shared guard behavior because it applies to every credentialed + channel +- Slack Socket Mode gateway conflict from: + - `src/lib/messaging/applier/conflict-detection/slack-socket-mode.ts` + - `src/lib/onboard/messaging-conflict-guard.ts` + - `src/lib/actions/sandbox/policy-channel.ts` + - migrate this channel-specific axis into a Slack `pre-enable` hook + +### `runtime-preload` + +Applier responsibilities: + +- collect runtime preload hooks for enabled channels +- stage any preload scripts or generated shell fragments needed by the sandbox +- expose a shell-consumable plan artifact for `scripts/nemoclaw-start.sh` +- keep startup behavior channel-owned even when shell performs the final install + +Concrete behavior to migrate: + +- Telegram diagnostics preload: + - `scripts/nemoclaw-start.sh` + - `nemoclaw-blueprint/scripts/telegram-diagnostics.js` +- Slack channel guard preload: + - `scripts/nemoclaw-start.sh` + - `nemoclaw-blueprint/scripts/slack-channel-guard.js` +- Slack runtime env normalization and secret tripwire: + - `scripts/nemoclaw-start.sh` +- WeChat diagnostics preload: + - `nemoclaw-blueprint/scripts/wechat-diagnostics.js` + +Implementation note: + +- Shell cannot import TypeScript manifests directly. +- The applier should generate/stage a compact runtime artifact that shell can + consume without knowing channel details. + +### `health-check` + +Applier responsibilities: + +- run `plan.healthChecks` after create/rebuild readiness +- call hook handlers through the central hook runner +- keep checks bounded and deterministic +- return structured check results to the lifecycle caller + +Concrete behavior to migrate: + +- WeChat health check application: + - `src/lib/messaging/channels/wechat/hooks/health-check.ts` +- Telegram bridge startup and DM allowlist warnings: + - moved into `telegram-openclaw-bridge-health` manifest output +- OpenClaw bridge startup verification for Telegram/Discord/Slack: + - moved into static `health-check` hook outputs consumed by + `src/lib/actions/sandbox/policy-channel.ts` + +### `status` + +Applier responsibilities: + +- expose a cheap status runner for configured/enabled channels +- use manifest/hook-provided runtime aliases and log signatures +- avoid deep probes or long waits + +Concrete behavior to migrate: + +- runtime config key aliases and log patterns: + - `src/lib/channel-runtime-status.ts` +- Telegram conflict log signatures: + - `src/lib/status-command-deps.ts` +- Slack gateway overlap reporting: + - `src/lib/status-command-deps.ts` + - `src/lib/messaging/applier/conflict-detection/slack-socket-mode.ts` +- channel display/known-channel validation: + - `src/lib/actions/sandbox/channel-status.ts` + +### `diagnostic` + +Applier responsibilities: + +- run deeper channel diagnostics when explicitly requested +- allow channel-specific diagnostic output while keeping the CLI orchestration + common +- keep common parsers generic and channel signatures hook-owned + +Concrete behavior to migrate: + +- detailed runtime channel checks currently split across: + - `src/lib/channel-runtime-status.ts` + - `src/lib/actions/sandbox/channel-status.ts` + - `src/lib/actions/sandbox/doctor.ts` + +### State Updates + +State updates stay in common applier code, not in concrete core branches. + +Applier responsibilities: + +- apply `plan.stateUpdates` +- persist serializable channel config into `SandboxEntry.messaging.plan` +- replay config for rebuild planning from the plan +- keep secrets out of host-side state + +Concrete behavior to migrate: + +- Telegram mention mode drift/config handling in: + - `src/lib/onboard/messaging-config.ts` + - `src/lib/onboard/sandbox-build-patch-config.ts` + - `src/lib/onboard/machine/handlers/sandbox.ts` +- WeChat config gather/hydration/drift handling in: + - `src/lib/onboard/wechat-config.ts` + - `src/lib/actions/sandbox/rebuild.ts` + - `src/lib/actions/sandbox/policy-channel.ts` + - `src/lib/onboard/sandbox-build-patch-config.ts` + - `src/lib/onboard/machine/handlers/sandbox.ts` + +## Straggler Inventory + +### Channel Catalog and Metadata + +- `src/lib/sandbox/channels.ts` + - old channel catalog with env keys, prompts, token formats, labels, login + modes, and config env keys +- `src/lib/messaging-channel-config.ts` + - config env aliases, including Discord aliases +- `src/lib/onboard/messaging-prep.ts` + - static provider/env mapping +- `src/lib/onboard/messaging-reuse.ts` + - hard-coded provider names +- `src/lib/onboard/messaging-credentials.ts` + - env-key-to-channel mapping +- `src/lib/onboard/extra-placeholder-keys.ts` + - messaging credential placeholder keys +- `src/lib/onboard/sandbox-provider-cleanup.ts` + - hard-coded provider suffix cleanup +- `src/lib/actions/sandbox/snapshot.ts` + - hard-coded provider suffixes +- `src/lib/credentials/store.ts` + - static messaging credential env keys +- `src/lib/credentials/command-support.ts` + - concrete bridge provider suffixes +- `src/lib/security/redact.ts` + - concrete messaging token redaction keys + +### Policy + +- `src/lib/policy/index.ts` + - policy labels, aliases, and Discord-specific messaging +- `src/lib/onboard/policy-presets.ts` + - explicit channel env to preset suggestions +- `src/lib/onboard/initial-policy.ts` + - Hermes messaging policy key mapping +- `src/lib/onboard/messaging-policy-presets.ts` + - Slack-specific required preset mapping + +### Build and Agent Install + +- `src/lib/messaging/applier/build/messaging-build-applier.mts` + - OpenClaw package allowlist for Discord, Slack, WeChat +- `src/lib/sandbox/build-context.ts` + - stages Slack-specific patch script +- `scripts/patch-openclaw-slack-deny-feedback.mts` + - Slack package compatibility patch + +### Runtime Scripts + +- `scripts/nemoclaw-start.sh` + - placeholder key lists + - Telegram/Discord/OpenClaw credential field mapping + - Slack env normalization + - Slack secret tripwire + - Telegram diagnostics install + - Slack guard install + - hard-coded channel command help +- `scripts/lib/sandbox-init.sh` + - active channel logging for Telegram/Discord/Slack +- `scripts/install.sh` + - non-interactive env help for Discord/Slack/Telegram + +### WeChat Host-Side Logic + +- `src/lib/host-qr-handlers.ts` + - old host QR handler registry +- `src/ext/wechat/login.ts` + - WeChat login implementation +- `src/ext/wechat/qr.ts` + - WeChat QR rendering/helper implementation + +Keep implementation helpers if useful, but invoke them only from WeChat channel +hooks. + +### Runtime Status + +- `src/lib/channel-runtime-status.ts` + - runtime config key map and gateway log patterns +- `src/lib/status-command-deps.ts` + - Telegram and Slack concrete status signatures +- `src/lib/actions/sandbox/channel-status.ts` + - known channel validation and WhatsApp-specialized diagnostics + +## Implementation Sequence + +### Step 0: Plan Approval + +- Add this migration plan. +- Do not change runtime behavior. +- Wait for maintainer approval before implementation. + +### Step 1: Define Phase Contracts + +Add all required phase names to `ChannelHookPhase` before migrating behavior. + +Required additions: + +- `pre-enable` +- `runtime-preload` + +Keep existing phases: + +- `enroll` +- `reachability-check` +- `agent-install` +- `render` +- `apply` +- `post-agent-install` +- `health-check` +- `status` +- `diagnostic` + +Do not add separate applier phase unions or speculative phase result types in +this step. Applier execution should use `ChannelHookPhase`, +`MessagingHookApplyRequest`, and `MessagingHookRunResult` unless a later runner +needs a concrete additional contract. + +Validation: + +- `npm run typecheck:cli` +- manifest type tests +- hook runner tests + +### Step 2: Centralize Phase Execution In Applier + +Create applier entrypoints that all core call sites can use. + +Expected entrypoints: + +- `applyPreEnableChecks(plan, context)` +- `applyRuntimePreloads(plan, context)` +- `applyHealthChecks(plan, context)` +- `applyStatusChecks(plan, context)` +- `applyDiagnostics(plan, context)` +- shared hook request builder for all phases + +The applier should: + +- select enabled plan channels +- select hooks matching the phase +- run common phase hooks before channel-specific hooks when both apply +- honor hook failure policy +- return structured results instead of printing or exiting directly +- introduce result/context types only when the first real runner needs them + +Validation: + +- hook runner tests +- applier tests with fake plans and fake hooks +- plan-filter tests + +### Step 3: Implement Common Hooks + +Implement common hooks for behavior shared across channels. + +Initial common hooks: + +- plan state update/replay helper, invoked by applier state code rather than + concrete core branches +- generic runtime channel config/log comparison for `status` +- generic provider/policy metadata helpers where they are currently hard-coded + +Do not move the shared credential conflict guard into a hook. It applies to +every credentialed channel and should remain a shared guard that runs before +channel-specific `pre-enable` hooks. + +Validation: + +- conflict detection tests +- host-state applier tests +- channel runtime status tests + +### Step 4: Implement Channel-Specific Hooks + +Move channel-specific behavior into the owning channel directory. + +Custom hook inventory by phase: + +| Phase | Channel | Hook | Migration status | +|---|---|---|---| +| `pre-enable` | Slack | `slack-socket-mode-gateway-conflict` | migrated | +| `runtime-preload` | Slack | `slack-runtime-preload` | migrated | +| `runtime-preload` | Telegram | `telegram-runtime-preload` | migrated | +| `runtime-preload` | WeChat | `wechat-runtime-preload` | migrated | +| `runtime-preload` | WhatsApp | `whatsapp-runtime-preload` | migrated, optional channel | +| `runtime-preload` | Discord | none | no current runtime preload behavior found | +| `health-check` | Telegram | `telegram-openclaw-bridge-health` | migrated | +| `health-check` | WeChat | `wechat-health-check` | migrated caller path | +| `health-check` | Slack | `slack-openclaw-bridge-health` | migrated | +| `health-check` | Discord | `discord-openclaw-bridge-health` | migrated | +| `status` | Telegram | getUpdates conflict/status signatures | migrated | +| `status` | Slack | gateway overlap reporting | migrated | +| `status` | Discord | runtime alias/log signatures | migrated | +| `diagnostic` | Telegram | common channel status/policy diagnostics | migrated, common helper | +| `diagnostic` | Slack | common channel status plus manifest status overlap | migrated, common helper | +| `diagnostic` | Discord | common policy/config diagnostics | migrated, common helper | +| `diagnostic` | WeChat | common channel status/policy diagnostics | migrated, common helper | +| `diagnostic` | WhatsApp | common metadata plus existing optional QR deep probe | migrated, optional channel | + +Slack hooks: + +- `pre-enable`: Socket Mode gateway conflict +- `runtime-preload`: channel guard install, runtime placeholder normalization, + secret-on-disk tripwire +- `status`: gateway overlap reporting + +Telegram hooks: + +- `reachability-check`: keep getMe-style verification +- `runtime-preload`: diagnostics preload +- `health-check`: bridge startup and DM allowlist warnings +- `status`: getUpdates conflict signature + +WeChat hooks: + +- `enroll`: keep host QR login +- `post-agent-install`: keep account seeding +- `runtime-preload`: diagnostics preload +- `health-check`: account/iLink sanity + +Discord hooks: + +- `agent-install`: package install metadata +- `status`: runtime alias/log signature +- `diagnostic`: handled by common manifest-derived channel status helper + +Validation: + +- channel hook unit tests +- existing Telegram/Slack/WeChat tests +- manifest tests confirming hooks are declared by channel manifests + +### Step 5: Migrate Core Call Sites To Applier Calls + +Replace concrete channel calls in core with applier phase calls. + +Primary migrations: + +- onboard/create preflight calls `applyPreEnableChecks` +- `channels add` calls `applyPreEnableChecks` +- `channels start` calls `applyPreEnableChecks` +- create/rebuild finalization calls `applyHealthChecks` +- status commands call `applyStatusChecks` +- doctor/deep diagnostics call `applyDiagnostics` +- sandbox build/start path consumes `applyRuntimePreloads` outputs + +Validation: + +- onboard messaging tests +- channels add/start/stop/remove tests +- status and doctor tests +- rebuild tests + +### Step 6: Plan-Owned State Replay + +Make `SandboxEntry.messaging.plan` the authoritative source for channel config +replay through common applier state handling. + +Migrate: + +- Telegram mention mode +- WeChat account/base URL/user ID +- Slack allowed users/channels +- Discord allowed IDs/server IDs + +Keep old session fields as read-only compatibility fallback where needed, but +stop writing new channel state there once the plan path is active. + +Validation: + +- onboard session plan tests +- rebuild plan tests +- WeChat manifest tests +- Telegram config tests + +### Step 7: Manifest Metadata Adapter + +After phase runners are in place, replace remaining metadata-only hard-coded +lists with manifest-backed helpers. + +Shared helpers should resolve: + +- available channels by agent +- credential env keys +- channel for env key +- provider names and suffixes +- config env keys and aliases +- policy presets and policy key aliases +- OpenClaw runtime channel keys and aliases +- package install specs + +Replace `src/lib/sandbox/channels.ts` with a compatibility adapter over this +metadata. Keep its public shape stable for existing callers. + +Validation: + +- `npm run typecheck:cli` +- targeted tests for `src/lib/sandbox/channels.ts` +- manifest registry/compiler tests + +### Step 8: Remove Remaining Hard-Coded Lists + +Replace remaining concrete channel lists with manifest/applier-derived helpers. + +Primary files: + +- `src/lib/onboard/messaging-prep.ts` +- `src/lib/onboard/messaging-reuse.ts` +- `src/lib/onboard/messaging-credentials.ts` +- `src/lib/onboard/policy-presets.ts` +- `src/lib/onboard/initial-policy.ts` +- `src/lib/onboard/messaging-policy-presets.ts` +- `src/lib/actions/sandbox/snapshot.ts` +- `src/lib/credentials/store.ts` +- `src/lib/credentials/command-support.ts` +- `src/lib/security/redact.ts` +- `scripts/lib/sandbox-init.sh` +- `scripts/install.sh` + +Treat `src/lib/deploy/index.ts` as optional/legacy unless deploy messaging +support is confirmed in scope. + +Validation: + +- `npm run typecheck:cli` +- targeted messaging tests +- `npm test` when behavior changes cross CLI boundaries + +## Approval Gate + +Implementation should start only after this hook-first plan is approved. + +Proposed first implementation task after approval: + +1. Add `pre-enable` and `runtime-preload` to `ChannelHookPhase`. +2. Add applier phase context/result types. +3. Add no-op applier phase runners with tests. +4. Keep runtime behavior unchanged until concrete hooks are migrated. + +## Implementation Progress + +Completed: + +- Added `pre-enable` and `runtime-preload` to `ChannelHookPhase`. +- Added `MessagingSetupApplier.applyHooksForPhase()` and phase helpers that run + manifest-declared hooks through the existing hook runner contract. +- Kept `enforceMessagingChannelConflicts` as shared guard behavior. Do not move + the common credential conflict axis into a hook. +- Added Slack `pre-enable` hook `slack.socketModeGatewayConflict` for the + Socket Mode gateway axis and declared it in the Slack manifest. +- Migrated `channels add` from direct Slack Socket Mode helper calls to + `MessagingSetupApplier.applyHooksForPhase(plan, "pre-enable", ...)`. +- Migrated onboard's `enforceMessagingChannelConflicts` Slack gateway axis to + the same Slack `pre-enable` hook while keeping the shared credential-conflict + guard in place. +- Declared OpenClaw `runtime-preload` hook outputs in channel manifests for: + - Slack runtime env aliasing, channel guard preload, and secret scan + - Telegram diagnostics preload + - WeChat diagnostics preload + - WhatsApp compact-QR connect preload +- Migrated `scripts/nemoclaw-start.sh` from concrete channel preload functions + to a generic runtime-preload consumer that reads active channel hook outputs + from `NEMOCLAW_MESSAGING_PLAN_B64`. +- Declared static OpenClaw bridge startup health outputs for Telegram, Slack, + and Discord, including Telegram DM allowlist warning metadata. +- Migrated `channels add` post-rebuild checks to + `MessagingSetupApplier.applyHooksForPhase(plan, "health-check", ...)` and a + generic health-check output consumer. The old Telegram action helper was + removed. +- Declared static status outputs for: + - OpenClaw runtime config aliases and gateway-log patterns for Telegram, + Slack, Discord, WeChat, and WhatsApp + - Telegram gateway-log conflict signatures + - Slack single-gateway overlap reporting +- Migrated `channel-runtime-status`, bare `status` bridge checks, and status + overlap reporting from concrete channel maps/branches to manifest-derived + status outputs. +- Migrated common diagnostic metadata to a shared manifest-derived helper + instead of channel hooks, since required channel diagnostics are the same + registration/policy/status surface. +- Migrated `channels status` channel validation, default channel selection, and + policy coverage from the legacy concrete channel registry to that common + manifest helper. +- Migrated `doctor` messaging diagnostics to use common manifest-derived + deep-probe hints and manifest-derived gateway-overlap status outputs. + +Pending: + +- Optional follow-up: relocate the existing WhatsApp in-sandbox QR probe body + from `actions/sandbox/channel-status.ts` into a channel-owned diagnostic hook + implementation if WhatsApp is pulled into required scope. diff --git a/src/lib/messaging/applier/hook-phases.test.ts b/src/lib/messaging/applier/hook-phases.test.ts new file mode 100644 index 0000000000..601226e51d --- /dev/null +++ b/src/lib/messaging/applier/hook-phases.test.ts @@ -0,0 +1,279 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { MessagingHookApplyRequest, MessagingHookApplyRunner } from "./types"; +import { + applyDiagnostics, + applyPreEnableChecks, + applyRuntimePreloads, + MessagingSetupApplier, +} from "./index"; +import type { + ChannelHookPhase, + MessagingChannelId, + SandboxMessagingChannelPlan, + SandboxMessagingPlan, +} from "../manifest"; + +describe("messaging applier hook phases", () => { + it("runs enabled channel hooks for the requested phase through the provided runner", async () => { + const calls: MessagingHookApplyRequest[] = []; + const result = await applyPreEnableChecks(makePlan(), { + runHook: (request) => { + calls.push(request); + return { + outputs: { + checked: { + kind: "config", + value: "ok", + }, + }, + }; + }, + }); + + expect(calls).toEqual([ + expect.objectContaining({ + sandboxName: "demo", + agent: "openclaw", + channelId: "telegram", + hookId: "telegram-pre-enable", + phase: "pre-enable", + handler: "telegram.preEnable", + inputs: { + allowedIds: "12345", + "allowedIds.telegram": "12345", + "credential.telegramBotToken.placeholder": "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + }, + }), + ]); + expect(result).toMatchObject({ + phase: "pre-enable", + appliedHooks: ["telegram:telegram-pre-enable"], + skippedHooks: [], + }); + expect(result.hookResults).toEqual([ + { + hookId: "telegram-pre-enable", + handlerId: "telegram.preEnable", + phase: "pre-enable", + outputs: { + checked: { + kind: "config", + value: "ok", + }, + }, + }, + ]); + }); + + it("requires a runner only when the selected phase has matching hooks", async () => { + await expect(applyPreEnableChecks(makePlan())).rejects.toThrow( + "Messaging hook phase 'pre-enable' requires a hook runner.", + ); + + await expect(applyDiagnostics(makePlan())).resolves.toEqual({ + phase: "diagnostic", + hookRequests: [], + hookResults: [], + appliedHooks: [], + skippedHooks: [], + }); + }); + + it("merges phase-level inputs into hook requests", async () => { + const calls: MessagingHookApplyRequest[] = []; + + await applyPreEnableChecks(makePlan(), { + additionalInputs: { + currentSandbox: "demo", + currentGatewayName: "nemoclaw", + }, + runHook: (request) => { + calls.push(request); + }, + }); + + expect(calls[0]?.inputs).toMatchObject({ + allowedIds: "12345", + currentSandbox: "demo", + currentGatewayName: "nemoclaw", + }); + }); + + it("keeps phase wrappers on the shared ChannelHookPhase type", async () => { + const phases: ChannelHookPhase[] = []; + await applyRuntimePreloads(makePlan(), { + runHook: (request) => { + phases.push(request.phase); + }, + }); + + expect(phases).toEqual(["runtime-preload"]); + }); + + it("honors skip-channel failure policy and continues later hooks", async () => { + const runHook: MessagingHookApplyRunner = (request) => { + if (request.hookId === "telegram-pre-enable") { + throw new Error("telegram skipped"); + } + return { + hookId: request.hookId, + handlerId: request.handler, + phase: request.phase, + outputs: {}, + }; + }; + + const result = await MessagingSetupApplier.applyHooksForPhase( + makePlan({ + telegramOnFailure: "skip-channel", + includeDiscordPreEnable: true, + }), + "pre-enable", + { runHook }, + ); + + expect(result.skippedHooks).toEqual(["telegram:telegram-pre-enable"]); + expect(result.appliedHooks).toEqual(["discord:discord-pre-enable"]); + expect(result.hookResults).toEqual([ + { + hookId: "discord-pre-enable", + handlerId: "discord.preEnable", + phase: "pre-enable", + outputs: {}, + }, + ]); + }); +}); + +function makePlan( + options: { + readonly telegramOnFailure?: "abort" | "skip-channel"; + readonly includeDiscordPreEnable?: boolean; + } = {}, +): SandboxMessagingPlan { + const channels: SandboxMessagingChannelPlan[] = [ + makeChannel("telegram", { + hooks: [ + { + channelId: "telegram", + id: "telegram-pre-enable", + phase: "pre-enable", + handler: "telegram.preEnable", + inputs: ["allowedIds", "allowedIds.telegram", "credential.telegramBotToken.placeholder"], + outputs: [ + { + id: "checked", + kind: "config", + }, + ], + onFailure: options.telegramOnFailure, + }, + { + channelId: "telegram", + id: "telegram-runtime-preload", + phase: "runtime-preload", + handler: "telegram.runtimePreload", + }, + ], + }), + makeChannel("slack", { + active: false, + disabled: true, + hooks: [ + { + channelId: "slack", + id: "slack-pre-enable", + phase: "pre-enable", + handler: "slack.preEnable", + }, + ], + }), + ]; + if (options.includeDiscordPreEnable) { + channels.push( + makeChannel("discord", { + hooks: [ + { + channelId: "discord", + id: "discord-pre-enable", + phase: "pre-enable", + handler: "discord.preEnable", + }, + ], + }), + ); + } + + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "add-channel", + channels, + disabledChannels: ["slack"], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "demo-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + ], + networkPolicy: { + presets: ["telegram"], + entries: [ + { + channelId: "telegram", + presetName: "telegram", + policyKeys: ["telegram"], + source: "manifest", + }, + ], + }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }; +} + +function makeChannel( + channelId: MessagingChannelId, + options: { + readonly active?: boolean; + readonly disabled?: boolean; + readonly hooks?: SandboxMessagingChannelPlan["hooks"]; + } = {}, +): SandboxMessagingChannelPlan { + return { + channelId, + displayName: channelId, + authMode: "token-paste", + active: options.active ?? true, + selected: true, + configured: true, + disabled: options.disabled ?? false, + inputs: + channelId === "telegram" + ? [ + { + channelId, + inputId: "allowedIds", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_ALLOWED_IDS", + statePath: "allowedIds.telegram", + value: "12345", + }, + ] + : [], + hooks: options.hooks ?? [], + }; +} diff --git a/src/lib/messaging/applier/hook-phases.ts b/src/lib/messaging/applier/hook-phases.ts new file mode 100644 index 0000000000..9898e34fdd --- /dev/null +++ b/src/lib/messaging/applier/hook-phases.ts @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + MessagingHookInputMap, + MessagingHookOutputMap, + MessagingHookRunResult, +} from "../hooks"; +import type { + ChannelHookPhase, + MessagingSerializableValue, + SandboxMessagingPlan, +} from "../manifest"; +import { listHookRequests } from "./agent-config"; +import type { ConflictRegistryEntry } from "./conflict-detection/types"; +import type { MessagingHookApplyRequest, MessagingHookApplyRunner } from "./types"; + +const EMPTY_OUTPUTS: MessagingHookOutputMap = Object.freeze({}); + +export interface MessagingHookPhaseOptions { + readonly runHook?: MessagingHookApplyRunner; + readonly additionalInputs?: MessagingHookInputMap; +} + +export interface MessagingPreEnableHookInputContext { + readonly currentSandbox?: string | null; + readonly currentGatewayName?: string | null; + readonly registryEntries?: readonly ConflictRegistryEntry[]; +} + +export function createMessagingPreEnableHookInputs( + context: MessagingPreEnableHookInputContext, +): MessagingHookInputMap { + const inputs: Record = {}; + if (context.currentSandbox !== undefined) { + inputs.currentSandbox = context.currentSandbox; + } + if (context.currentGatewayName !== undefined) { + inputs.currentGatewayName = context.currentGatewayName; + } + if (context.registryEntries) { + inputs.registryEntries = context.registryEntries.map(serializeRegistryEntry); + } + return inputs; +} + +export async function applyMessagingHooksForPhase( + plan: SandboxMessagingPlan, + phase: ChannelHookPhase, + options: MessagingHookPhaseOptions = {}, +): Promise<{ + readonly phase: ChannelHookPhase; + readonly hookRequests: readonly MessagingHookApplyRequest[]; + readonly hookResults: readonly MessagingHookRunResult[]; + readonly appliedHooks: readonly string[]; + readonly skippedHooks: readonly string[]; +}> { + const hookRequests = listHookRequests(plan, phase); + if (hookRequests.length > 0 && !options.runHook) { + throw new Error(`Messaging hook phase '${phase}' requires a hook runner.`); + } + + const hookResults: MessagingHookRunResult[] = []; + const appliedHooks: string[] = []; + const skippedHooks: string[] = []; + for (const request of hookRequests) { + const requestWithInputs = withAdditionalInputs(request, options.additionalInputs); + try { + const result = await options.runHook?.(requestWithInputs); + appliedHooks.push(formatHookKey(requestWithInputs)); + hookResults.push(normalizeHookRunResult(requestWithInputs, result)); + } catch (error) { + if (requestWithInputs.onFailure === "skip-channel") { + skippedHooks.push(formatHookKey(requestWithInputs)); + continue; + } + throw error; + } + } + + return { + phase, + hookRequests, + hookResults, + appliedHooks, + skippedHooks, + }; +} + +export function applyPreEnableChecks( + plan: SandboxMessagingPlan, + options?: MessagingHookPhaseOptions, +): ReturnType { + return applyMessagingHooksForPhase(plan, "pre-enable", options); +} + +export function applyRuntimePreloads( + plan: SandboxMessagingPlan, + options?: MessagingHookPhaseOptions, +): ReturnType { + return applyMessagingHooksForPhase(plan, "runtime-preload", options); +} + +export function applyHealthChecks( + plan: SandboxMessagingPlan, + options?: MessagingHookPhaseOptions, +): ReturnType { + return applyMessagingHooksForPhase(plan, "health-check", options); +} + +export function applyStatusChecks( + plan: SandboxMessagingPlan, + options?: MessagingHookPhaseOptions, +): ReturnType { + return applyMessagingHooksForPhase(plan, "status", options); +} + +export function applyDiagnostics( + plan: SandboxMessagingPlan, + options?: MessagingHookPhaseOptions, +): ReturnType { + return applyMessagingHooksForPhase(plan, "diagnostic", options); +} + +function normalizeHookRunResult( + request: MessagingHookApplyRequest, + result: void | MessagingHookRunResult | { readonly outputs?: MessagingHookOutputMap } | undefined, +): MessagingHookRunResult { + if (result && "hookId" in result && "handlerId" in result && "phase" in result) { + return result; + } + return { + hookId: request.hookId, + handlerId: request.handler, + phase: request.phase, + outputs: result?.outputs ?? EMPTY_OUTPUTS, + }; +} + +function withAdditionalInputs( + request: MessagingHookApplyRequest, + additionalInputs: MessagingHookInputMap | undefined, +): MessagingHookApplyRequest { + if (!additionalInputs || Object.keys(additionalInputs).length === 0) return request; + return { + ...request, + inputs: { + ...request.inputs, + ...additionalInputs, + }, + }; +} + +function formatHookKey(request: MessagingHookApplyRequest): string { + return `${request.channelId}:${request.hookId}`; +} + +function serializeRegistryEntry(entry: ConflictRegistryEntry): MessagingSerializableValue { + return { + name: entry.name, + gatewayName: entry.gatewayName ?? null, + messaging: entry.messaging?.plan + ? { + plan: entry.messaging.plan as unknown as MessagingSerializableValue, + } + : null, + }; +} diff --git a/src/lib/messaging/applier/index.ts b/src/lib/messaging/applier/index.ts index 85a6eae92a..3cec51ee91 100644 --- a/src/lib/messaging/applier/index.ts +++ b/src/lib/messaging/applier/index.ts @@ -4,6 +4,7 @@ export * from "./setup-applier"; export * from "./host-state-applier"; export * from "./agent-config"; +export * from "./hook-phases"; export * from "./conflict-detection"; export * from "./openshell-provider"; export * from "./policy"; diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index bc862028a0..59c7e47e49 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -425,6 +425,11 @@ describe("MessagingSetupApplier", () => { (request) => `${request.channelId}:${request.hookId}`, ), ).toEqual([ + "slack:slack-socket-mode-gateway-conflict", + "slack:slack-runtime-preload", + "slack:slack-openclaw-bridge-health", + "slack:slack-openclaw-runtime-status", + "slack:slack-socket-mode-gateway-status", "slack:slack-openclaw-package-install", "slack:slack-token-paste", "slack:slack-config-prompt", diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 2463feef40..1705962a17 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -3,11 +3,13 @@ import { Buffer } from "node:buffer"; +import type { MessagingHookInputMap } from "../hooks"; import type { ChannelHookPhase, SandboxMessagingPlan } from "../manifest"; import { applyAgentConfigAtOpenShell as applyAgentConfigPlanAtOpenShell, listHookRequests as listPlanHookRequests, } from "./agent-config"; +import { applyMessagingHooksForPhase as applyPlanHooksForPhase } from "./hook-phases"; import { applyCredentialsAtOpenShell as applyCredentialsPlanAtOpenShell } from "./openshell-provider"; import { applyPolicyAtOpenShell as applyPolicyPlanAtOpenShell } from "./policy"; import { @@ -68,6 +70,18 @@ export class MessagingSetupApplier { return listPlanHookRequests(plan, phase); } + static applyHooksForPhase( + plan: SandboxMessagingPlan, + phase: ChannelHookPhase, + options: { + readonly runHook?: MessagingHookApplyRunner; + readonly additionalInputs?: MessagingHookInputMap; + } = {}, + ): ReturnType { + assertSandboxMessagingPlan(plan); + return applyPlanHooksForPhase(plan, phase, options); + } + static async applyAgentConfigAtOpenShell( plan: SandboxMessagingPlan, options: { diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 9647bca3bf..b6635ed733 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -185,6 +185,50 @@ export const discordManifest = { ], }, hooks: [ + { + id: "discord-openclaw-bridge-health", + phase: "health-check", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawBridgeStartup", + kind: "health-check", + required: true, + value: { + type: "openclaw-bridge-startup", + configFile: "/sandbox/.openclaw/openclaw.json", + channelConfigPath: "channels.discord", + enabledPath: "enabled", + logFile: "/tmp/gateway.log", + maxLogLines: 400, + logLinePattern: "^\\[discord\\] |^\\[channels\\] \\[discord\\]", + warningPattern: + "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", + positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", + }, + }, + ], + onFailure: "abort", + }, + { + id: "discord-openclaw-runtime-status", + phase: "status", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawRuntimeChannel", + kind: "status", + required: true, + value: { + type: "openclaw-runtime-channel", + configKeys: ["discord"], + logPatterns: ["discord"], + }, + }, + ], + }, { id: "discord-openclaw-package-install", phase: "agent-install", diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index c5770328f4..0c80e17377 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -8,9 +8,15 @@ import { describe, expect, it } from "vitest"; import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, + COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, } from "../hooks/common"; -import type { ChannelInputSpec, ChannelManifest, ChannelRenderSpec } from "../manifest"; +import type { + ChannelHookSpec, + ChannelInputSpec, + ChannelManifest, + ChannelRenderSpec, +} from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS, createBuiltInChannelManifestRegistry, @@ -20,7 +26,10 @@ import { wechatManifest, whatsappManifest, } from "./index"; -import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks"; +import { + SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, +} from "./slack/hooks"; import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; function findInput(manifest: ChannelManifest, inputId: string): ChannelInputSpec { @@ -35,6 +44,12 @@ function findRender(manifest: ChannelManifest, renderId: string): ChannelRenderS return render; } +function findHook(manifest: ChannelManifest, hookId: string): ChannelHookSpec { + const hook = manifest.hooks.find((entry) => entry.id === hookId); + if (!hook) throw new Error(`missing hook ${manifest.id}.${hookId}`); + return hook; +} + function renderJson(manifest: ChannelManifest): string { return JSON.stringify(manifest.render); } @@ -109,6 +124,74 @@ function expectSlackCredentialValidationHook(inputIds: readonly string[]): void }); } +function expectSlackSocketModeGatewayConflictHook(): void { + expect(slackManifest.hooks).toContainEqual({ + id: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + handler: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + onFailure: "abort", + }); +} + +function expectRuntimePreloadHook( + manifest: ChannelManifest, + hookId: string, + outputId: string, +): void { + expect(findHook(manifest, hookId)).toMatchObject({ + id: hookId, + phase: "runtime-preload", + handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + agents: ["openclaw"], + outputs: [ + expect.objectContaining({ + id: outputId, + kind: "runtime-preload", + required: true, + }), + ], + onFailure: "abort", + }); +} + +function expectOpenClawBridgeHealthHook(manifest: ChannelManifest, hookId: string): void { + expect(findHook(manifest, hookId)).toMatchObject({ + id: hookId, + phase: "health-check", + handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + agents: ["openclaw"], + outputs: [ + expect.objectContaining({ + id: "openclawBridgeStartup", + kind: "health-check", + required: true, + }), + ], + onFailure: "abort", + }); +} + +function expectStatusHook( + manifest: ChannelManifest, + hookId: string, + outputId: string, + type: string, +): void { + expect(findHook(manifest, hookId)).toMatchObject({ + id: hookId, + phase: "status", + handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + outputs: [ + expect.objectContaining({ + id: outputId, + kind: "status", + required: true, + value: expect.objectContaining({ type }), + }), + ], + }); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -147,6 +230,7 @@ describe("built-in channel manifests", () => { "src/lib/messaging/channels/wechat/hooks/index.ts", "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", "src/lib/messaging/channels/slack/manifest.ts", + "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts", "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", "src/lib/messaging/hooks/common/config-prompt.ts", @@ -253,6 +337,26 @@ describe("built-in channel manifests", () => { }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); expectReachabilityHook(telegramManifest, ["botToken"]); + expectRuntimePreloadHook(telegramManifest, "telegram-runtime-preload", "telegramDiagnostics"); + expect(JSON.stringify(findHook(telegramManifest, "telegram-runtime-preload"))).toContain( + "telegram-diagnostics.js", + ); + expectOpenClawBridgeHealthHook(telegramManifest, "telegram-openclaw-bridge-health"); + expect(JSON.stringify(findHook(telegramManifest, "telegram-openclaw-bridge-health"))).toContain( + "Telegram direct-message allowlist is empty", + ); + expectStatusHook( + telegramManifest, + "telegram-openclaw-runtime-status", + "openclawRuntimeChannel", + "openclaw-runtime-channel", + ); + expectStatusHook( + telegramManifest, + "telegram-gateway-conflict-status", + "gatewayConflictCounter", + "gateway-log-conflict-counter", + ); }); it("declares Discord guild and allowlist render intent for both agents", () => { @@ -291,6 +395,13 @@ describe("built-in channel manifests", () => { expect(renderJson(discordManifest)).toContain("require_mention"); expectTokenPasteEnrollHook(discordManifest, ["botToken"]); expectConfigPromptEnrollHook(discordManifest, ["serverId", "requireMention", "userId"]); + expectOpenClawBridgeHealthHook(discordManifest, "discord-openclaw-bridge-health"); + expectStatusHook( + discordManifest, + "discord-openclaw-runtime-status", + "openclawRuntimeChannel", + "openclaw-runtime-channel", + ); }); it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { @@ -338,9 +449,30 @@ describe("built-in channel manifests", () => { expect(renderJson(slackManifest)).toContain('"path":"channels.slack"'); expect(renderJson(slackManifest)).toContain('"accounts"'); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); + expectSlackSocketModeGatewayConflictHook(); + expectRuntimePreloadHook(slackManifest, "slack-runtime-preload", "slackRuntimePreload"); + expect(JSON.stringify(findHook(slackManifest, "slack-runtime-preload"))).toContain( + "slack-channel-guard.js", + ); + expect(JSON.stringify(findHook(slackManifest, "slack-runtime-preload"))).toContain( + "SLACK_BOT_TOKEN", + ); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); expectSlackCredentialValidationHook(["botToken", "appToken"]); + expectOpenClawBridgeHealthHook(slackManifest, "slack-openclaw-bridge-health"); + expectStatusHook( + slackManifest, + "slack-openclaw-runtime-status", + "openclawRuntimeChannel", + "openclaw-runtime-channel", + ); + expectStatusHook( + slackManifest, + "slack-socket-mode-gateway-status", + "singleGatewayOverlap", + "single-gateway-channel-overlap", + ); expect(slackManifest.state).toEqual({ persist: { allowedIds: ["allowedUsers"], @@ -418,8 +550,14 @@ describe("built-in channel manifests", () => { "wechat.ilinkLogin", "common.configPrompt", "wechat.seedOpenClawAccount", + "common.staticOutputs", "wechat.healthCheck", + "common.staticOutputs", ]); + expectRuntimePreloadHook(wechatManifest, "wechat-runtime-preload", "wechatDiagnostics"); + expect(JSON.stringify(findHook(wechatManifest, "wechat-runtime-preload"))).toContain( + "wechat-diagnostics.js", + ); expectConfigPromptEnrollHook(wechatManifest, ["allowedIds"]); const seedHook = wechatManifest.hooks.find( (hook) => hook.id === "wechat-seed-openclaw-account", @@ -443,6 +581,12 @@ describe("built-in channel manifests", () => { inputs: ["wechatConfig.accountId"], onFailure: "abort", }); + expectStatusHook( + wechatManifest, + "wechat-openclaw-runtime-status", + "openclawRuntimeChannel", + "openclaw-runtime-channel", + ); }); it("declares WhatsApp as in-sandbox QR with optional allowlist config", () => { @@ -481,5 +625,15 @@ describe("built-in channel manifests", () => { expect(renderJson(whatsappManifest)).toContain("platforms.whatsapp"); expect(renderJson(whatsappManifest)).not.toContain("WHATSAPP_BOT_TOKEN"); expect(renderJson(whatsappManifest)).not.toContain("openshell:resolve:env:WHATSAPP"); + expectRuntimePreloadHook(whatsappManifest, "whatsapp-runtime-preload", "whatsappQrCompact"); + expect(JSON.stringify(findHook(whatsappManifest, "whatsapp-runtime-preload"))).toContain( + "whatsapp-qr-compact.js", + ); + expectStatusHook( + whatsappManifest, + "whatsapp-openclaw-runtime-status", + "openclawRuntimeChannel", + "openclaw-runtime-channel", + ); }); }); diff --git a/src/lib/messaging/channels/slack/hooks/index.ts b/src/lib/messaging/channels/slack/hooks/index.ts index 3047d914a5..fcfa83cff2 100644 --- a/src/lib/messaging/channels/slack/hooks/index.ts +++ b/src/lib/messaging/channels/slack/hooks/index.ts @@ -2,15 +2,21 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createSlackSocketModeGatewayConflictHookRegistration, + type SlackSocketModeGatewayConflictHookOptions, +} from "./socket-mode-gateway-conflict"; import { createSlackValidateCredentialsHookRegistration, type SlackValidateCredentialsHookOptions, } from "./validate-credentials"; export * from "./credential-validation"; +export * from "./socket-mode-gateway-conflict"; export * from "./validate-credentials"; export interface SlackHookOptions { + readonly socketModeGatewayConflict?: SlackSocketModeGatewayConflictHookOptions; readonly validateCredentials?: SlackValidateCredentialsHookOptions; } @@ -18,16 +24,17 @@ export function createSlackHookRegistrations( options: SlackHookOptions = {}, ): readonly MessagingHookRegistration[] { return [ + createSlackSocketModeGatewayConflictHookRegistration( + withoutUndefinedValues(options.socketModeGatewayConflict), + ), createSlackValidateCredentialsHookRegistration( withoutUndefinedValues(options.validateCredentials), ), ] as const; } -function withoutUndefinedValues( - options: SlackValidateCredentialsHookOptions | undefined, -): SlackValidateCredentialsHookOptions { +function withoutUndefinedValues(options: T | undefined): T { return Object.fromEntries( Object.entries(options ?? {}).filter(([, value]) => value !== undefined), - ) as SlackValidateCredentialsHookOptions; + ) as T; } diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts new file mode 100644 index 0000000000..bd914f64d0 --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + makePlan, + planEntry, + slackBindings, + slackChannel, +} from "../../../../../../test/helpers/messaging-conflict-fixtures"; +import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import type { ChannelHookSpec, MessagingSerializableValue } from "../../../manifest"; +import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; +import { + createSlackSocketModeGatewayConflictHookRegistration, + SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, +} from "./socket-mode-gateway-conflict"; + +const HOOK = { + id: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + handler: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + onFailure: "abort", +} as const satisfies ChannelHookSpec; + +function slackEntry(name: string, gatewayName?: string | null): ConflictRegistryEntry { + const entry = planEntry( + name, + makePlan(name, { + channels: [slackChannel()], + credentialBindings: slackBindings("bot", "app", name), + }), + ); + return gatewayName === undefined ? entry : { ...entry, gatewayName }; +} + +describe("slack.socketModeGatewayConflict hook", () => { + it("passes when no active Slack sandbox shares the gateway", async () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayConflictHookRegistration({ + currentSandbox: "bob", + currentGatewayName: "nemoclaw", + registryEntries: [slackEntry("alice", "nemoclaw-9090")], + }), + ]); + + await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).resolves.toEqual({ + hookId: "slack-socket-mode-gateway-conflict", + handlerId: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + phase: "pre-enable", + outputs: {}, + }); + }); + + it("aborts with the canonical Socket Mode gateway conflict message", async () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayConflictHookRegistration({ + currentSandbox: "bob", + currentGatewayName: "nemoclaw", + registryEntries: [slackEntry("alice", "nemoclaw")], + }), + ]); + + await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).rejects.toThrow( + "Slack Socket Mode is already enabled for sandbox 'alice' on this gateway; " + + "only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.", + ); + }); + + it("accepts serialized applier inputs for registry-scoped checks", async () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayConflictHookRegistration(), + ]); + const registryEntries = JSON.parse( + JSON.stringify([slackEntry("alice", "nemoclaw")]), + ) as MessagingSerializableValue; + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "slack", + inputs: { + currentSandbox: "bob", + currentGatewayName: "nemoclaw", + registryEntries, + }, + }), + ).rejects.toThrow("Slack Socket Mode is already enabled for sandbox 'alice'"); + }); + + it("requires gateway and registry context when no options are injected", async () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayConflictHookRegistration(), + ]); + + await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).rejects.toThrow( + "Slack Socket Mode gateway conflict hook requires currentGatewayName and registryEntries.", + ); + }); +}); diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts new file mode 100644 index 0000000000..6cee2a387c --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + findSlackSocketModeGatewayConflicts, + formatSlackSocketModeConflictMessage, + type SlackGatewayConflict, +} from "../../../applier/conflict-detection/slack-socket-mode"; +import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; +import type { MessagingSerializableValue } from "../../../manifest"; +import type { + MessagingHookContext, + MessagingHookHandler, + MessagingHookRegistration, +} from "../../../hooks/types"; + +export const SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID = "slack.socketModeGatewayConflict"; + +export interface SlackSocketModeGatewayConflictHookOptions { + readonly currentSandbox?: string | null | (() => string | null); + readonly currentGatewayName?: string | (() => string); + readonly registryEntries?: + | readonly ConflictRegistryEntry[] + | (() => readonly ConflictRegistryEntry[]); + readonly findConflicts?: ( + currentSandbox: string | null, + currentGatewayName: string, + entries: readonly ConflictRegistryEntry[], + ) => readonly SlackGatewayConflict[]; + readonly formatConflict?: (otherSandbox: string) => string; +} + +export function createSlackSocketModeGatewayConflictHook( + options: SlackSocketModeGatewayConflictHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "slack") return {}; + + const currentSandbox = resolveCurrentSandbox(context, options); + const currentGatewayName = resolveCurrentGatewayName(context, options); + const entries = resolveRegistryEntries(context, options); + if (!currentGatewayName || !entries) { + throw new Error( + "Slack Socket Mode gateway conflict hook requires currentGatewayName and registryEntries.", + ); + } + + const findConflicts = options.findConflicts ?? findSlackSocketModeGatewayConflicts; + const conflicts = findConflicts(currentSandbox, currentGatewayName, entries); + if (conflicts.length === 0) return {}; + + const formatConflict = options.formatConflict ?? formatSlackSocketModeConflictMessage; + throw new Error(conflicts.map(({ sandbox }) => formatConflict(sandbox)).join("\n")); + }; +} + +export function createSlackSocketModeGatewayConflictHookRegistration( + options: SlackSocketModeGatewayConflictHookOptions = {}, +): MessagingHookRegistration { + return { + id: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + handler: createSlackSocketModeGatewayConflictHook(options), + }; +} + +function resolveCurrentSandbox( + context: MessagingHookContext, + options: SlackSocketModeGatewayConflictHookOptions, +): string | null { + return ( + normalizeNullableString(context.inputs?.currentSandbox) ?? + resolveNullableOption(options.currentSandbox) + ); +} + +function resolveCurrentGatewayName( + context: MessagingHookContext, + options: SlackSocketModeGatewayConflictHookOptions, +): string | null { + return ( + normalizeNullableString(context.inputs?.currentGatewayName) ?? + resolveStringOption(options.currentGatewayName) + ); +} + +function resolveRegistryEntries( + context: MessagingHookContext, + options: SlackSocketModeGatewayConflictHookOptions, +): readonly ConflictRegistryEntry[] | null { + const inputEntries = parseRegistryEntries(context.inputs?.registryEntries); + if (inputEntries) return inputEntries; + const entries = + typeof options.registryEntries === "function" + ? options.registryEntries() + : options.registryEntries; + return entries ? [...entries] : null; +} + +function parseRegistryEntries( + value: MessagingSerializableValue | undefined, +): readonly ConflictRegistryEntry[] | null { + if (!Array.isArray(value)) return null; + const entries = value.flatMap((entry) => { + if (!isObject(entry) || typeof entry.name !== "string" || entry.name.length === 0) { + return []; + } + const gatewayName = normalizeNullableString(entry.gatewayName); + return [ + { + name: entry.name, + gatewayName, + messaging: isObject(entry.messaging) + ? (entry.messaging as ConflictRegistryEntry["messaging"]) + : null, + }, + ]; + }); + return entries.length > 0 ? entries : null; +} + +function normalizeNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function resolveNullableOption( + value: string | null | (() => string | null) | undefined, +): string | null { + return typeof value === "function" ? value() : (value ?? null); +} + +function resolveStringOption(value: string | (() => string) | undefined): string | null { + return typeof value === "function" ? value() : (value ?? null); +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index a14735056f..b9c46aa1b6 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -163,6 +163,125 @@ export const slackManifest = { ], }, hooks: [ + { + id: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + handler: "slack.socketModeGatewayConflict", + onFailure: "abort", + }, + { + id: "slack-runtime-preload", + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "slackRuntimePreload", + kind: "runtime-preload", + required: true, + value: { + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + message: + "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + { + envKey: "SLACK_APP_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", + value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + message: + "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + ], + preloads: [ + { + source: "/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js", + target: "/tmp/nemoclaw-slack-channel-guard.js", + nodeOptions: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Slack channel guard (unhandled-rejection safety net)", + installedMessage: "[channels] Slack channel guard installed (NODE_OPTIONS updated)", + }, + ], + secretScans: [ + { + path: "/sandbox/.openclaw/openclaw.json", + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }, + }, + ], + onFailure: "abort", + }, + { + id: "slack-openclaw-bridge-health", + phase: "health-check", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawBridgeStartup", + kind: "health-check", + required: true, + value: { + type: "openclaw-bridge-startup", + configFile: "/sandbox/.openclaw/openclaw.json", + channelConfigPath: "channels.slack", + enabledPath: "enabled", + logFile: "/tmp/gateway.log", + maxLogLines: 400, + logLinePattern: "^\\[slack\\] |^\\[channels\\] \\[slack\\]", + warningPattern: + "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", + positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", + }, + }, + ], + onFailure: "abort", + }, + { + id: "slack-openclaw-runtime-status", + phase: "status", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawRuntimeChannel", + kind: "status", + required: true, + value: { + type: "openclaw-runtime-channel", + configKeys: ["slack"], + logPatterns: ["slack"], + }, + }, + ], + }, + { + id: "slack-socket-mode-gateway-status", + phase: "status", + handler: "common.staticOutputs", + outputs: [ + { + id: "singleGatewayOverlap", + kind: "status", + required: true, + value: { + type: "single-gateway-channel-overlap", + reason: "socket-mode-gateway", + message: + "'{first}' and '{second}' both have Slack Socket Mode enabled on the same gateway; only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.", + }, + }, + ], + }, { id: "slack-openclaw-package-install", phase: "agent-install", diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 4447d70705..17727637d2 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -213,5 +213,108 @@ export const telegramManifest = { inputs: ["botToken"], onFailure: "skip-channel", }, + { + id: "telegram-runtime-preload", + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "telegramDiagnostics", + kind: "runtime-preload", + required: true, + value: { + preloads: [ + { + source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", + target: "/tmp/nemoclaw-telegram-diagnostics.js", + nodeOptions: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", + installedMessage: + "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }, + }, + ], + onFailure: "abort", + }, + { + id: "telegram-openclaw-bridge-health", + phase: "health-check", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawBridgeStartup", + kind: "health-check", + required: true, + value: { + type: "openclaw-bridge-startup", + configFile: "/sandbox/.openclaw/openclaw.json", + channelConfigPath: "channels.telegram", + enabledPath: "enabled", + logFile: "/tmp/gateway.log", + maxLogLines: 400, + logLinePattern: "^\\[telegram\\] |^\\[channels\\] \\[telegram\\]", + warningPattern: + "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", + positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", + allowlistWarning: { + accountContainerPath: "accounts", + preferredAccountKey: "default", + policyPath: "dmPolicy", + requiredPolicy: "allowlist", + allowListPath: "allowFrom", + messages: [ + "Telegram direct-message allowlist is empty in baked openclaw.json.", + "Set TELEGRAM_ALLOWED_IDS before rebuild, or complete OpenClaw pairing before expecting DM replies.", + "Telegram Bot API sendMessage tests outbound delivery only; send from a Telegram client to test inbound agent replies.", + ], + }, + }, + }, + ], + onFailure: "abort", + }, + { + id: "telegram-openclaw-runtime-status", + phase: "status", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawRuntimeChannel", + kind: "status", + required: true, + value: { + type: "openclaw-runtime-channel", + configKeys: ["telegram"], + logPatterns: ["telegram"], + }, + }, + ], + }, + { + id: "telegram-gateway-conflict-status", + phase: "status", + handler: "common.staticOutputs", + outputs: [ + { + id: "gatewayConflictCounter", + kind: "status", + required: true, + value: { + type: "gateway-log-conflict-counter", + logFile: "/tmp/gateway.log", + maxLogLines: 200, + pattern: "getUpdates conflict|409\\s*:?\\s*Conflict", + flags: "i", + }, + }, + ], + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 06fbd4a231..c4ccd28af9 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -224,6 +224,33 @@ export const wechatManifest = { ], onFailure: "abort", }, + { + id: "wechat-runtime-preload", + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "wechatDiagnostics", + kind: "runtime-preload", + required: true, + value: { + preloads: [ + { + source: "/usr/local/lib/nemoclaw/preloads/wechat-diagnostics.js", + target: "/tmp/nemoclaw-wechat-diagnostics.js", + nodeOptions: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing WeChat diagnostics (provider readiness + inference errors)", + installedMessage: "[channels] WeChat diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }, + }, + ], + onFailure: "abort", + }, { id: "wechat-health-check", phase: "health-check", @@ -231,5 +258,23 @@ export const wechatManifest = { inputs: ["wechatConfig.accountId"], onFailure: "abort", }, + { + id: "wechat-openclaw-runtime-status", + phase: "status", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawRuntimeChannel", + kind: "status", + required: true, + value: { + type: "openclaw-runtime-channel", + configKeys: ["openclaw-weixin"], + logPatterns: ["wechat", "openclaw-weixin"], + }, + }, + ], + }, ], } as const satisfies ChannelManifest; diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index d82dcf6373..3bd0b61b03 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -97,6 +97,50 @@ export const whatsappManifest = { ], }, hooks: [ + { + id: "whatsapp-runtime-preload", + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "whatsappQrCompact", + kind: "runtime-preload", + required: true, + value: { + preloads: [ + { + source: "/usr/local/lib/nemoclaw/preloads/whatsapp-qr-compact.js", + target: "/tmp/nemoclaw-whatsapp-qr-compact.js", + nodeOptions: ["connect"], + optional: true, + installMessage: + "[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)", + }, + ], + }, + }, + ], + onFailure: "abort", + }, + { + id: "whatsapp-openclaw-runtime-status", + phase: "status", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "openclawRuntimeChannel", + kind: "status", + required: true, + value: { + type: "openclaw-runtime-channel", + configKeys: ["whatsapp"], + logPatterns: ["whatsapp"], + }, + }, + ], + }, { id: "whatsapp-openclaw-package-install", phase: "agent-install", diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index e6ff851170..34935d5fed 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -291,9 +291,18 @@ describe("ManifestCompiler", () => { expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( true, ); + expect(plan.healthChecks.find((check) => check.channelId === "telegram")?.hookIds).toEqual([ + "telegram-openclaw-bridge-health", + ]); + expect(plan.healthChecks.find((check) => check.channelId === "discord")?.hookIds).toEqual([ + "discord-openclaw-bridge-health", + ]); expect(plan.healthChecks.find((check) => check.channelId === "wechat")?.hookIds).toEqual([ "wechat-health-check", ]); + expect(plan.healthChecks.find((check) => check.channelId === "slack")?.hookIds).toEqual([ + "slack-openclaw-bridge-health", + ]); expect( plan.agentRender.find( (render) => render.channelId === "telegram" && render.kind === "json-fragment", @@ -540,6 +549,10 @@ describe("ManifestCompiler", () => { "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", + "telegram-runtime-preload", + "telegram-openclaw-bridge-health", + "telegram-openclaw-runtime-status", + "telegram-gateway-conflict-status", ]); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); @@ -772,6 +785,10 @@ describe("ManifestCompiler", () => { "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", + "telegram-runtime-preload", + "telegram-openclaw-bridge-health", + "telegram-openclaw-runtime-status", + "telegram-gateway-conflict-status", ]); }); diff --git a/src/lib/messaging/diagnostics.test.ts b/src/lib/messaging/diagnostics.test.ts new file mode 100644 index 0000000000..79497f108f --- /dev/null +++ b/src/lib/messaging/diagnostics.test.ts @@ -0,0 +1,35 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { collectBuiltInMessagingChannelDiagnostics } from "./diagnostics"; + +describe("messaging channel diagnostics", () => { + it("derives common channel diagnostic metadata directly from manifests", () => { + const specs = collectBuiltInMessagingChannelDiagnostics(); + + expect(specs.map((spec) => spec.channelId)).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + ]); + expect(specs.find((spec) => spec.channelId === "telegram")).toMatchObject({ + policyPresets: ["telegram"], + preferredDefault: false, + }); + expect(specs.find((spec) => spec.channelId === "wechat")).toMatchObject({ + policyPresets: ["wechat"], + }); + expect(specs.find((spec) => spec.channelId === "whatsapp")).toMatchObject({ + policyPresets: ["whatsapp"], + preferredDefault: true, + deepProbe: "in-sandbox-qr", + doctorWhenNoHealthSignals: expect.objectContaining({ + hint: "run `{cli} {sandbox} channels status --channel {channel}` to probe inbound delivery", + }), + }); + }); +}); diff --git a/src/lib/messaging/diagnostics.ts b/src/lib/messaging/diagnostics.ts new file mode 100644 index 0000000000..0b91a03e5d --- /dev/null +++ b/src/lib/messaging/diagnostics.ts @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "./channels"; +import type { ChannelManifest, ChannelPolicyPresetReference, MessagingAgentId } from "./manifest"; + +export interface MessagingChannelDiagnosticSpec { + readonly channelId: string; + readonly policyPresets: readonly string[]; + readonly preferredDefault: boolean; + readonly deepProbe?: "in-sandbox-qr"; + readonly doctorWhenNoHealthSignals?: { + readonly detail: string; + readonly hint: string; + }; +} + +export function collectBuiltInMessagingChannelDiagnostics( + options: { readonly agent?: MessagingAgentId } = {}, +): MessagingChannelDiagnosticSpec[] { + return collectMessagingChannelDiagnostics( + createBuiltInChannelManifestRegistry().listAvailable( + options.agent ? { agent: options.agent } : undefined, + ), + ); +} + +export function collectMessagingChannelDiagnostics( + manifests: readonly ChannelManifest[], +): MessagingChannelDiagnosticSpec[] { + return manifests.map((manifest) => { + const deepProbe = manifest.auth.mode === "in-sandbox-qr" ? "in-sandbox-qr" : undefined; + return { + channelId: manifest.id, + policyPresets: policyPresetNames(manifest.policyPresets), + preferredDefault: deepProbe !== undefined, + ...(deepProbe ? { deepProbe, doctorWhenNoHealthSignals: qrDeepProbeDoctorHint() } : {}), + }; + }); +} + +function qrDeepProbeDoctorHint(): MessagingChannelDiagnosticSpec["doctorWhenNoHealthSignals"] { + return { + detail: + "{channels} enabled; {channel} inbound delivery is not inferred from conflict signatures{pausedSuffix}", + hint: "run `{cli} {sandbox} channels status --channel {channel}` to probe inbound delivery", + }; +} + +function policyPresetNames(presets: readonly ChannelPolicyPresetReference[] | undefined): string[] { + return (presets ?? []).map((preset) => (typeof preset === "string" ? preset : preset.name)); +} diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 2067f8559c..184a7fceec 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -36,6 +36,7 @@ describe("MessagingHookRegistry", () => { "common.staticOutputs", "common.tokenPaste", "common.configPrompt", + "slack.socketModeGatewayConflict", "slack.validateCredentials", "telegram.allowlistAliases", "telegram.getMeReachability", diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index c0e99120ba..84b5ed45d5 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -1,9 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +export * from "./applier"; export * from "./channels"; export * from "./compiler"; +export * from "./diagnostics"; export * from "./hooks"; -export * from "./applier"; export * from "./manifest"; +export * from "./status-outputs"; export * from "./utils"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 19582946e6..37f682ff0e 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -153,10 +153,12 @@ export interface ChannelRebuildHydrationSpec { export type ChannelHookPhase = | "enroll" | "reachability-check" + | "pre-enable" | "agent-install" | "render" | "apply" | "post-agent-install" + | "runtime-preload" | "health-check" | "diagnostic" | "status"; @@ -184,7 +186,10 @@ export interface ChannelHookOutputSpec { | "build-arg" | "build-file" | "package-install" - | "agent-render"; + | "agent-render" + | "runtime-preload" + | "health-check" + | "status"; readonly required?: boolean; readonly value?: MessagingSerializableValue; } diff --git a/src/lib/messaging/status-outputs.ts b/src/lib/messaging/status-outputs.ts new file mode 100644 index 0000000000..d8a209e7c1 --- /dev/null +++ b/src/lib/messaging/status-outputs.ts @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createBuiltInChannelManifestRegistry } from "./channels"; +import type { ChannelManifest, MessagingAgentId, MessagingSerializableValue } from "./manifest"; + +export type MessagingStatusOutput = + | OpenClawRuntimeChannelStatusOutput + | GatewayLogConflictCounterStatusOutput + | SingleGatewayChannelOverlapStatusOutput; + +export interface MessagingStatusOutputBase { + readonly channelId: string; + readonly hookId: string; + readonly outputId: string; +} + +export interface OpenClawRuntimeChannelStatusOutput extends MessagingStatusOutputBase { + readonly type: "openclaw-runtime-channel"; + readonly configKeys: readonly string[]; + readonly logPatterns: readonly string[]; +} + +export interface GatewayLogConflictCounterStatusOutput extends MessagingStatusOutputBase { + readonly type: "gateway-log-conflict-counter"; + readonly logFile: string; + readonly maxLogLines: number; + readonly pattern: string; + readonly flags: string; +} + +export interface SingleGatewayChannelOverlapStatusOutput extends MessagingStatusOutputBase { + readonly type: "single-gateway-channel-overlap"; + readonly reason: string; + readonly message: string; +} + +export function collectBuiltInMessagingStatusOutputs( + options: { readonly agent?: MessagingAgentId } = {}, +): MessagingStatusOutput[] { + return collectMessagingStatusOutputs(createBuiltInChannelManifestRegistry().list(), options); +} + +export function collectMessagingStatusOutputs( + manifests: readonly ChannelManifest[], + options: { + readonly agent?: MessagingAgentId; + } = {}, +): MessagingStatusOutput[] { + const outputs: MessagingStatusOutput[] = []; + for (const manifest of manifests) { + for (const hook of manifest.hooks) { + if (hook.phase !== "status") continue; + if (options.agent && hook.agents && !hook.agents.includes(options.agent)) continue; + for (const output of hook.outputs ?? []) { + if (output.kind !== "status" || output.value === undefined) continue; + const parsed = parseMessagingStatusOutput(manifest.id, hook.id, output.id, output.value); + if (parsed) outputs.push(parsed); + } + } + } + return outputs; +} + +function parseMessagingStatusOutput( + channelId: string, + hookId: string, + outputId: string, + value: MessagingSerializableValue, +): MessagingStatusOutput | null { + if (!isObjectRecord(value) || typeof value.type !== "string") return null; + const base = { channelId, hookId, outputId }; + if (value.type === "openclaw-runtime-channel") { + const configKeys = stringArray(value.configKeys); + const logPatterns = stringArray(value.logPatterns); + if (configKeys.length === 0 || logPatterns.length === 0) return null; + return { + ...base, + type: "openclaw-runtime-channel", + configKeys, + logPatterns, + }; + } + if (value.type === "gateway-log-conflict-counter") { + const logFile = stringField(value, "logFile"); + const pattern = stringField(value, "pattern"); + if (!logFile || !pattern) return null; + return { + ...base, + type: "gateway-log-conflict-counter", + logFile, + maxLogLines: maxLogLines(value.maxLogLines), + pattern, + flags: stringField(value, "flags") ?? "i", + }; + } + if (value.type === "single-gateway-channel-overlap") { + const reason = stringField(value, "reason"); + const message = stringField(value, "message"); + if (!reason || !message) return null; + return { + ...base, + type: "single-gateway-channel-overlap", + reason, + message, + }; + } + return null; +} + +function stringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) + : []; +} + +function stringField(value: Record, key: string): string | undefined { + const field = value[key]; + return typeof field === "string" && field.length > 0 ? field : undefined; +} + +function maxLogLines(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 200; + return Math.min(Math.max(Math.trunc(value), 1), 2000); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/onboard/messaging-conflict-guard.test.ts b/src/lib/onboard/messaging-conflict-guard.test.ts index 60e3cb0b5f..04f7593076 100644 --- a/src/lib/onboard/messaging-conflict-guard.test.ts +++ b/src/lib/onboard/messaging-conflict-guard.test.ts @@ -9,17 +9,31 @@ import { slackBindings, slackChannel, } from "../../../test/helpers/messaging-conflict-fixtures"; +import { SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID } from "../messaging/channels/slack/hooks"; import type { SandboxMessagingPlan } from "../messaging/manifest"; import { enforceMessagingChannelConflicts } from "./messaging-conflict-guard"; class AbortError extends Error {} +const SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK = { + channelId: "slack", + id: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + handler: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + onFailure: "abort", +} as const satisfies SandboxMessagingPlan["channels"][number]["hooks"][number]; + // Distinct per-sandbox token hashes so the credential-sharing axis stays // silent and the gateway axis is exercised in isolation: the whole point of // #4953 is that *different* Slack apps still collide on a shared gateway. function slackPlan(sandboxName: string): SandboxMessagingPlan { return makePlan(sandboxName, { - channels: [slackChannel()], + channels: [ + { + ...slackChannel(), + hooks: [SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK], + }, + ], credentialBindings: slackBindings(`${sandboxName}-bot`, `${sandboxName}-app`, sandboxName), }); } @@ -63,7 +77,7 @@ describe("enforceMessagingChannelConflicts — Slack Socket Mode gateway axis (# expect.stringContaining("Slack Socket Mode is already enabled for sandbox 'alice'"), ); expect(error).toHaveBeenCalledWith( - expect.stringContaining("only one sandbox per gateway can receive Slack Socket Mode events"), + expect.stringContaining("resolve the messaging pre-enable conflict above"), ); }); diff --git a/src/lib/onboard/messaging-conflict-guard.ts b/src/lib/onboard/messaging-conflict-guard.ts index 65ae846caf..7199333f58 100644 --- a/src/lib/onboard/messaging-conflict-guard.ts +++ b/src/lib/onboard/messaging-conflict-guard.ts @@ -4,23 +4,10 @@ /** * Pre-commit messaging channel conflict guard for the onboard entrypoint. * - * Extracted from `onboard.ts` to keep that oversized entrypoint from growing - * (the codebase-growth guardrail blocks net additions to `src/lib/onboard.ts`; - * logic under `src/lib/onboard/` is allowed to grow). It bundles the two - * conflict axes that must be checked before a sandbox is committed: - * - * 1. **Credential-scoped** (`findChannelConflictsFromPlan`): another sandbox - * already uses one of this sandbox's channel credentials. Shared channel - * credentials (Telegram getUpdates, Discord gateway, Slack Socket Mode) - * only allow one consumer, so two sandboxes with the same token silently - * break both bridges (#1953). - * - * 2. **Gateway-scoped Slack Socket Mode** (`findSlackSocketModeGatewayConflicts`): - * even with distinct Slack apps/tokens, only one sandbox per OpenShell - * gateway reliably receives Socket Mode events. A second Slack sandbox on - * the same gateway is a silent black hole — NemoClaw reports its bridge - * healthy while events never arrive (#4953). This axis is independent of - * the credential check, which only catches a *shared* token. + * Extracted from `onboard.ts` to keep that oversized entrypoint from growing. + * It keeps the credential-scoped conflict guard shared, then runs + * channel-owned `pre-enable` hooks for checks that belong to a concrete + * messaging channel. * * Dependencies are injected so the orchestration is unit-testable without a * live gateway or the onboard module's global state. @@ -28,11 +15,11 @@ import type { ConflictRegistry, ConflictRegistryEntry } from "../messaging/applier"; import { + createMessagingPreEnableHookInputs, findChannelConflictsFromPlan, - findSlackSocketModeGatewayConflicts, - formatSlackSocketModeConflictMessage, - getActiveChannelIdsFromPlan, + MessagingSetupApplier, } from "../messaging/applier"; +import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../messaging/hooks"; import type { SandboxMessagingPlan } from "../messaging/manifest"; export interface MessagingConflictGuardDeps { @@ -45,8 +32,7 @@ export interface MessagingConflictGuardDeps { * Channels this sandbox has stopped (`channels stop`). The compiled env plan * still lists them as configured, but they will not be re-registered with the * gateway, so a stopped channel must not count as an active consumer for - * conflict detection — e.g. a stopped Slack bridge must not block another - * sandbox on the same gateway (CodeRabbit, #4953). + * conflict detection. */ readonly currentSandboxDisabledChannels?: readonly string[]; /** Registry facade: list/update sandboxes (state/registry satisfies this). */ @@ -78,12 +64,9 @@ export async function enforceMessagingChannelConflicts( const { sandboxName, registry } = deps; // Fold channels stopped on this sandbox into the plan's disabled set so every - // conflict axis sees the *effective* (post-`channels stop`) channel list. A - // stopped bridge is not re-registered, so it is neither a credential consumer - // (axis 1) nor a Socket Mode consumer (axis 2) and must not block another - // sandbox. Both `getActiveChannelIdsFromPlan` and `planToConflictChannelRequests` - // already honor `plan.disabledChannels`, so this single fold covers both axes - // (CodeRabbit, #4953). + // conflict axis sees the *effective* (post-`channels stop`) channel list. + // Both manifest hook filtering and `planToConflictChannelRequests` already + // honor `plan.disabledChannels`, so this single fold covers both axes. const currentPlan: SandboxMessagingPlan | null = deps.currentPlan ? { ...deps.currentPlan, @@ -125,29 +108,63 @@ export async function enforceMessagingChannelConflicts( } } - // Axis 2: gateway-scoped Slack Socket Mode conflict (#4953). Runs whenever the - // effective plan still enables Slack, regardless of credential availability, - // because the conflict is the shared gateway, not the shared token. - if (currentPlan && getActiveChannelIdsFromPlan(currentPlan).includes("slack")) { - const slackConflicts = findSlackSocketModeGatewayConflicts( - sandboxName, - deps.gatewayName, - registry.listSandboxes().sandboxes, - ); - if (slackConflicts.length > 0) { - for (const { sandbox } of slackConflicts) { - deps.log(` ⚠ ${formatSlackSocketModeConflictMessage(sandbox)}`); - } - if (deps.isNonInteractive()) { - deps.error( - ` Aborting: only one sandbox per gateway can receive Slack Socket Mode events. Run \`${deps.cliName()} channels stop slack\` / \`${deps.cliName()} channels remove slack\` on the other sandbox, or onboard this sandbox on a separate gateway (set NEMOCLAW_GATEWAY_PORT).`, - ); - abort(deps); - } - if (!(await deps.promptContinue())) { - deps.log(" Aborting sandbox creation."); - abort(deps); - } + // Axis 2: channel-owned pre-enable checks. These may run even when credential + // hashes are unavailable because a channel may have a non-credential conflict + // axis. + if (currentPlan) { + await enforceMessagingPreEnableHooks(deps, currentPlan); + } +} + +async function enforceMessagingPreEnableHooks( + deps: MessagingConflictGuardDeps, + currentPlan: SandboxMessagingPlan, +): Promise { + const requests = MessagingSetupApplier.listHookRequests(currentPlan, "pre-enable"); + if (requests.length === 0) return; + + const hookRegistry = createBuiltInMessagingHookRegistry(); + const additionalInputs = createMessagingPreEnableHookInputs({ + currentSandbox: deps.sandboxName, + currentGatewayName: deps.gatewayName, + registryEntries: deps.registry.listSandboxes().sandboxes, + }); + + try { + await MessagingSetupApplier.applyHooksForPhase(currentPlan, "pre-enable", { + additionalInputs, + runHook: (request) => + runMessagingHook( + { + id: request.hookId, + phase: request.phase, + handler: request.handler, + inputs: request.inputKeys, + outputs: request.outputs, + onFailure: request.onFailure, + }, + hookRegistry, + { + channelId: request.channelId, + isInteractive: !deps.isNonInteractive(), + inputs: request.inputs, + }, + ), + }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + for (const line of message.split("\n").filter((entry) => entry.trim().length > 0)) { + deps.log(` ⚠ ${line}`); + } + if (deps.isNonInteractive()) { + deps.error( + ` Aborting: resolve the messaging pre-enable conflict above or run \`${deps.cliName()} channels stop \` / \`${deps.cliName()} channels remove \` on the other sandbox.`, + ); + abort(deps); + } + if (!(await deps.promptContinue())) { + deps.log(" Aborting sandbox creation."); + abort(deps); } } } diff --git a/src/lib/onboard/sandbox-messaging-preflight.test.ts b/src/lib/onboard/sandbox-messaging-preflight.test.ts index a972977ab6..d25d8dc02b 100644 --- a/src/lib/onboard/sandbox-messaging-preflight.test.ts +++ b/src/lib/onboard/sandbox-messaging-preflight.test.ts @@ -38,7 +38,18 @@ function planChannel(channelId: string) { configured: true, disabled: false, inputs: [], - hooks: [], + hooks: + channelId === "slack" + ? [ + { + channelId: "slack", + id: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + handler: "slack.socketModeGatewayConflict", + onFailure: "abort", + }, + ] + : [], }; } @@ -229,7 +240,7 @@ describe("prepareSandboxMessagingPreflight", () => { expect.stringContaining("Slack Socket Mode is already enabled for sandbox 'other'"), ); expect(deps.error).toHaveBeenCalledWith( - expect.stringContaining("only one sandbox per gateway can receive Slack Socket Mode events"), + expect.stringContaining("resolve the messaging pre-enable conflict above"), ); }); diff --git a/src/lib/status-command-deps.test.ts b/src/lib/status-command-deps.test.ts index 77563c2e34..3c31575df1 100644 --- a/src/lib/status-command-deps.test.ts +++ b/src/lib/status-command-deps.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { createRequire } from "node:module"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; const require = createRequire(import.meta.url); const { buildStatusCommandDeps } = @@ -44,7 +44,7 @@ describe("buildStatusCommandDeps", () => { `#!/usr/bin/env bash printf '%s\n' "$*" >> ${JSON.stringify(callsFile)} if [ "$1" = "sandbox" ] && [ "$2" = "exec" ]; then - printf '7\n' + printf 'getUpdates conflict\\n409 Conflict\\n409: Conflict\\n' exit 0 fi exit 0 @@ -54,11 +54,12 @@ exit 0 const deps = buildStatusCommandDeps(tmp); expect(deps.checkMessagingBridgeHealth!("alpha", ["telegram"])).toEqual([ - { channel: "telegram", conflicts: 7 }, + { channel: "telegram", conflicts: 3 }, ]); expect(fs.readFileSync(callsFile, "utf-8")).toContain( - "sandbox exec -n alpha -- sh -c tail -n 200 /tmp/gateway.log", + "sandbox exec -n alpha -- sh -c tail -n 200 '/tmp/gateway.log'", ); + expect(fs.readFileSync(callsFile, "utf-8")).not.toContain("grep -cE"); }); it("skips gateway-log probes for non-Telegram channel sets", () => { diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 8f3adb3615..2d4e75c674 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -8,12 +8,26 @@ import { resolveOpenshell } from "./adapters/openshell/resolve"; import { OPENSHELL_PROBE_TIMEOUT_MS } from "./adapters/openshell/timeouts"; import { getNamedGatewayLifecycleState } from "./gateway-runtime-action"; import { getLiveGatewayInference } from "./inference/live"; -import type { GatewayHealth, MessagingBridgeHealth, ShowStatusCommandDeps } from "./inventory"; -import { detectAllSlackSocketModeGatewayOverlaps, findAllOverlaps } from "./messaging/applier"; +import type { + GatewayHealth, + MessagingBridgeHealth, + MessagingOverlap, + ShowStatusCommandDeps, +} from "./inventory"; +import { findAllOverlaps } from "./messaging/applier"; +import { getActiveChannelIdsFromPlan } from "./messaging/plan-validation"; +import { + collectBuiltInMessagingStatusOutputs, + type GatewayLogConflictCounterStatusOutput, + type SingleGatewayChannelOverlapStatusOutput, +} from "./messaging/status-outputs"; +import { BASE_GATEWAY_NAME } from "./onboard/gateway-binding"; import * as registry from "./state/registry"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; +const STATUS_OUTPUTS = collectBuiltInMessagingStatusOutputs(); + function captureOpenshell( rootDir: string, args: string[], @@ -35,26 +49,30 @@ function checkMessagingBridgeHealth( sandboxName: string, channels: string[], ): MessagingBridgeHealth[] { - // Only Telegram currently emits a recognizable conflict signature in the - // gateway log. Discord/Slack have similar single-consumer constraints but - // log differently; we can extend the regex when those patterns are known. - if (!Array.isArray(channels) || !channels.includes("telegram")) return []; + const channelSet = new Set(Array.isArray(channels) ? channels : []); + const specs = STATUS_OUTPUTS.filter(isGatewayLogConflictCounterStatusOutput).filter((spec) => + channelSet.has(spec.channelId), + ); + if (specs.length === 0) return []; const openshell = resolveOpenshell(); if (!openshell) return []; - const script = - 'tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true'; - try { - const result = spawnSync( + + const results: MessagingBridgeHealth[] = []; + for (const spec of specs) { + const logTail = readSandboxFileTail( + rootDir, openshell, - ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", script], - { cwd: rootDir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + sandboxName, + spec.logFile, + spec.maxLogLines, ); - const count = Number.parseInt((result.stdout || "").trim(), 10); - if (!Number.isFinite(count) || count === 0) return []; - return [{ channel: "telegram", conflicts: count }]; - } catch { - return []; + if (logTail === null) continue; + const conflicts = countRegexMatchesByLine(logTail, spec.pattern, spec.flags); + if (conflicts > 0) { + results.push({ channel: spec.channelId, conflicts }); + } } + return results; } function findMessagingOverlaps() { @@ -63,27 +81,103 @@ function findMessagingOverlaps() { try { // Report both conflict axes independently and without deduping. They are // distinct, both-true facts: a shared messaging credential conflicts on any - // gateway (the gateway-independent, more actionable signal), while two Slack - // sandboxes on one gateway conflict even with distinct tokens (#4953). A - // pair that hits both genuinely has two problems, so surfacing both avoids - // masking the credential warning behind the gateway one. - const credentialOverlaps = findAllOverlaps(registry); - const slackGatewayOverlaps = detectAllSlackSocketModeGatewayOverlaps( - registry.listSandboxes().sandboxes, - ); - return [ - ...credentialOverlaps, - ...slackGatewayOverlaps.map((o) => ({ - channel: "slack", - sandboxes: o.sandboxes, - reason: "slack-socket-mode-gateway" as const, - })), - ]; + // gateway, while manifest-declared gateway exclusivity can conflict even + // with distinct credentials. A pair that hits both genuinely has two + // problems, so surfacing both avoids masking the credential warning behind + // the gateway one. + const { sandboxes } = registry.listSandboxes(); + const credentialOverlaps = findAllOverlaps({ + listSandboxes: () => ({ sandboxes }), + }); + const singleGatewayOverlaps = STATUS_OUTPUTS.filter( + isSingleGatewayChannelOverlapStatusOutput, + ).flatMap((spec) => detectSingleGatewayChannelOverlaps(sandboxes, spec)); + return [...credentialOverlaps, ...singleGatewayOverlaps]; } catch { return []; } } +function isGatewayLogConflictCounterStatusOutput( + output: (typeof STATUS_OUTPUTS)[number], +): output is GatewayLogConflictCounterStatusOutput { + return output.type === "gateway-log-conflict-counter"; +} + +function isSingleGatewayChannelOverlapStatusOutput( + output: (typeof STATUS_OUTPUTS)[number], +): output is SingleGatewayChannelOverlapStatusOutput { + return output.type === "single-gateway-channel-overlap"; +} + +function readSandboxFileTail( + rootDir: string, + openshell: string, + sandboxName: string, + path: string, + maxLines: number, +): string | null { + const script = `tail -n ${maxLines} ${shellQuote(path)} 2>/dev/null || true`; + try { + const result = spawnSync( + openshell, + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", script], + { cwd: rootDir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + ); + return typeof result.stdout === "string" ? result.stdout : ""; + } catch { + return null; + } +} + +function countRegexMatchesByLine(logTail: string, pattern: string, flags: string): number { + let regex: RegExp; + try { + regex = new RegExp(pattern, flags.replaceAll("g", "")); + } catch { + return 0; + } + return logTail + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && regex.test(line)).length; +} + +function detectSingleGatewayChannelOverlaps( + entries: readonly registry.SandboxEntry[], + spec: SingleGatewayChannelOverlapStatusOutput, +): MessagingOverlap[] { + const byGateway = new Map(); + for (const entry of entries) { + if (!entry.messaging?.plan) continue; + if (!getActiveChannelIdsFromPlan(entry.messaging.plan).includes(spec.channelId)) continue; + const gatewayName = entry.gatewayName ?? BASE_GATEWAY_NAME; + const names = byGateway.get(gatewayName) ?? []; + names.push(entry.name); + byGateway.set(gatewayName, names); + } + + const overlaps: MessagingOverlap[] = []; + for (const names of byGateway.values()) { + if (names.length < 2) continue; + for (let i = 0; i < names.length; i += 1) { + for (let j = i + 1; j < names.length; j += 1) { + overlaps.push({ + channel: spec.channelId, + sandboxes: [names[i], names[j]], + reason: spec.reason, + message: spec.message, + }); + } + } + } + return overlaps; +} + +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} + function readGatewayLog(rootDir: string, sandboxName: string): string | null { const openshell = resolveOpenshell(); if (!openshell) return null; diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index cbb1abae9f..587220c6fa 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -21,6 +21,109 @@ function runtimeShellEnvBlock(src: string): string { return src.slice(start, end); } +function messagingRuntimePreloadSection( + src: string, + options: { + planPath?: string; + connectPreloadsPath?: string; + sourcePrefix?: string; + targetPrefix?: string; + secretScanPrefix?: string; + } = {}, +): string { + const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); + const end = src.indexOf("_read_gateway_token()", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + let section = src.slice(start, end); + if (options.planPath) { + section = section.replace( + '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', + `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(options.planPath)}`, + ); + } + if (options.connectPreloadsPath) { + section = section + .replace( + '_MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list"', + `_MESSAGING_CONNECT_PRELOADS_FILE=${JSON.stringify(options.connectPreloadsPath)}`, + ) + .replaceAll("/tmp/nemoclaw-messaging-connect-preloads.list", options.connectPreloadsPath); + } + if (options.sourcePrefix) { + section = section.replace( + 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', + `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(options.sourcePrefix)}`, + ); + } + if (options.targetPrefix) { + section = section.replace( + 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', + `PRELOAD_TARGET_PREFIX = ${JSON.stringify(options.targetPrefix)}`, + ); + } + if (options.secretScanPrefix) { + section = section.replace( + 'if not path.startswith("/sandbox/"):', + `if not path.startswith(${JSON.stringify(options.secretScanPrefix)}):`, + ); + } + return section; +} + +function encodeRuntimePreloadPlan( + channelId: string, + value: Record, + options: { active?: boolean } = {}, +): string { + const active = options.active ?? true; + return Buffer.from( + JSON.stringify({ + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active, + selected: true, + configured: true, + disabled: !active, + inputs: [], + hooks: [ + { + channelId, + id: `${channelId}-runtime-preload`, + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "runtimePreload", + kind: "runtime-preload", + required: true, + value, + }, + ], + onFailure: "abort", + }, + ], + }, + ], + disabledChannels: active ? [] : [channelId], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }), + ).toString("base64"); +} + function nonRootFallbackBlock(src: string): string { const start = src.indexOf("# ── Non-root fallback"); const end = src.indexOf("# ── Root path", start); @@ -347,14 +450,14 @@ describe("nemoclaw-start non-root fallback", () => { 'ensure_gateway_token_if_missing() { echo "SHOULD_NOT_ENSURE"; exit 76; }', "write_openclaw_config_baseline() { :; }", "export_gateway_token() { :; }", + "write_messaging_runtime_preload_plan() { :; }", "write_runtime_shell_env() { :; }", "ensure_runtime_shell_env_shim() { :; }", "lock_rc_files() { :; }", - "normalize_slack_runtime_env() { :; }", + "apply_messaging_runtime_env_aliases() { :; }", 'configure_messaging_channels() { echo "SHOULD_NOT_CONFIGURE"; exit 70; }', - 'install_telegram_diagnostics() { echo "SHOULD_NOT_INSTALL"; exit 71; }', - 'install_slack_channel_guard() { echo "SHOULD_NOT_INSTALL"; exit 73; }', - 'verify_no_slack_secrets_on_disk() { echo "SHOULD_NOT_VERIFY"; exit 74; }', + 'install_messaging_runtime_preloads() { echo "SHOULD_NOT_INSTALL"; exit 71; }', + 'verify_messaging_runtime_secret_scans() { echo "SHOULD_NOT_VERIFY"; exit 74; }', "seed_default_workspace_templates() { :; }", "_SANDBOX_HOME=/sandbox", "NEMOCLAW_CMD=(bash -c 'echo EXPLICIT_COMMAND; exit 23')", @@ -586,7 +689,7 @@ describe("nemoclaw-start gateway token export (#1114)", () => { '_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"', '_SECCOMP_GUARD_SCRIPT="/tmp/seccomp-guard.js"', '_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"', - '_SLACK_GUARD_SCRIPT="/nonexistent/slack-guard.js"', + "emit_messaging_connect_runtime_preload_exports() { :; }", "_TOOL_REDIRECTS=()", "set +u", "export_gateway_token", @@ -836,7 +939,7 @@ describe("nemoclaw-start configure guard behavior", () => { '_NEMOTRON_FIX_SCRIPT="/tmp/nemotron-fix.js"', '_SECCOMP_GUARD_SCRIPT="/tmp/seccomp-guard.js"', '_CIAO_GUARD_SCRIPT="/tmp/ciao-guard.js"', - '_SLACK_GUARD_SCRIPT="/nonexistent/slack-guard.js"', + "emit_messaging_connect_runtime_preload_exports() { :; }", 'export OPENCLAW_GATEWAY_URL="ws://127.0.0.1:18789"', 'export OPENCLAW_GATEWAY_PORT="18789"', 'export OPENCLAW_GATEWAY_TOKEN="test-gateway-token"', @@ -1347,28 +1450,6 @@ describe("Slack channel guard — unhandled-rejection safety net (#2340)", () => const src = fs.readFileSync(START_SCRIPT, "utf-8"); const extractGuardScript = () => startScriptHeredoc(src, "SLACK_GUARD_EOF"); - function slackGuardSection(guardPath: string, configPath: string): string { - const start = src.indexOf("# read-only at runtime), this injects a Node.js preload"); - const end = src.indexOf("_read_gateway_token()", start); - if (start === -1 || end === -1 || end <= start) { - throw new Error("Expected Slack channel guard section in scripts/nemoclaw-start.sh"); - } - return src - .slice(start, end) - .replace( - '_SLACK_GUARD_SCRIPT="/tmp/nemoclaw-slack-channel-guard.js"', - `_SLACK_GUARD_SCRIPT=${JSON.stringify(guardPath)}`, - ) - .replace( - '_SLACK_GUARD_SOURCE="/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js"', - `_SLACK_GUARD_SOURCE=${JSON.stringify(path.join(PRELOAD_SCRIPTS, "slack-channel-guard.js"))}`, - ) - .replace( - 'local config_file="/sandbox/.openclaw/openclaw.json"', - `local config_file=${JSON.stringify(configPath)}`, - ); - } - function runSlackGuardHarness(body: string): ReturnType { return spawnSync( process.execPath, @@ -1384,21 +1465,48 @@ ${body}`, it("installs the guard only when Slack is configured", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-guard-")); - const configPath = path.join(tmpDir, "openclaw.json"); + const sourcePrefix = path.join(tmpDir, "preloads") + path.sep; + const guardSource = path.join(sourcePrefix, "slack-channel-guard.js"); const guardPath = path.join(tmpDir, "slack-channel-guard.js"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); const scriptPath = path.join(tmpDir, "run.sh"); - const run = (config: string) => { - fs.writeFileSync(configPath, config); + fs.mkdirSync(sourcePrefix, { recursive: true }); + fs.copyFileSync(path.join(PRELOAD_SCRIPTS, "slack-channel-guard.js"), guardSource); + const runtimeValue = { + preloads: [ + { + source: guardSource, + target: guardPath, + nodeOptions: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Slack channel guard (unhandled-rejection safety net)", + installedMessage: "[channels] Slack channel guard installed (NODE_OPTIONS updated)", + }, + ], + }; + const run = (active: boolean) => { fs.rmSync(guardPath, { force: true }); + fs.rmSync(planPath, { force: true }); + fs.rmSync(connectPreloadsPath, { force: true }); fs.writeFileSync( scriptPath, [ "#!/usr/bin/env bash", "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "NODE_OPTIONS='--require /already-loaded.js'", - slackGuardSection(guardPath, configPath), - "install_slack_channel_guard", + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue, { active }))}`, + messagingRuntimePreloadSection(src, { + planPath, + connectPreloadsPath, + sourcePrefix, + targetPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_preload_plan", + "install_messaging_runtime_preloads", 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', ].join("\n"), { mode: 0o700 }, @@ -1407,17 +1515,18 @@ ${body}`, }; try { - const noSlack = run('{"channels":{}}\n'); + const noSlack = run(false); expect(noSlack.status).toBe(0); expect(fs.existsSync(guardPath)).toBe(false); expect(noSlack.stdout).not.toContain(guardPath); - const withSlack = run('{"channels":{"slack":{"accounts":{"default":{}}}}}\n'); + const withSlack = run(true); expect(withSlack.status).toBe(0); expect(fs.existsSync(guardPath)).toBe(true); expect((fs.statSync(guardPath).mode & 0o777).toString(8)).toBe("444"); expect(withSlack.stdout).toContain("--require /already-loaded.js"); expect(withSlack.stdout).toContain(`--require ${guardPath}`); + expect(fs.readFileSync(connectPreloadsPath, "utf-8")).toContain(guardPath); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -2927,29 +3036,39 @@ describe("seed_default_workspace_templates (#3240)", () => { describe("Slack secrets-on-disk tripwire (#2085)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - function extractFunction(name: string): string { - const match = src.match(new RegExp(`${name}\\(\\) \\{([\\s\\S]*?)^\\}`, "m")); - if (!match) { - throw new Error(`Expected ${name} in scripts/nemoclaw-start.sh`); - } - return `${name}() {${match[1]}\n}`; - } - it("refuses to serve when real Slack tokens leak to disk", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-secret-")); const configPath = path.join(tmpDir, "openclaw.json"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const runtimeValue = { + secretScans: [ + { + path: configPath, + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }; const scriptPath = path.join(tmpDir, "run.sh"); - const fn = extractFunction("verify_no_slack_secrets_on_disk").replace( - 'local config="/sandbox/.openclaw/openclaw.json"', - `local config=${JSON.stringify(configPath)}`, - ); const run = (config: string) => { fs.writeFileSync(configPath, config); + fs.rmSync(planPath, { force: true }); fs.writeFileSync( scriptPath, - ["#!/usr/bin/env bash", "set -euo pipefail", fn, "verify_no_slack_secrets_on_disk"].join( - "\n", - ), + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, + messagingRuntimePreloadSection(src, { + planPath, + secretScanPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_preload_plan", + "verify_messaging_runtime_secret_scans", + ].join("\n"), { mode: 0o700 }, ); return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); @@ -3572,24 +3691,47 @@ describe("provider placeholder refresh (#4251)", () => { describe("Slack runtime env normalization (#4274)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); - // Exercises normalize_slack_runtime_env() through the real shell function so - // we prove the *exported* process-env values the OpenClaw child inherits are - // Bolt-compatible, not the canonical "openshell:resolve:env:*" placeholder. + // Exercises apply_messaging_runtime_env_aliases() through the real shell + // function so we prove the exported process-env values the OpenClaw child + // inherits are Bolt-compatible, not the canonical "openshell:resolve:env:*" + // placeholder. function runNormalize(env: Record = {}): { bot: string; app: string; result: ReturnType; } { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-runtime-env-")); + const planPath = path.join(tmpDir, "runtime-plan.json"); const scriptPath = path.join(tmpDir, "run.sh"); - const fn = extractShellFunctionFromSource(src, "normalize_slack_runtime_env"); + const runtimeValue = { + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + message: + "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + { + envKey: "SLACK_APP_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", + value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + message: + "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + ], + }; fs.writeFileSync( scriptPath, [ "#!/usr/bin/env bash", "set -euo pipefail", - fn, - "normalize_slack_runtime_env", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, + messagingRuntimePreloadSection(src, { planPath }), + "write_messaging_runtime_preload_plan", + "apply_messaging_runtime_env_aliases", 'printf "BOT=%s\\n" "${SLACK_BOT_TOKEN-__UNSET__}"', 'printf "APP=%s\\n" "${SLACK_APP_TOKEN-__UNSET__}"', ].join("\n"), @@ -3708,28 +3850,6 @@ describe("Telegram diagnostics (#2766)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF"); - function telegramDiagnosticsSection(preloadPath: string, configPath: string): string { - const start = src.indexOf("# ── Telegram diagnostics"); - const end = src.indexOf("_read_gateway_token()", start); - if (start === -1 || end === -1 || end <= start) { - throw new Error("Expected Telegram diagnostics section in scripts/nemoclaw-start.sh"); - } - return src - .slice(start, end) - .replace( - '_TELEGRAM_DIAGNOSTICS_SCRIPT="/tmp/nemoclaw-telegram-diagnostics.js"', - `_TELEGRAM_DIAGNOSTICS_SCRIPT=${JSON.stringify(preloadPath)}`, - ) - .replace( - '_TELEGRAM_DIAGNOSTICS_SOURCE="/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js"', - `_TELEGRAM_DIAGNOSTICS_SOURCE=${JSON.stringify(path.join(PRELOAD_SCRIPTS, "telegram-diagnostics.js"))}`, - ) - .replace( - 'local config_file="/sandbox/.openclaw/openclaw.json"', - `local config_file=${JSON.stringify(configPath)}`, - ); - } - function preGatewaySetupBlock( kind: "non-root" | "root", gatewayLog: string, @@ -3787,13 +3907,14 @@ describe("Telegram diagnostics (#2766)", () => { "ensure_gateway_token_if_missing() { :; }", "write_openclaw_config_baseline() { :; }", "export_gateway_token() { :; }", + "write_messaging_runtime_preload_plan() { :; }", "write_runtime_shell_env() { :; }", "ensure_runtime_shell_env_shim() { :; }", "lock_rc_files() { :; }", - "normalize_slack_runtime_env() { :; }", + "apply_messaging_runtime_env_aliases() { :; }", 'configure_messaging_channels() { echo "ORDER:configure"; }', - "install_slack_channel_guard() { :; }", - "verify_no_slack_secrets_on_disk() { :; }", + `install_messaging_runtime_preloads() { : > ${JSON.stringify(preloadPath)}; chmod 444 ${JSON.stringify(preloadPath)}; }`, + "verify_messaging_runtime_secret_scans() { :; }", "seed_default_workspace_templates() { :; }", "seed_default_workspace_templates_as_sandbox() { seed_default_workspace_templates; }", "write_auth_profile() { :; }", @@ -3820,9 +3941,8 @@ describe("Telegram diagnostics (#2766)", () => { `_NEMOTRON_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "nemotron-fix.js"))}`, `_SECCOMP_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "seccomp-guard.js"))}`, `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, - `_SLACK_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-guard.js"))}`, + `validate_nemoclaw_tmp_permissions() { validate_tmp_permissions ${JSON.stringify(preloadPath)}; }`, "NEMOCLAW_CMD=()", - telegramDiagnosticsSection(preloadPath, configPath), preGatewaySetupBlock(kind, gatewayLog, autoPairLog), ].join("\n"), { mode: 0o700 }, @@ -3848,21 +3968,48 @@ describe("Telegram diagnostics (#2766)", () => { it("installs a Telegram diagnostics preload only when Telegram is configured", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); - const configPath = path.join(tmpDir, "openclaw.json"); + const sourcePrefix = path.join(tmpDir, "preloads") + path.sep; + const sourcePath = path.join(sourcePrefix, "telegram-diagnostics.js"); const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); const scriptPath = path.join(tmpDir, "run.sh"); - const run = (config: string) => { - fs.writeFileSync(configPath, config); + fs.mkdirSync(sourcePrefix, { recursive: true }); + fs.copyFileSync(path.join(PRELOAD_SCRIPTS, "telegram-diagnostics.js"), sourcePath); + const runtimeValue = { + preloads: [ + { + source: sourcePath, + target: preloadPath, + nodeOptions: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", + installedMessage: "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }; + const run = (active: boolean) => { fs.rmSync(preloadPath, { force: true }); + fs.rmSync(planPath, { force: true }); + fs.rmSync(connectPreloadsPath, { force: true }); fs.writeFileSync( scriptPath, [ "#!/usr/bin/env bash", "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "NODE_OPTIONS='--require /already-loaded.js'", - telegramDiagnosticsSection(preloadPath, configPath), - "install_telegram_diagnostics", + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("telegram", runtimeValue, { active }))}`, + messagingRuntimePreloadSection(src, { + planPath, + connectPreloadsPath, + sourcePrefix, + targetPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_preload_plan", + "install_messaging_runtime_preloads", 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', ].join("\n"), { mode: 0o700 }, @@ -3871,19 +4018,20 @@ describe("Telegram diagnostics (#2766)", () => { }; try { - const noTelegram = run('{"channels":{}}\n'); + const noTelegram = run(false); expect(noTelegram.status).toBe(0); expect(fs.existsSync(preloadPath)).toBe(false); expect(noTelegram.stdout).toContain("NODE_OPTIONS=--require /already-loaded.js"); expect(noTelegram.stdout).not.toContain(preloadPath); - const withTelegram = run('{"channels":{"telegram":{}}}\n'); + const withTelegram = run(true); expect(withTelegram.status).toBe(0); expect(fs.existsSync(preloadPath)).toBe(true); expect((fs.statSync(preloadPath).mode & 0o777).toString(8)).toBe("444"); expect(withTelegram.stdout).toContain("--require /already-loaded.js"); expect(withTelegram.stdout).toContain(`--require ${preloadPath}`); expect(withTelegram.stderr).toContain("Telegram diagnostics installed"); + expect(fs.readFileSync(connectPreloadsPath, "utf-8")).toContain(preloadPath); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -4071,12 +4219,14 @@ process.stderr.write('FailoverError: token=123456:LATER\\n'); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-rc-")); const proxyEnv = path.join(tmpDir, "proxy-env.sh"); const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); const scriptPath = path.join(tmpDir, "write-env.sh"); const runtimeBlock = `${runtimeShellEnvBlock(src)}\nwrite_runtime_shell_env`.replaceAll( "/tmp/nemoclaw-proxy-env.sh", proxyEnv, ); fs.writeFileSync(preloadPath, "// diagnostics\n"); + fs.writeFileSync(connectPreloadsPath, `${preloadPath}\n`); fs.writeFileSync( scriptPath, [ @@ -4092,8 +4242,8 @@ process.stderr.write('FailoverError: token=123456:LATER\\n'); `_NEMOTRON_FIX_SCRIPT=${JSON.stringify(path.join(tmpDir, "nemotron-fix.js"))}`, `_SECCOMP_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "seccomp-guard.js"))}`, `_CIAO_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "ciao-guard.js"))}`, - `_TELEGRAM_DIAGNOSTICS_SCRIPT=${JSON.stringify(preloadPath)}`, - `_SLACK_GUARD_SCRIPT=${JSON.stringify(path.join(tmpDir, "slack-guard.js"))}`, + `_MESSAGING_CONNECT_PRELOADS_FILE=${JSON.stringify(connectPreloadsPath)}`, + extractShellFunctionFromSource(src, "emit_messaging_connect_runtime_preload_exports"), "_TOOL_REDIRECTS=()", "set +u", runtimeBlock, @@ -5173,9 +5323,7 @@ describe("direct-root entrypoint composition under CAP_DAC_OVERRIDE drop", () => '_NEMOTRON_FIX_SCRIPT=""', '_SECCOMP_GUARD_SCRIPT=""', '_CIAO_GUARD_SCRIPT=""', - '_TELEGRAM_DIAGNOSTICS_SCRIPT=""', - '_SLACK_GUARD_SCRIPT=""', - '_WHATSAPP_QR_COMPACT_SCRIPT=""', + "emit_messaging_connect_runtime_preload_exports() { :; }", '_TOOL_REDIRECTS=("NEMOCLAW_TEST_REDIRECT=/tmp/nemoclaw-test")', 'NODE_USE_ENV_PROXY=""', readToken, From b99378ab25998654a7f4b9811d3909b32df2186e Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 12 Jun 2026 21:28:35 +0700 Subject: [PATCH 02/23] refactor(messaging): finish manifest channel migration --- ci/test-file-size-budget.json | 2 +- scripts/install.sh | 4 +- scripts/lib/sandbox-init.sh | 38 +- scripts/nemoclaw-start.sh | 252 ++++++---- src/lib/actions/sandbox/policy-channel.ts | 70 +-- src/lib/actions/sandbox/rebuild.ts | 36 +- src/lib/actions/sandbox/snapshot.ts | 15 +- ...hermes-secret-boundary-behavioural.test.ts | 14 +- src/lib/credentials/command-support.ts | 8 +- src/lib/credentials/store.ts | 7 +- src/lib/messaging-channel-config.test.ts | 4 + src/lib/messaging-channel-config.ts | 37 +- src/lib/messaging/MIGRATION.md | 65 ++- .../conflict-detection/manifest-metadata.ts | 18 +- src/lib/messaging/applier/hook-phases.test.ts | 17 +- .../messaging/applier/setup-applier.test.ts | 29 ++ src/lib/messaging/applier/setup-applier.ts | 53 ++- src/lib/messaging/channels/built-ins.ts | 28 ++ .../messaging/channels/discord/manifest.ts | 16 +- src/lib/messaging/channels/index.ts | 27 +- src/lib/messaging/channels/metadata.test.ts | 128 ++++++ src/lib/messaging/channels/metadata.ts | 367 +++++++++++++++ .../socket-mode-gateway-conflict.test.ts | 24 +- .../hooks/socket-mode-gateway-conflict.ts | 4 +- src/lib/messaging/channels/slack/manifest.ts | 2 +- .../messaging/channels/telegram/manifest.ts | 10 +- src/lib/messaging/manifest/types.ts | 3 + src/lib/messaging/plan-validation.test.ts | 175 +++++++ src/lib/messaging/plan-validation.ts | 46 +- src/lib/onboard.ts | 10 +- src/lib/onboard/docker-gpu-patch.ts | 7 +- src/lib/onboard/initial-policy.ts | 8 +- .../onboard/machine/core-flow-phases.test.ts | 2 - .../onboard/machine/handlers/sandbox.test.ts | 12 +- src/lib/onboard/machine/handlers/sandbox.ts | 24 - src/lib/onboard/messaging-config.test.ts | 88 ++++ src/lib/onboard/messaging-config.ts | 31 +- src/lib/onboard/messaging-conflict-guard.ts | 4 +- src/lib/onboard/messaging-credentials.ts | 7 +- src/lib/onboard/messaging-policy-presets.ts | 7 +- src/lib/onboard/messaging-prep.test.ts | 7 +- src/lib/onboard/messaging-prep.ts | 29 +- src/lib/onboard/messaging-reuse.ts | 12 +- src/lib/onboard/policy-presets.ts | 36 +- .../sandbox-build-patch-config.test.ts | 114 +---- src/lib/onboard/sandbox-build-patch-config.ts | 59 +-- src/lib/onboard/sandbox-create-plan.ts | 23 +- src/lib/onboard/sandbox-provider-cleanup.ts | 11 +- src/lib/onboard/wechat-config.ts | 72 --- src/lib/policy/index.ts | 89 ++-- src/lib/sandbox/channels.test.ts | 23 +- src/lib/sandbox/channels.ts | 211 +++++---- src/lib/security/redact.ts | 29 +- test/channels-add-preset.test.ts | 16 +- test/cli/connect-recovery.test.ts | 156 ++++--- test/cli/destroy-detach-order.test.ts | 2 +- test/cli/doctor-gateway-token.test.ts | 432 ++++++++++-------- test/cli/logs.test.ts | 22 +- test/cli/maintenance-command.test.ts | 38 +- test/cli/snapshot-shields.test.ts | 6 +- test/destroy-cleanup-sandbox-services.test.ts | 12 +- .../support-tests/e2e-fixture-context.test.ts | 5 +- .../e2e-shell-supervisor.test.ts | 1 - test/nemoclaw-start-slack-runtime.test.ts | 222 +++++++++ test/nemoclaw-start.test.ts | 191 +------- test/onboard-policy-suggestions.test.ts | 2 +- test/sandbox-init.test.ts | 41 +- test/sandbox-provider-cleanup.test.ts | 37 +- test/whatsapp-qr-compact.test.ts | 12 +- 69 files changed, 2320 insertions(+), 1289 deletions(-) create mode 100644 src/lib/messaging/channels/built-ins.ts create mode 100644 src/lib/messaging/channels/metadata.test.ts create mode 100644 src/lib/messaging/channels/metadata.ts create mode 100644 src/lib/onboard/messaging-config.test.ts delete mode 100644 src/lib/onboard/wechat-config.ts create mode 100644 test/nemoclaw-start-slack-runtime.test.ts diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index 922b0391ea..a0561d9306 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1871, "test/generate-openclaw-config.test.ts": 1990, "test/install-preflight.test.ts": 4207, - "test/nemoclaw-start.test.ts": 5231, + "test/nemoclaw-start.test.ts": 5230, "test/onboard-messaging.test.ts": 2063, "test/onboard-selection.test.ts": 6891, "test/onboard.test.ts": 4774, diff --git a/scripts/install.sh b/scripts/install.sh index bf35b94fd8..98d0e8e301 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -570,9 +570,7 @@ usage() { printf " BRAVE_API_KEY Enable Brave Search with this API key (kept behind OpenShell provider rewrite)\n" printf " NEMOCLAW_EXPERIMENTAL=1 Show experimental/local options\n" printf " CHAT_UI_URL Chat UI URL to open after setup\n" - printf " DISCORD_BOT_TOKEN Auto-enable Discord policy support\n" - printf " SLACK_BOT_TOKEN Auto-enable Slack policy support\n" - printf " TELEGRAM_BOT_TOKEN Auto-enable Telegram policy support\n" + printf " Messaging credential env vars Auto-enable matching messaging policy support\n" printf "\n" } diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index af1fb59567..c28241958d 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -777,11 +777,41 @@ harden_config_symlinks() { # of config files is not possible — Landlock enforces read-only at # the kernel level. configure_messaging_channels() { - [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 + local channels + channels="$(read_messaging_plan_channels || true)" + [ -n "$channels" ] || return 0 echo "[channels] Messaging channels active (baked at build time):" >&2 - [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && echo "[channels] telegram" >&2 - [ -n "${DISCORD_BOT_TOKEN:-}" ] && echo "[channels] discord" >&2 - [ -n "${SLACK_BOT_TOKEN:-}" ] && echo "[channels] slack" >&2 + while IFS= read -r channel; do + [ -n "$channel" ] || continue + echo "[channels] $channel" >&2 + done < in the child environment. Refresh # baked canonical placeholders in openclaw.json after the integrity check so # token egress keeps working across provider attach/refresh generations without # ever writing a raw credential to disk. @@ -1107,20 +1107,85 @@ refresh_openclaw_provider_placeholders() { local hash_file="/sandbox/.openclaw/.config-hash" [ -f "$config_file" ] || return 0 - local keys="TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN BRAVE_API_KEY" + local keys + keys="$( + python3 - "$config_file" <<'PYPLACEHOLDERKEYS' +import base64 +import json +import os +import re +import sys + +config_file = sys.argv[1] +prefix = "openshell:resolve:env:" +alias_marker = "-OPENSHELL-RESOLVE-ENV-" +env_key_re = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") +revision_re = re.compile(r"^v[0-9]+_") +keys = set() + + +def add_key(value): + key = revision_re.sub("", value) + if env_key_re.match(key): + keys.add(key) + + +def walk(value): + if isinstance(value, str): + if value.startswith(prefix): + add_key(value[len(prefix) :]) + alias_index = value.find(alias_marker) + if alias_index > 0: + add_key(value[alias_index + len(alias_marker) :]) + return + if isinstance(value, list): + for item in value: + walk(item) + return + if isinstance(value, dict): + for item in value.values(): + walk(item) + + +try: + with open(config_file, encoding="utf-8") as f: + walk(json.load(f)) +except Exception: + pass + +raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() +if raw_plan: + try: + plan = json.loads(base64.b64decode(raw_plan).decode("utf-8")) + for binding in plan.get("credentialBindings", []): + if isinstance(binding, dict) and isinstance(binding.get("providerEnvKey"), str): + add_key(binding["providerEnvKey"]) + except Exception: + pass + +base_keys = { + key + for key in keys + if not any(key != candidate and key.startswith(f"{candidate}_") for candidate in keys) +} +print(" ".join(sorted(base_keys))) +PYPLACEHOLDERKEYS + )" + local base_keys="$keys" # Append operator-registered extras from NEMOCLAW_EXTRA_PLACEHOLDER_KEYS so # the revision-strip walk also collapses suffixed placeholders such as - # openshell:resolve:env:v51_TELEGRAM_BOT_TOKEN_AGENT_A back to the canonical + # openshell:resolve:env:v51__AGENT_A back to the canonical # form. The host-side onboard parser at # src/lib/onboard/extra-placeholder-keys.ts already filters by an identical # regex, rejects canonical-channel collisions, and requires every entry to # extend a canonical channel envKey with a non-empty `_`; this loop - # mirrors all three checks because the env var travels through one extra hop - # and a sandbox operator could clobber it independently. Keeping both - # parsers symmetrical means a host-side restriction (refusing GITHUB_TOKEN, - # NEMOCLAW_EXTRA_PLACEHOLDER_KEYS itself, etc.) cannot be bypassed by - # mutating the runtime env after sandbox boot. + # mirrors those checks against provider envKeys discovered from the messaging + # plan and current OpenClaw config because the env var travels through one + # extra hop and a sandbox operator could clobber it independently. Keeping the + # sandbox parser restrictive means a host-side refusal for unrelated secrets + # (GITHUB_TOKEN, NEMOCLAW_EXTRA_PLACEHOLDER_KEYS itself, etc.) cannot be + # bypassed by mutating the runtime env after sandbox boot. local extra_token local _extra_raw="${NEMOCLAW_EXTRA_PLACEHOLDER_KEYS-}" # Normalize commas to whitespace so callers can pass either form, @@ -1129,29 +1194,43 @@ refresh_openclaw_provider_placeholders() { local _extras_accepted=0 local _canon_prefix local _accepted_this_token + local _canonical_collision + local _example_key + local _accepted_extra_keys="" for extra_token in $_extra_raw; do - case "$extra_token" in - '' | TELEGRAM_BOT_TOKEN | DISCORD_BOT_TOKEN | SLACK_BOT_TOKEN | SLACK_APP_TOKEN | BRAVE_API_KEY | WECHAT_BOT_TOKEN) - continue - ;; - esac + [ -n "$extra_token" ] || continue + _canonical_collision=0 + for _canon_prefix in $base_keys; do + if [ "$extra_token" = "$_canon_prefix" ]; then + _canonical_collision=1 + break + fi + done + [ "$_canonical_collision" -eq 1 ] && continue if ! printf '%s' "$extra_token" | grep -Eq '^[A-Z][A-Z0-9_]{0,127}$'; then printf "[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '%s' — must match /^[A-Z][A-Z0-9_]{0,127}\$/\n" \ "$extra_token" >&2 continue fi _accepted_this_token=0 - for _canon_prefix in TELEGRAM_BOT_TOKEN_ DISCORD_BOT_TOKEN_ SLACK_BOT_TOKEN_ SLACK_APP_TOKEN_ WECHAT_BOT_TOKEN_ BRAVE_API_KEY_; do + _example_key="" + for _canon_prefix in $base_keys; do + [ -n "$_example_key" ] || _example_key="$_canon_prefix" case "$extra_token" in - "${_canon_prefix}"?*) + "${_canon_prefix}_"?*) _accepted_this_token=1 break ;; esac done if [ "$_accepted_this_token" -ne 1 ]; then - printf "[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '%s' — must extend a canonical channel envKey such as TELEGRAM_BOT_TOKEN_\n" \ - "$extra_token" >&2 + if [ -n "$_example_key" ]; then + printf "[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '%s' — must extend a discovered provider envKey such as %s_\n" \ + "$extra_token" "$_example_key" >&2 + else + printf "[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '%s' — must extend a discovered provider envKey from the messaging plan or OpenClaw config\n" \ + "$extra_token" >&2 + fi continue fi if [ "$_extras_accepted" -ge 32 ]; then @@ -1159,6 +1238,7 @@ refresh_openclaw_provider_placeholders() { break fi keys="$keys $extra_token" + _accepted_extra_keys="${_accepted_extra_keys:+$_accepted_extra_keys }$extra_token" _extras_accepted=$((_extras_accepted + 1)) done if [ "$_extras_accepted" -gt 0 ]; then @@ -1167,9 +1247,8 @@ refresh_openclaw_provider_placeholders() { # revision-scoped placeholder has been staged yet (which is the steady # state for a fresh provider attach). Stripping the canonical baseline # prefix here keeps the log line about extras only. - local _accepted_extras="${keys#TELEGRAM_BOT_TOKEN DISCORD_BOT_TOKEN SLACK_BOT_TOKEN SLACK_APP_TOKEN BRAVE_API_KEY }" printf '[config] NEMOCLAW_EXTRA_PLACEHOLDER_KEYS accepted %d entry(ies): %s\n' \ - "$_extras_accepted" "$_accepted_extras" >&2 + "$_extras_accepted" "$_accepted_extra_keys" >&2 fi if [ -L "$config_file" ] || [ -L "$hash_file" ]; then @@ -1191,6 +1270,7 @@ import sys config_file = sys.argv[1] prefix = "openshell:resolve:env:" +alias_marker = "-OPENSHELL-RESOLVE-ENV-" keys = os.environ.get("NEMOCLAW_PROVIDER_PLACEHOLDER_KEYS", "").split() replacements = {} warnings = [] @@ -1200,11 +1280,6 @@ for key in keys: if value.startswith(prefix) and value != f"{prefix}{key}": replacements[f"{prefix}{key}"] = (key, value) -channel_credentials = { - "telegram": ("botToken", "TELEGRAM_BOT_TOKEN"), - "discord": ("token", "DISCORD_BOT_TOKEN"), - } - with open(config_file, encoding="utf-8") as f: config = json.load(f) @@ -1212,8 +1287,8 @@ refreshed = set() # Match each canonical placeholder only as an exact token. The OpenShell # placeholder grammar is "openshell:resolve:env:[A-Za-z_][A-Za-z0-9_]*", -# so the negative-lookahead ensures replacing TELEGRAM_BOT_TOKEN does not -# also mutate TELEGRAM_BOT_TOKEN_AGENT_A; sort longest-first so two keys +# so the negative-lookahead ensures replacing one provider env key does not +# also mutate a suffixed extra placeholder; sort longest-first so two keys # sharing a strict prefix still match the more specific one when both # replacements happen to apply to the same exact-token position (the # lookahead already guarantees disjoint matches in practice, but keeping @@ -1240,63 +1315,54 @@ def rewrite(value): updated = rewrite(config) -channels = updated.get("channels", {}) if isinstance(updated, dict) else {} -if isinstance(channels, dict): - for channel, (field, env_key) in channel_credentials.items(): - channel_cfg = channels.get(channel, {}) - if not isinstance(channel_cfg, dict): - continue - accounts = channel_cfg.get("accounts", {}) - if not isinstance(accounts, dict): - continue - env_value = os.environ.get(env_key, "") - for account_id, account in accounts.items(): - if not isinstance(account, dict): - continue - token = account.get(field) - if not isinstance(token, str) or not token.startswith(prefix): - continue - label = f"{channel}.{account_id}.{field}" - if not env_value: - warnings.append( - f"[channels] {label} is an OpenShell placeholder but {env_key} is missing from the runtime environment" - ) - elif not env_value.startswith(prefix): - warnings.append( - f"[channels] {label} left unchanged because {env_key} is not an OpenShell placeholder; refusing to write raw credentials to openclaw.json" - ) - elif token != env_value: - warnings.append( - f"[channels] {label} placeholder does not match the OpenShell runtime placeholder for {env_key}" - ) +def placeholder_suffix_matches_env_key(suffix, env_key): + if suffix == env_key: + return True + revision = re.match(r"^v[0-9]+_", suffix) + return bool(revision and suffix[len(revision.group(0)) :] == env_key) -# Slack stores Bolt-compatible aliases (xoxb-/xapp-OPENSHELL-RESOLVE-ENV-*) on -# disk rather than the canonical "openshell:resolve:env:*" placeholder, so the -# loop above (which keys on the canonical prefix) never inspects it. Diagnose -# the alias-vs-runtime-env consistency separately. The aliases themselves are -# never rewritten on disk — the L7 egress proxy resolves them at request time — -# so we only warn, never mutate. Ref: NVIDIA/NemoClaw#4274. -slack_aliases = { - "botToken": ("SLACK_BOT_TOKEN", "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", "xoxb-"), - "appToken": ("SLACK_APP_TOKEN", "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", "xapp-"), - } -if isinstance(channels, dict): - slack_cfg = channels.get("slack", {}) - slack_accounts = slack_cfg.get("accounts", {}) if isinstance(slack_cfg, dict) else {} - if isinstance(slack_accounts, dict): - for account_id, account in slack_accounts.items(): - if not isinstance(account, dict): - continue - for field, (env_key, alias, token_scheme) in slack_aliases.items(): - if account.get(field) != alias: + +def path_label(path): + if len(path) >= 5 and path[0] == "channels" and path[2] == "accounts": + return f"{path[1]}.{path[3]}.{path[4]}" + return ".".join(path) + + +def walk_for_warnings(value, path): + if isinstance(value, str): + if value.startswith(prefix): + suffix = value[len(prefix) :] + for env_key in keys: + if not placeholder_suffix_matches_env_key(suffix, env_key): continue - label = f"slack.{account_id}.{field}" env_value = os.environ.get(env_key, "") - # A valid runtime placeholder is the canonical self-referential - # form or its revision-scoped variant for *this* key; a - # placeholder for a different key (or a suffix collision) is not - # accepted and must be surfaced. A genuine xoxb-/xapp- token is - # accepted by Bolt as-is. + label = path_label(path) + if not env_value: + warnings.append( + f"[channels] {label} is an OpenShell placeholder but {env_key} is missing from the runtime environment" + ) + elif not env_value.startswith(prefix): + warnings.append( + f"[channels] {label} left unchanged because {env_key} is not an OpenShell placeholder; refusing to write raw credentials to openclaw.json" + ) + elif not placeholder_suffix_matches_env_key(env_value[len(prefix) :], env_key): + warnings.append( + f"[channels] {label} placeholder does not match the OpenShell runtime placeholder for {env_key}" + ) + elif value != env_value: + warnings.append( + f"[channels] {label} placeholder does not match the OpenShell runtime placeholder for {env_key}" + ) + break + alias_index = value.find(alias_marker) + if alias_index > 0: + alias_env_key = value[alias_index + len(alias_marker) :] + token_scheme = value[:alias_index] + "-" + for env_key in keys: + if env_key != alias_env_key: + continue + label = path_label(path) + env_value = os.environ.get(env_key, "") placeholder_re = re.compile( rf"^{re.escape(prefix)}(v[0-9]+_)?{re.escape(env_key)}$" ) @@ -1306,8 +1372,20 @@ if isinstance(channels, dict): ) elif not placeholder_re.match(env_value) and not env_value.startswith(token_scheme): warnings.append( - f"[channels] {label} runtime {env_key} is neither the {env_key} OpenShell placeholder nor a {token_scheme} Slack token; Slack Bolt may reject it" + f"[channels] {label} runtime {env_key} is neither the {env_key} OpenShell placeholder nor a {token_scheme} token; runtime may reject it" ) + break + return + if isinstance(value, list): + for index, item in enumerate(value): + walk_for_warnings(item, path + [str(index)]) + return + if isinstance(value, dict): + for key, item in value.items(): + walk_for_warnings(item, path + [str(key)]) + + +walk_for_warnings(updated, []) if updated != config: with open(config_file, "w", encoding="utf-8") as f: @@ -2664,8 +2742,8 @@ PYAPPROVEAFTER echo "Changes inside the sandbox do not persist across rebuilds." >&2 echo "" >&2 echo "To add or remove messaging channels, exit the sandbox and run:" >&2 - echo " nemoclaw channels add " >&2 - echo " nemoclaw channels remove " >&2 + echo " nemoclaw channels add " >&2 + echo " nemoclaw channels remove " >&2 echo "" >&2 echo "WhatsApp pairs entirely inside the sandbox; complete pairing via:" >&2 echo " openclaw channels login --channel whatsapp" >&2 @@ -2732,8 +2810,8 @@ PYAPPROVEAFTER echo "Changes inside the sandbox do not persist across rebuilds." >&2 echo "" >&2 echo "To add or remove messaging channels, exit the sandbox and run:" >&2 - echo " nemoclaw channels add " >&2 - echo " nemoclaw channels remove " >&2 + echo " nemoclaw channels add " >&2 + echo " nemoclaw channels remove " >&2 echo "" >&2 echo "These stage the change and rebuild the sandbox to apply it." >&2 echo "WhatsApp pairs entirely inside the sandbox; complete pairing via:" >&2 @@ -2800,7 +2878,9 @@ GUARDENVEOF # ciao network guard for connect sessions. echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_CIAO_GUARD_SCRIPT\"" # Manifest-declared messaging preloads for connect sessions. - emit_messaging_connect_runtime_preload_exports + if type emit_messaging_connect_runtime_preload_exports >/dev/null 2>&1; then + emit_messaging_connect_runtime_preload_exports + fi # Tool cache redirects — generated from _TOOL_REDIRECTS (single source of truth) echo '# Tool cache redirects — keep transient tool state under /tmp' for _redir in "${_TOOL_REDIRECTS[@]}"; do diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index d0b05832f5..86ba2ba75e 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -15,16 +15,17 @@ import { createBuiltInRenderTemplateResolver, createMessagingPreEnableHookInputs, getMessagingManifestAvailabilityContext, + type MessagingHookOutputValue, MessagingHostStateApplier, + type MessagingSerializableValue, MessagingSetupApplier, MessagingWorkflowPlanner, runMessagingHook, - type MessagingHookOutputValue, - type MessagingSerializableValue, type SandboxMessagingChannelPlan, type SandboxMessagingPlan, toMessagingAgentId, } from "../../messaging"; +import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; import { hashCredential } from "../../security/credential-hash"; const { isNonInteractive } = require("../../onboard") as { isNonInteractive: () => boolean }; @@ -32,6 +33,7 @@ const onboardProviders = require("../../onboard/providers"); import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets"; import { BASE_GATEWAY_NAME } from "../../onboard/gateway-binding"; +import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import * as policies from "../../policy"; const onboardSession = @@ -473,7 +475,7 @@ async function checkMessagingPreEnableHooks( plan: SandboxMessagingPlan, force: boolean, ): Promise { - const requests = MessagingSetupApplier.listHookRequests(plan, "pre-enable"); + const requests = MessagingSetupApplier.listPreEnableChecks(plan); if (requests.length === 0) return true; let registryEntries: ReturnType["sandboxes"]; @@ -493,7 +495,7 @@ async function checkMessagingPreEnableHooks( }); try { - await MessagingSetupApplier.applyHooksForPhase(plan, "pre-enable", { + await MessagingSetupApplier.applyPreEnableChecks(plan, { additionalInputs, runHook: (request) => runMessagingHook( @@ -700,12 +702,12 @@ async function runMessagingHealthChecksAfterRebuild( sandboxName: string, plan: SandboxMessagingPlan, ): Promise { - if (MessagingSetupApplier.listHookRequests(plan, "health-check").length === 0) return; + if (MessagingSetupApplier.listHealthChecks(plan).length === 0) return; const hookRegistry = createBuiltInMessagingHookRegistry(); - let result: Awaited>; + let result: Awaited>; try { - result = await MessagingSetupApplier.applyHooksForPhase(plan, "health-check", { + result = await MessagingSetupApplier.applyHealthChecks(plan, { runHook: (request) => runMessagingHook( { @@ -1008,7 +1010,7 @@ async function planSandboxChannelAdd( const availableChannels = availableManifestChannelsForAgent(agent); const supportedChannelIds = availableChannels.map((manifest) => manifest.id); - hydrateAddChannelEnvFromSession(sandboxName, channelId); + hydrateAddChannelEnvFromStoredState(sandboxName); try { const plan = await planner.buildChannelAddPlanFromSandboxEntry({ @@ -1139,50 +1141,9 @@ function formatMissingInput(input: SandboxMessagingChannelPlan["inputs"][number] return input.sourceEnv ? `${input.inputId} (${input.sourceEnv})` : input.inputId; } -function hydrateAddChannelEnvFromSession(sandboxName: string, channelId: string): void { - if (channelId !== "wechat") return; +function hydrateAddChannelEnvFromStoredState(sandboxName: string): void { const savedSession = safeLoadOnboardSession(); - const savedWechat = - savedSession?.sandboxName === sandboxName ? (savedSession.wechatConfig ?? null) : null; - if (!savedWechat) return; - if (savedWechat.accountId && !process.env.WECHAT_ACCOUNT_ID) { - process.env.WECHAT_ACCOUNT_ID = savedWechat.accountId; - } - if (savedWechat.baseUrl && !process.env.WECHAT_BASE_URL) { - process.env.WECHAT_BASE_URL = savedWechat.baseUrl; - } - if (savedWechat.userId && !process.env.WECHAT_USER_ID) { - process.env.WECHAT_USER_ID = savedWechat.userId; - } -} - -function persistManifestAddState(sandboxName: string, manifest: ChannelManifest): void { - if (manifest.id === "wechat") persistWechatConfigFromEnv(sandboxName); -} - -function persistWechatConfigFromEnv(sandboxName: string): void { - const captured = { - accountId: normalizeEnvValue(process.env.WECHAT_ACCOUNT_ID), - baseUrl: normalizeEnvValue(process.env.WECHAT_BASE_URL), - userId: normalizeEnvValue(process.env.WECHAT_USER_ID), - }; - if (!captured.accountId && !captured.baseUrl && !captured.userId) return; - const session = safeLoadOnboardSession(); - if (session?.sandboxName !== sandboxName) return; - try { - onboardSession.updateSession((current) => { - const prior = current.wechatConfig; - current.wechatConfig = { - accountId: captured.accountId || prior?.accountId, - baseUrl: captured.baseUrl || prior?.baseUrl, - userId: captured.userId || prior?.userId, - }; - return current; - }); - } catch { - // The channel remains usable for an immediate rebuild; deferred rebuilds - // can be recovered by re-running channels add for the same sandbox. - } + hydrateMessagingChannelConfig(getStoredMessagingChannelConfig(sandboxName, savedSession)); } function safeLoadOnboardSession(): ReturnType { @@ -1193,11 +1154,6 @@ function safeLoadOnboardSession(): ReturnType } } -function normalizeEnvValue(value: string | undefined): string | undefined { - const normalized = value?.replace(/\r/g, "").trim(); - return normalized || undefined; -} - export async function addSandboxChannel( sandboxName: string, options: ChannelMutationOptions = {}, @@ -1268,7 +1224,6 @@ export async function addSandboxChannel( process.exit(1); } await applyChannelAddToGatewayAndRegistry(sandboxName, canonical, {}); - persistManifestAddState(sandboxName, manifest); if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); process.exit(1); @@ -1322,7 +1277,6 @@ export async function addSandboxChannel( process.exit(1); } - persistManifestAddState(sandboxName, manifest); if (!MessagingHostStateApplier.applyPlanToRegistry(sandboxName, plan)) { console.error(` ${YW}⚠${R} Could not persist messaging plan for '${sandboxName}'.`); console.error( diff --git a/src/lib/actions/sandbox/rebuild.ts b/src/lib/actions/sandbox/rebuild.ts index e2c1daaeff..4e9a4a1bc9 100644 --- a/src/lib/actions/sandbox/rebuild.ts +++ b/src/lib/actions/sandbox/rebuild.ts @@ -55,6 +55,8 @@ import { MessagingWorkflowPlanner, toMessagingAgentId, } from "../../messaging"; +import { hydrateMessagingChannelConfig } from "../../messaging-channel-config"; +import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config"; import { pruneDisabledMessagingPolicyPresets } from "../../onboard/messaging-policy-presets"; import { captureSandboxListWithGatewayRecovery, @@ -348,31 +350,19 @@ export async function rebuildSandbox( return; } - // Stash WeChat per-account metadata into process.env before the rebuild - // touches anything destructive. The metadata lives in session.wechatConfig - // (captured during the original onboard's host-side QR login) — the only - // durable source today. Surfacing it as WECHAT_ACCOUNT_ID / WECHAT_BASE_URL - // / WECHAT_USER_ID lets the in-process onboard --resume that fires later - // see it directly via the wechatConfig builder's process.env path. - // `openclaw-weixin/` runtime state is intentionally NOT in state_dirs — - // the manifest post-agent-install hook rebuilds account files from these - // env-backed config inputs every image build, so keeping the envs here is - // what the next image needs to put the right accountId/baseUrl/userId back - // into openclaw.json + the accounts state file. + // Hydrate non-secret messaging config before the rebuild touches anything + // destructive. The manifest plan in registry is the durable source; legacy + // session channel fields are read only as compatibility fallback by + // getStoredMessagingChannelConfig(). { - // Only hydrate from the session when it belongs to THIS sandbox. The - // global session file holds the most recent onboard, which may be for a - // different sandbox — pulling its wechatConfig would leak that - // sandbox's accountId / baseUrl / userId into this image build. const rebuildSession = onboardSession.loadSession(); - const wc = - rebuildSession?.sandboxName === sandboxName ? (rebuildSession.wechatConfig ?? null) : null; - if (wc?.accountId && !process.env.WECHAT_ACCOUNT_ID) - process.env.WECHAT_ACCOUNT_ID = wc.accountId; - if (wc?.baseUrl && !process.env.WECHAT_BASE_URL) process.env.WECHAT_BASE_URL = wc.baseUrl; - if (wc?.userId && !process.env.WECHAT_USER_ID) process.env.WECHAT_USER_ID = wc.userId; - if (wc?.accountId) { - log(`Stashed WeChat account metadata for rebuild: accountId=${wc.accountId}`); + const hydratedMessagingConfig = hydrateMessagingChannelConfig( + getStoredMessagingChannelConfig(sandboxName, rebuildSession), + ); + if (hydratedMessagingConfig) { + log( + `Stashed messaging config for rebuild: ${Object.keys(hydratedMessagingConfig).join(",")}`, + ); } } diff --git a/src/lib/actions/sandbox/snapshot.ts b/src/lib/actions/sandbox/snapshot.ts index a00813212c..431b2994aa 100644 --- a/src/lib/actions/sandbox/snapshot.ts +++ b/src/lib/actions/sandbox/snapshot.ts @@ -12,6 +12,7 @@ import { import { CLI_NAME } from "../../cli/branding"; import { prompt as askPrompt } from "../../credentials/store"; import { getSandboxDeleteOutcome } from "../../domain/sandbox/destroy"; +import { listMessagingProviderSuffixes } from "../../messaging/channels"; import * as policies from "../../policy"; import { ROOT, run, shellQuote, validateName } from "../../runner"; import { parseLiveSandboxNames } from "../../runtime-recovery"; @@ -267,22 +268,16 @@ function deleteSandboxForRestore(name: string): void { // Destination-only cleanup so the recreated sandbox does not inherit stale // host-side state or hit provider-name conflicts (Codex #3796 P2): // - /tmp/nemoclaw-services-: PID dir for this sandbox's services - // - OpenShell providers named -{telegram,discord,slack,wechat}-bridge - // and -slack-app: per-sandbox messaging bridges + // - OpenShell per-sandbox messaging bridge providers declared by channel + // manifests. // - shields-.json + shields timer: per-sandbox shields artifacts try { fs.rmSync(`/tmp/nemoclaw-services-${name}`, { recursive: true, force: true }); } catch { // PID dir may not exist \u2014 ignore. } - for (const suffix of [ - "telegram-bridge", - "discord-bridge", - "slack-bridge", - "slack-app", - "wechat-bridge", - ]) { - runOpenshell(["provider", "delete", `${name}-${suffix}`], { + for (const suffix of listMessagingProviderSuffixes()) { + runOpenshell(["provider", "delete", `${name}${suffix}`], { ignoreError: true, stdio: ["ignore", "ignore", "ignore"], }); diff --git a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts index 7947b9a81b..a8ef786903 100644 --- a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts +++ b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts @@ -8,14 +8,14 @@ // generated-shell shape assertions live in // runtime-hermes-secret-boundary-shape.test.ts. +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { describe, it, expect } from "vitest"; +import { describe, expect, it } from "vitest"; import { - HERMES_SECRET_BOUNDARY_VALIDATOR_PATH, __testing, + HERMES_SECRET_BOUNDARY_VALIDATOR_PATH, } from "../../../dist/lib/agent/hermes-recovery-boundary"; import { buildRecoveryScript } from "../../../dist/lib/agent/runtime"; import { hermesAgent } from "./hermes-recovery-boundary-fixtures"; @@ -161,7 +161,9 @@ describe("Hermes secret-boundary guard — guard snippet behaviour", () => { expect(result.stderr).toContain("[gateway-recovery] WARNING"); }); - it("runtime-env guard exits 1 on python validator failure, kills processes, and logs [SECURITY]", () => { + it("runtime-env guard exits 1 on python validator failure, kills processes, and logs [SECURITY]", { + timeout: 20_000, + }, () => { const result = runGuard({ guard: __testing.buildHermesRuntimeEnvBoundaryGuard(), pythonExit: 1, @@ -436,7 +438,7 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () = } finally { fs.rmSync(harness.tmp, { recursive: true, force: true }); } - }); + }, 20_000); it("refuses on runtime-env violation using the real validator against a proxy-env that exports a raw secret", () => { const harness = prepareRecoveryHarness("runtime-env-real"); @@ -481,5 +483,5 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () = } finally { fs.rmSync(harness.tmp, { recursive: true, force: true }); } - }); + }, 20_000); }); diff --git a/src/lib/credentials/command-support.ts b/src/lib/credentials/command-support.ts index c3aa1fb61d..c61a3f4126 100644 --- a/src/lib/credentials/command-support.ts +++ b/src/lib/credentials/command-support.ts @@ -3,15 +3,11 @@ import { recoverNamedGatewayRuntime } from "../actions/global"; import { CLI_DISPLAY_NAME, CLI_NAME } from "../cli/branding"; +import { listMessagingProviderSuffixes } from "../messaging/channels"; // Suffixes that mark per-sandbox messaging integrations in the gateway's // provider list. These are managed by `channels`, not `credentials`. -const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [ - "-telegram-bridge", - "-discord-bridge", - "-slack-bridge", - "-slack-app", -]; +const BRIDGE_PROVIDER_SUFFIXES: readonly string[] = [...listMessagingProviderSuffixes()]; export function isBridgeProviderName(name: string): boolean { return BRIDGE_PROVIDER_SUFFIXES.some((suffix) => name.endsWith(suffix)); diff --git a/src/lib/credentials/store.ts b/src/lib/credentials/store.ts index 8464633e7c..d45501413a 100644 --- a/src/lib/credentials/store.ts +++ b/src/lib/credentials/store.ts @@ -14,6 +14,7 @@ import path from "node:path"; import readline from "node:readline"; import { isErrnoException } from "../core/errno"; +import { listMessagingCredentialMetadata } from "../messaging/channels"; import { rejectSymlinksOnPath } from "../state/config-io"; const UNSAFE_HOME_PATHS = new Set(["/tmp", "/var/tmp", "/dev/shm", "/"]); @@ -41,12 +42,8 @@ export const KNOWN_CREDENTIAL_ENV_KEYS: readonly string[] = [ "GITHUB_TOKEN", "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN", - "TELEGRAM_BOT_TOKEN", "ALLOWED_CHAT_IDS", - "DISCORD_BOT_TOKEN", - "SLACK_BOT_TOKEN", - "SLACK_APP_TOKEN", - "WECHAT_BOT_TOKEN", + ...listMessagingCredentialMetadata().map((credential) => credential.providerEnvKey), ]; // Hard upper bound on the legacy credentials.json size we are willing to diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 50a98af1fc..0e2cd94705 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -21,6 +21,10 @@ describe("messaging channel config", () => { "WECHAT_ALLOWED_IDS", "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", + "WECHAT_ACCOUNT_ID", + "WECHAT_BASE_URL", + "WECHAT_USER_ID", + "WHATSAPP_ALLOWED_IDS", ]); }); diff --git a/src/lib/messaging-channel-config.ts b/src/lib/messaging-channel-config.ts index 6225fb5bfe..19402ab53c 100644 --- a/src/lib/messaging-channel-config.ts +++ b/src/lib/messaging-channel-config.ts @@ -1,21 +1,28 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { BUILT_IN_CHANNEL_MANIFESTS, getMessagingConfigEnvAliases } from "./messaging/channels"; import { listChannels } from "./sandbox/channels"; export type MessagingChannelConfig = Record; const channels = listChannels(); +const manifestConfigInputs = BUILT_IN_CHANNEL_MANIFESTS.flatMap((manifest) => + manifest.inputs + .filter((input) => input.kind === "config") + .map((input) => ({ + envKey: input.envKey, + validValues: "validValues" in input ? input.validValues : undefined, + })), +); const requireMentionKeys = new Set( - channels - .map((channel) => channel.requireMentionEnvKey) - .filter((key): key is string => typeof key === "string" && key.length > 0), + [ + ...channels.map((channel) => channel.requireMentionEnvKey), + ...manifestConfigInputs.filter(hasBooleanStringValues).map((input) => input.envKey), + ].filter((key): key is string => typeof key === "string" && key.length > 0), ); -const configKeyAliases: Readonly> = { - DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], - DISCORD_USER_ID: ["DISCORD_ALLOWED_IDS"], -}; +const configKeyAliases = getMessagingConfigEnvAliases(); const aliasToCanonical = new Map( Object.entries(configKeyAliases).flatMap(([canonical, aliases]) => @@ -25,19 +32,27 @@ const aliasToCanonical = new Map( export const MESSAGING_CHANNEL_CONFIG_ENV_KEYS: readonly string[] = [ ...new Set( - channels.flatMap((channel) => - [ + [ + ...channels.flatMap((channel) => [ channel.serverIdEnvKey, channel.userIdEnvKey, channel.channelIdEnvKey, channel.requireMentionEnvKey, - ].filter((key): key is string => typeof key === "string" && key.length > 0), - ), + ]), + ...manifestConfigInputs.map((input) => input.envKey), + ...BUILT_IN_CHANNEL_MANIFESTS.flatMap( + (manifest) => manifest.state.rebuildHydration?.map((hydration) => hydration.env) ?? [], + ), + ].filter((key): key is string => typeof key === "string" && key.length > 0), ), ]; const knownConfigKeys = new Set(MESSAGING_CHANNEL_CONFIG_ENV_KEYS); +function hasBooleanStringValues(input: { readonly validValues?: readonly string[] }): boolean { + return input.validValues?.includes("0") === true && input.validValues.includes("1"); +} + export type MessagingChannelConfigEnvResolution = { canonicalKey: string | null; sourceKey: string | null; diff --git a/src/lib/messaging/MIGRATION.md b/src/lib/messaging/MIGRATION.md index 8ae217768b..16dcd44c54 100644 --- a/src/lib/messaging/MIGRATION.md +++ b/src/lib/messaging/MIGRATION.md @@ -494,8 +494,8 @@ Primary migrations: - `channels add` calls `applyPreEnableChecks` - `channels start` calls `applyPreEnableChecks` - create/rebuild finalization calls `applyHealthChecks` -- status commands call `applyStatusChecks` -- doctor/deep diagnostics call `applyDiagnostics` +- status commands consume common manifest-derived status outputs +- doctor/deep diagnostics consume common manifest-derived diagnostic specs - sandbox build/start path consumes `applyRuntimePreloads` outputs Validation: @@ -598,12 +598,17 @@ Completed: - Added `pre-enable` and `runtime-preload` to `ChannelHookPhase`. - Added `MessagingSetupApplier.applyHooksForPhase()` and phase helpers that run manifest-declared hooks through the existing hook runner contract. +- Added named `MessagingSetupApplier` phase methods for core-owned phase + orchestration: + - `listPreEnableChecks()` / `applyPreEnableChecks()` + - `listRuntimePreloads()` / `applyRuntimePreloads()` + - `listHealthChecks()` / `applyHealthChecks()` - Kept `enforceMessagingChannelConflicts` as shared guard behavior. Do not move the common credential conflict axis into a hook. - Added Slack `pre-enable` hook `slack.socketModeGatewayConflict` for the Socket Mode gateway axis and declared it in the Slack manifest. - Migrated `channels add` from direct Slack Socket Mode helper calls to - `MessagingSetupApplier.applyHooksForPhase(plan, "pre-enable", ...)`. + `MessagingSetupApplier.applyPreEnableChecks()`. - Migrated onboard's `enforceMessagingChannelConflicts` Slack gateway axis to the same Slack `pre-enable` hook while keeping the shared credential-conflict guard in place. @@ -618,9 +623,8 @@ Completed: - Declared static OpenClaw bridge startup health outputs for Telegram, Slack, and Discord, including Telegram DM allowlist warning metadata. - Migrated `channels add` post-rebuild checks to - `MessagingSetupApplier.applyHooksForPhase(plan, "health-check", ...)` and a - generic health-check output consumer. The old Telegram action helper was - removed. + `MessagingSetupApplier.applyHealthChecks()` and a generic health-check output + consumer. The old Telegram action helper was removed. - Declared static status outputs for: - OpenClaw runtime config aliases and gateway-log patterns for Telegram, Slack, Discord, WeChat, and WhatsApp @@ -637,6 +641,55 @@ Completed: manifest helper. - Migrated `doctor` messaging diagnostics to use common manifest-derived deep-probe hints and manifest-derived gateway-overlap status outputs. +- Migrated remaining core `pre-enable` and `health-check` hook execution call + sites from raw `applyHooksForPhase(..., "")` calls to the named applier + phase methods. +- Added common plan-state replay helpers that derive persisted state values from + `SandboxMessagingPlan.channels[].inputs` and replay env config through + manifest-declared `stateUpdates` / `rebuild-hydration`. +- Migrated resume drift detection to the common manifest-derived messaging + config comparison for Telegram, WeChat, Slack, and Discord. The old + Telegram/WeChat-specific drift checks were removed. +- Migrated `channels add` and sandbox `rebuild` config hydration to plan-backed + `getStoredMessagingChannelConfig()` plus `hydrateMessagingChannelConfig()`. +- Stopped writing new `session.telegramConfig` / `session.wechatConfig` values + from the build-patch and channel-add paths. Those old fields remain read-only + compatibility fallback when no plan config exists. +- Removed the obsolete WeChat host-side state helper from `src/lib/onboard`. +- Added manifest-backed channel metadata helpers for: + - available channel IDs by agent + - credential env keys and env-key-to-channel lookup + - provider name suffixes and sandbox-scoped provider names + - config env keys and manifest-declared env aliases + - policy preset-to-policy-key aliases + - OpenClaw runtime channel config/log keys + - package install hook outputs +- Moved Discord config env aliases into the Discord manifest. +- Replaced `src/lib/sandbox/channels.ts` with a compatibility adapter over + built-in manifests while preserving its public exports. +- Routed the conflict-detection metadata shim, onboard env-key channel lookup, + and credentials bridge-provider suffix detection through the manifest-backed + metadata helpers. +- Migrated remaining Step 8 metadata lists to manifest-backed helpers: + - create-time messaging token provider definitions in `messaging-prep` + - reusable provider names in `messaging-reuse` + - policy preset suggestions, Hermes policy-key aliases, required create-time + presets, and messaging preset validation notes + - sandbox snapshot/provider cleanup suffixes + - credential-store known env keys and redaction env assignment keys + - messaging config env aliases +- Moved Slack's create-time required policy preset flag into the Slack + manifest. +- Moved Discord's policy validation warning text into the Discord manifest. +- Moved Telegram's OpenClaw/Hermes policy key difference into the Telegram + manifest. +- Migrated `scripts/lib/sandbox-init.sh` active-channel logging from concrete + token env checks to `NEMOCLAW_MESSAGING_PLAN_B64`. +- Migrated `scripts/nemoclaw-start.sh` provider-placeholder refresh from + concrete credential/channel maps to provider env keys discovered from the + messaging plan and current OpenClaw config. +- Replaced the install help's concrete messaging env list with a generic + messaging credential note. Pending: diff --git a/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts b/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts index d793d97db6..d4a9287ddc 100644 --- a/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts +++ b/src/lib/messaging/applier/conflict-detection/manifest-metadata.ts @@ -1,21 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { BUILT_IN_CHANNEL_MANIFESTS } from "../../channels"; +import { + getMessagingCredentialEnvKeysByChannel, + getMessagingProviderSuffixesByChannel, +} from "../../channels"; // Map channelId to providerEnvKey values declared in built-in manifests. // This is the primary key set for hash comparison so a missing credential for // one of a channel's required credentials conservatively marks the comparison // as unknown-token rather than silently returning null. export const CHANNEL_CREDENTIAL_ENV_KEYS: Readonly> = - Object.fromEntries( - BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]), - ); + getMessagingCredentialEnvKeysByChannel(); -export const PROVIDER_SUFFIXES: Record = Object.fromEntries( - BUILT_IN_CHANNEL_MANIFESTS.flatMap((m) => { - const suffixes = m.credentials.map((c) => c.providerName.replace("{sandboxName}", "")); - if (suffixes.length === 0) return []; - return [[m.id, suffixes]]; - }), -); +export const PROVIDER_SUFFIXES: Readonly> = + getMessagingProviderSuffixesByChannel(); diff --git a/src/lib/messaging/applier/hook-phases.test.ts b/src/lib/messaging/applier/hook-phases.test.ts index 601226e51d..1662a3b788 100644 --- a/src/lib/messaging/applier/hook-phases.test.ts +++ b/src/lib/messaging/applier/hook-phases.test.ts @@ -3,19 +3,19 @@ import { describe, expect, it } from "vitest"; -import type { MessagingHookApplyRequest, MessagingHookApplyRunner } from "./types"; -import { - applyDiagnostics, - applyPreEnableChecks, - applyRuntimePreloads, - MessagingSetupApplier, -} from "./index"; import type { ChannelHookPhase, MessagingChannelId, SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; +import { + applyDiagnostics, + applyPreEnableChecks, + applyRuntimePreloads, + MessagingSetupApplier, +} from "./index"; +import type { MessagingHookApplyRequest, MessagingHookApplyRunner } from "./types"; describe("messaging applier hook phases", () => { it("runs enabled channel hooks for the requested phase through the provided runner", async () => { @@ -127,12 +127,11 @@ describe("messaging applier hook phases", () => { }; }; - const result = await MessagingSetupApplier.applyHooksForPhase( + const result = await MessagingSetupApplier.applyPreEnableChecks( makePlan({ telegramOnFailure: "skip-channel", includeDiscordPreEnable: true, }), - "pre-enable", { runHook }, ); diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index 59c7e47e49..d689a86d1e 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -193,6 +193,35 @@ describe("MessagingSetupApplier", () => { handler: "wechat.seedOpenClawAccount", }), ]); + + const slackPlan = await buildOnboardPlan( + { + SLACK_BOT_TOKEN: "xoxb-slack-token", + SLACK_APP_TOKEN: "xapp-slack-token", + }, + ["slack"], + ); + expect(MessagingSetupApplier.listPreEnableChecks(slackPlan)).toEqual([ + expect.objectContaining({ + channelId: "slack", + hookId: "slack-socket-mode-gateway-conflict", + phase: "pre-enable", + }), + ]); + expect(MessagingSetupApplier.listRuntimePreloads(slackPlan)).toEqual([ + expect.objectContaining({ + channelId: "slack", + hookId: "slack-runtime-preload", + phase: "runtime-preload", + }), + ]); + expect(MessagingSetupApplier.listHealthChecks(slackPlan)).toEqual([ + expect.objectContaining({ + channelId: "slack", + hookId: "slack-openclaw-bridge-health", + phase: "health-check", + }), + ]); }); it("upserts OpenShell generic providers from plan credential bindings", async () => { diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 1705962a17..87230b6d3d 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -3,13 +3,18 @@ import { Buffer } from "node:buffer"; -import type { MessagingHookInputMap } from "../hooks"; import type { ChannelHookPhase, SandboxMessagingPlan } from "../manifest"; import { applyAgentConfigAtOpenShell as applyAgentConfigPlanAtOpenShell, listHookRequests as listPlanHookRequests, } from "./agent-config"; -import { applyMessagingHooksForPhase as applyPlanHooksForPhase } from "./hook-phases"; +import { + applyHealthChecks as applyPlanHealthChecks, + applyMessagingHooksForPhase as applyPlanHooksForPhase, + applyPreEnableChecks as applyPlanPreEnableChecks, + applyRuntimePreloads as applyPlanRuntimePreloads, + type MessagingHookPhaseOptions, +} from "./hook-phases"; import { applyCredentialsAtOpenShell as applyCredentialsPlanAtOpenShell } from "./openshell-provider"; import { applyPolicyAtOpenShell as applyPolicyPlanAtOpenShell } from "./policy"; import { @@ -70,18 +75,54 @@ export class MessagingSetupApplier { return listPlanHookRequests(plan, phase); } + static listPreEnableChecks(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] { + assertSandboxMessagingPlan(plan); + return listPlanHookRequests(plan, "pre-enable"); + } + + static listRuntimePreloads(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] { + assertSandboxMessagingPlan(plan); + return listPlanHookRequests(plan, "runtime-preload"); + } + + static listHealthChecks(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] { + assertSandboxMessagingPlan(plan); + return listPlanHookRequests(plan, "health-check"); + } + static applyHooksForPhase( plan: SandboxMessagingPlan, phase: ChannelHookPhase, - options: { - readonly runHook?: MessagingHookApplyRunner; - readonly additionalInputs?: MessagingHookInputMap; - } = {}, + options: MessagingHookPhaseOptions = {}, ): ReturnType { assertSandboxMessagingPlan(plan); return applyPlanHooksForPhase(plan, phase, options); } + static applyPreEnableChecks( + plan: SandboxMessagingPlan, + options: MessagingHookPhaseOptions = {}, + ): ReturnType { + assertSandboxMessagingPlan(plan); + return applyPlanPreEnableChecks(plan, options); + } + + static applyRuntimePreloads( + plan: SandboxMessagingPlan, + options: MessagingHookPhaseOptions = {}, + ): ReturnType { + assertSandboxMessagingPlan(plan); + return applyPlanRuntimePreloads(plan, options); + } + + static applyHealthChecks( + plan: SandboxMessagingPlan, + options: MessagingHookPhaseOptions = {}, + ): ReturnType { + assertSandboxMessagingPlan(plan); + return applyPlanHealthChecks(plan, options); + } + static async applyAgentConfigAtOpenShell( plan: SandboxMessagingPlan, options: { diff --git a/src/lib/messaging/channels/built-ins.ts b/src/lib/messaging/channels/built-ins.ts new file mode 100644 index 0000000000..441e224122 --- /dev/null +++ b/src/lib/messaging/channels/built-ins.ts @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { ChannelManifestRegistry } from "../manifest"; +import { createChannelManifestRegistry } from "../manifest"; +import { discordManifest } from "./discord/manifest"; +import { slackManifest } from "./slack/manifest"; +import { telegramManifest } from "./telegram/manifest"; +import { wechatManifest } from "./wechat/manifest"; +import { whatsappManifest } from "./whatsapp/manifest"; + +export { discordManifest } from "./discord/manifest"; +export { slackManifest } from "./slack/manifest"; +export { telegramManifest } from "./telegram/manifest"; +export { wechatManifest } from "./wechat/manifest"; +export { whatsappManifest } from "./whatsapp/manifest"; + +export const BUILT_IN_CHANNEL_MANIFESTS = [ + telegramManifest, + discordManifest, + wechatManifest, + slackManifest, + whatsappManifest, +] as const; + +export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry { + return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS); +} diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index b6635ed733..8dbde897b8 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -28,6 +28,7 @@ export const discordManifest = { kind: "config", required: false, envKey: "DISCORD_SERVER_ID", + envAliases: ["DISCORD_SERVER_IDS"], statePath: "discordGuilds.serverId", prompt: { label: "Discord Server ID (for guild workspace access)", @@ -53,6 +54,7 @@ export const discordManifest = { kind: "config", required: false, envKey: "DISCORD_USER_ID", + envAliases: ["DISCORD_ALLOWED_IDS"], statePath: "discordGuilds.userIds", promptWhenInput: "serverId", prompt: { @@ -71,7 +73,19 @@ export const discordManifest = { placeholder: "openshell:resolve:env:DISCORD_BOT_TOKEN", }, ], - policyPresets: ["discord"], + policyPresets: [ + { + name: "discord", + validationWarningLines: [ + "For Discord preset validation, do not use curl as the success signal:", + "curl is not in the preset binary allowlist, so curl probes can fail even", + "when the policy is working. Use Node HTTPS against", + "https://discord.com/api/v10/gateway or validate the configured", + 'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")', + "can also be inconclusive behind a proxy.", + ], + }, + ], render: [ { id: "discord-openclaw-channel", diff --git a/src/lib/messaging/channels/index.ts b/src/lib/messaging/channels/index.ts index 3281260d73..187cb7e14d 100644 --- a/src/lib/messaging/channels/index.ts +++ b/src/lib/messaging/channels/index.ts @@ -1,29 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { ChannelManifestRegistry } from "../manifest"; -import { createChannelManifestRegistry } from "../manifest"; -import { discordManifest } from "./discord/manifest"; -import { slackManifest } from "./slack/manifest"; -import { telegramManifest } from "./telegram/manifest"; -import { wechatManifest } from "./wechat/manifest"; -import { whatsappManifest } from "./whatsapp/manifest"; - -export { discordManifest } from "./discord/manifest"; -export { slackManifest } from "./slack/manifest"; -export { telegramManifest } from "./telegram/manifest"; +export * from "./built-ins"; +export * from "./metadata"; export { createBuiltInRenderTemplateResolver } from "./template-resolver"; -export { wechatManifest } from "./wechat/manifest"; -export { whatsappManifest } from "./whatsapp/manifest"; - -export const BUILT_IN_CHANNEL_MANIFESTS = [ - telegramManifest, - discordManifest, - wechatManifest, - slackManifest, - whatsappManifest, -] as const; - -export function createBuiltInChannelManifestRegistry(): ChannelManifestRegistry { - return createChannelManifestRegistry(BUILT_IN_CHANNEL_MANIFESTS); -} diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts new file mode 100644 index 0000000000..b582e72a99 --- /dev/null +++ b/src/lib/messaging/channels/metadata.test.ts @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + getMessagingChannelForCredentialEnvKey, + getMessagingConfigEnvAliases, + getMessagingCredentialEnvKeysByChannel, + getMessagingPolicyKeyAliases, + getMessagingPolicyKeysByChannel, + getMessagingPolicyPresetValidationWarnings, + getMessagingProviderSuffixesByChannel, + listAvailableMessagingChannelIds, + listMessagingConfigEnvKeys, + listMessagingPackageInstallSpecs, + listMessagingProviderNamesForChannel, + listOpenClawRuntimeChannelMetadata, + listRequiredCreateTimeMessagingPolicyPresetNames, +} from "./metadata"; + +describe("built-in messaging channel metadata", () => { + it("lists available channels by agent from manifests", () => { + expect(listAvailableMessagingChannelIds({ agent: "openclaw" })).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + ]); + expect(listAvailableMessagingChannelIds({ agent: "hermes" })).toEqual([ + "telegram", + "discord", + "wechat", + "slack", + "whatsapp", + ]); + }); + + it("resolves credential env keys, env-key ownership, and provider names", () => { + expect(getMessagingCredentialEnvKeysByChannel()).toMatchObject({ + telegram: ["TELEGRAM_BOT_TOKEN"], + discord: ["DISCORD_BOT_TOKEN"], + wechat: ["WECHAT_BOT_TOKEN"], + slack: ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"], + whatsapp: [], + }); + expect(getMessagingChannelForCredentialEnvKey("SLACK_APP_TOKEN")).toBe("slack"); + expect(getMessagingChannelForCredentialEnvKey("WHATSAPP_ALLOWED_IDS")).toBeNull(); + expect(getMessagingProviderSuffixesByChannel()).toMatchObject({ + telegram: ["-telegram-bridge"], + discord: ["-discord-bridge"], + wechat: ["-wechat-bridge"], + slack: ["-slack-bridge", "-slack-app"], + }); + expect(listMessagingProviderNamesForChannel("demo", "slack")).toEqual([ + "demo-slack-bridge", + "demo-slack-app", + ]); + }); + + it("resolves config env keys and aliases from manifest inputs", () => { + expect(listMessagingConfigEnvKeys()).toEqual([ + "TELEGRAM_ALLOWED_IDS", + "TELEGRAM_REQUIRE_MENTION", + "DISCORD_SERVER_ID", + "DISCORD_REQUIRE_MENTION", + "DISCORD_USER_ID", + "WECHAT_ACCOUNT_ID", + "WECHAT_BASE_URL", + "WECHAT_USER_ID", + "WECHAT_ALLOWED_IDS", + "SLACK_ALLOWED_USERS", + "SLACK_ALLOWED_CHANNELS", + "WHATSAPP_ALLOWED_IDS", + ]); + expect(getMessagingConfigEnvAliases()).toEqual({ + DISCORD_SERVER_ID: ["DISCORD_SERVER_IDS"], + DISCORD_USER_ID: ["DISCORD_ALLOWED_IDS"], + }); + }); + + it("resolves policy aliases, OpenClaw runtime keys, and package specs", () => { + expect(getMessagingPolicyKeyAliases()).toMatchObject({ + telegram: ["telegram_bot", "telegram"], + discord: ["discord"], + wechat: ["wechat_bridge"], + slack: ["slack"], + whatsapp: ["whatsapp"], + }); + expect(getMessagingPolicyKeysByChannel({ agent: "hermes" })).toMatchObject({ + telegram: ["telegram"], + discord: ["discord"], + wechat: ["wechat_bridge"], + slack: ["slack"], + whatsapp: ["whatsapp"], + }); + expect(listRequiredCreateTimeMessagingPolicyPresetNames()).toEqual(["slack"]); + expect(getMessagingPolicyPresetValidationWarnings().discord).toContain( + "https://discord.com/api/v10/gateway or validate the configured", + ); + expect( + Object.fromEntries( + listOpenClawRuntimeChannelMetadata().map((entry) => [entry.channelId, entry.configKeys]), + ), + ).toMatchObject({ + telegram: ["telegram"], + discord: ["discord"], + wechat: ["openclaw-weixin"], + slack: ["slack"], + whatsapp: ["whatsapp"], + }); + expect( + Object.fromEntries( + listMessagingPackageInstallSpecs({ agent: "openclaw" }).map((entry) => [ + entry.channelId, + entry.spec, + ]), + ), + ).toMatchObject({ + discord: "npm:@openclaw/discord@{{openclaw.version}}", + wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + slack: "npm:@openclaw/slack@{{openclaw.version}}", + whatsapp: "npm:@openclaw/whatsapp@{{openclaw.version}}", + }); + expect(listMessagingPackageInstallSpecs({ agent: "hermes" })).toEqual([]); + }); +}); diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts new file mode 100644 index 0000000000..c52ff759a1 --- /dev/null +++ b/src/lib/messaging/channels/metadata.ts @@ -0,0 +1,367 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelHookOutputSpec, + ChannelManifest, + ChannelPolicyPresetReference, + ChannelPolicyPresetSpec, + MessagingAgentId, + MessagingSerializableObject, + MessagingSerializableValue, +} from "../manifest"; +import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins"; + +export interface MessagingManifestMetadataOptions { + readonly agent?: MessagingAgentId; + readonly manifests?: readonly ChannelManifest[]; +} + +export interface MessagingCredentialMetadata { + readonly channelId: string; + readonly credentialId: string; + readonly sourceInput: string; + readonly providerNameTemplate: string; + readonly providerNameSuffix: string; + readonly providerEnvKey: string; + readonly placeholder: string; +} + +export interface MessagingConfigEnvMetadata { + readonly channelId: string; + readonly inputId: string; + readonly envKey: string; + readonly envAliases: readonly string[]; + readonly statePath?: string; + readonly validValues?: readonly string[]; +} + +export interface MessagingPolicyPresetMetadata { + readonly channelId: string; + readonly presetName: string; + readonly policyKeys: readonly string[]; + readonly agentPolicyKeys: Partial>; + readonly requiredAtCreate: boolean; + readonly validationWarningLines: readonly string[]; +} + +export interface OpenClawRuntimeChannelMetadata { + readonly channelId: string; + readonly hookId: string; + readonly outputId: string; + readonly configKeys: readonly string[]; + readonly logPatterns: readonly string[]; +} + +export interface MessagingPackageInstallMetadata { + readonly channelId: string; + readonly hookId: string; + readonly outputId: string; + readonly agents: readonly MessagingAgentId[]; + readonly manager?: string; + readonly spec?: string; + readonly pin?: boolean; +} + +export function listBuiltInMessagingChannelManifests( + options: MessagingManifestMetadataOptions = {}, +): ChannelManifest[] { + return selectManifests(options); +} + +export function listAvailableMessagingChannelIds( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return selectManifests(options).map((manifest) => manifest.id); +} + +export function listMessagingCredentialMetadata( + options: MessagingManifestMetadataOptions = {}, +): MessagingCredentialMetadata[] { + return selectManifests(options).flatMap((manifest) => + manifest.credentials.map((credential) => ({ + channelId: manifest.id, + credentialId: credential.id, + sourceInput: credential.sourceInput, + providerNameTemplate: credential.providerName, + providerNameSuffix: providerNameSuffix(credential.providerName), + providerEnvKey: credential.providerEnvKey, + placeholder: credential.placeholder, + })), + ); +} + +export function getMessagingCredentialEnvKeysByChannel( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + return Object.fromEntries( + selectManifests(options).map((manifest) => [ + manifest.id, + manifest.credentials.map((credential) => credential.providerEnvKey), + ]), + ); +} + +export function getMessagingChannelForCredentialEnvKey( + envKey: string, + options: MessagingManifestMetadataOptions = {}, +): string | null { + return ( + listMessagingCredentialMetadata(options).find( + (credential) => credential.providerEnvKey === envKey, + )?.channelId ?? null + ); +} + +export function getMessagingProviderSuffixesByChannel( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + return Object.fromEntries( + selectManifests(options).flatMap((manifest) => { + const suffixes = manifest.credentials.map((credential) => + providerNameSuffix(credential.providerName), + ); + return suffixes.length > 0 ? [[manifest.id, suffixes]] : []; + }), + ); +} + +export function listMessagingProviderSuffixes( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return uniqueStrings( + listMessagingCredentialMetadata(options).map((credential) => credential.providerNameSuffix), + ); +} + +export function listMessagingProviderNamesForChannel( + sandboxName: string, + channelId: string, + options: MessagingManifestMetadataOptions = {}, +): string[] { + const manifest = selectManifests(options).find((entry) => entry.id === channelId); + if (!manifest) return []; + return manifest.credentials.map((credential) => + credential.providerName.replaceAll("{sandboxName}", sandboxName), + ); +} + +export function listMessagingConfigEnvMetadata( + options: MessagingManifestMetadataOptions = {}, +): MessagingConfigEnvMetadata[] { + return selectManifests(options).flatMap((manifest) => + manifest.inputs.flatMap((input) => { + if (input.kind !== "config" || !input.envKey) return []; + return [ + { + channelId: manifest.id, + inputId: input.id, + envKey: input.envKey, + envAliases: input.envAliases ?? [], + ...(input.statePath ? { statePath: input.statePath } : {}), + ...(input.validValues ? { validValues: input.validValues } : {}), + }, + ]; + }), + ); +} + +export function listMessagingConfigEnvKeys( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return uniqueStrings(listMessagingConfigEnvMetadata(options).map((input) => input.envKey)); +} + +export function getMessagingConfigEnvAliases( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + return Object.fromEntries( + listMessagingConfigEnvMetadata(options) + .filter((input) => input.envAliases.length > 0) + .map((input) => [input.envKey, input.envAliases]), + ); +} + +export function listMessagingPolicyPresetMetadata( + options: MessagingManifestMetadataOptions = {}, +): MessagingPolicyPresetMetadata[] { + return selectManifests(options).flatMap((manifest) => + (manifest.policyPresets ?? []).map((preset) => { + const normalized = normalizePolicyPreset(preset); + return { + channelId: manifest.id, + presetName: normalized.name, + policyKeys: normalized.policyKeys ?? [normalized.name], + agentPolicyKeys: normalized.agentPolicyKeys ?? {}, + requiredAtCreate: normalized.requiredAtCreate === true, + validationWarningLines: normalized.validationWarningLines ?? [], + }; + }), + ); +} + +export function getMessagingPolicyKeysByChannel( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + const result: Record = {}; + for (const preset of listMessagingPolicyPresetMetadata(options)) { + const keys = options.agent + ? (preset.agentPolicyKeys[options.agent] ?? preset.policyKeys) + : preset.policyKeys; + result[preset.channelId] = uniqueStrings([...(result[preset.channelId] ?? []), ...keys]); + } + return result; +} + +export function listRequiredCreateTimeMessagingPolicyPresetNames( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return uniqueStrings( + listMessagingPolicyPresetMetadata(options) + .filter((preset) => preset.requiredAtCreate) + .map((preset) => preset.presetName), + ); +} + +export function listRequiredCreateTimeMessagingPolicyPresetsByChannel( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + const result: Record = {}; + for (const preset of listMessagingPolicyPresetMetadata(options)) { + if (!preset.requiredAtCreate) continue; + result[preset.channelId] = uniqueStrings([ + ...(result[preset.channelId] ?? []), + preset.presetName, + ]); + } + return result; +} + +export function getMessagingPolicyKeyAliases( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + return Object.fromEntries( + listMessagingPolicyPresetMetadata(options).map((preset) => [ + preset.presetName, + uniqueStrings([ + ...preset.policyKeys, + ...Object.values(preset.agentPolicyKeys).flatMap((keys) => keys ?? []), + ]), + ]), + ); +} + +export function getMessagingPolicyPresetValidationWarnings( + options: MessagingManifestMetadataOptions = {}, +): Readonly> { + return Object.fromEntries( + listMessagingPolicyPresetMetadata(options) + .filter((preset) => preset.validationWarningLines.length > 0) + .map((preset) => [preset.presetName, preset.validationWarningLines]), + ); +} + +export function listOpenClawRuntimeChannelMetadata( + options: MessagingManifestMetadataOptions = {}, +): OpenClawRuntimeChannelMetadata[] { + return selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => + manifest.hooks.flatMap((hook) => { + if (hook.phase !== "status" || !hookTargetsAgent(hook.agents, "openclaw")) return []; + return (hook.outputs ?? []).flatMap((output) => { + if (output.kind !== "status") return []; + const value = serializableObject(output.value); + if (value?.type !== "openclaw-runtime-channel") return []; + return [ + { + channelId: manifest.id, + hookId: hook.id, + outputId: output.id, + configKeys: stringArray(value.configKeys), + logPatterns: stringArray(value.logPatterns), + }, + ]; + }); + }), + ); +} + +export function listMessagingPackageInstallSpecs( + options: MessagingManifestMetadataOptions = {}, +): MessagingPackageInstallMetadata[] { + return selectManifests(options).flatMap((manifest) => + manifest.hooks.flatMap((hook) => { + if (hook.phase !== "agent-install") return []; + if (options.agent && !hookTargetsAgent(hook.agents, options.agent)) return []; + return (hook.outputs ?? []).flatMap((output) => { + if (output.kind !== "package-install") return []; + const value = serializableObject(output.value); + return [ + { + channelId: manifest.id, + hookId: hook.id, + outputId: output.id, + agents: hook.agents ?? [], + ...packageInstallValue(value), + }, + ]; + }); + }), + ); +} + +function selectManifests(options: MessagingManifestMetadataOptions): ChannelManifest[] { + const manifests = options.manifests ?? BUILT_IN_CHANNEL_MANIFESTS; + const agent = options.agent; + const selected = agent + ? manifests.filter((manifest) => manifest.supportedAgents.includes(agent)) + : manifests; + return [...selected]; +} + +function providerNameSuffix(providerNameTemplate: string): string { + return providerNameTemplate.replaceAll("{sandboxName}", ""); +} + +function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPolicyPresetSpec { + return typeof preset === "string" ? { name: preset } : preset; +} + +function hookTargetsAgent( + agents: readonly MessagingAgentId[] | undefined, + agent: MessagingAgentId, +): boolean { + return agents === undefined || agents.includes(agent); +} + +function serializableObject( + value: ChannelHookOutputSpec["value"], +): MessagingSerializableObject | null { + return isSerializableObject(value) ? value : null; +} + +function packageInstallValue( + value: MessagingSerializableObject | null, +): Pick { + if (!value) return {}; + return { + ...(typeof value.manager === "string" ? { manager: value.manager } : {}), + ...(typeof value.spec === "string" ? { spec: value.spec } : {}), + ...(typeof value.pin === "boolean" ? { pin: value.pin } : {}), + }; +} + +function isSerializableObject( + value: MessagingSerializableValue | undefined, +): value is MessagingSerializableObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringArray(value: MessagingSerializableValue | undefined): string[] { + return Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +} + +function uniqueStrings(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts index bd914f64d0..4b850181fa 100644 --- a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts @@ -9,9 +9,9 @@ import { slackBindings, slackChannel, } from "../../../../../../test/helpers/messaging-conflict-fixtures"; +import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; import type { ChannelHookSpec, MessagingSerializableValue } from "../../../manifest"; -import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; import { createSlackSocketModeGatewayConflictHookRegistration, SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, @@ -88,6 +88,28 @@ describe("slack.socketModeGatewayConflict hook", () => { ).rejects.toThrow("Slack Socket Mode is already enabled for sandbox 'alice'"); }); + it("treats an empty serialized registry as valid no-conflict context", async () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayConflictHookRegistration(), + ]); + + await expect( + runMessagingHook(HOOK, registry, { + channelId: "slack", + inputs: { + currentSandbox: "bob", + currentGatewayName: "nemoclaw", + registryEntries: [], + }, + }), + ).resolves.toEqual({ + hookId: "slack-socket-mode-gateway-conflict", + handlerId: SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + phase: "pre-enable", + outputs: {}, + }); + }); + it("requires gateway and registry context when no options are injected", async () => { const registry = new MessagingHookRegistry([ createSlackSocketModeGatewayConflictHookRegistration(), diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts index 6cee2a387c..f3cca33f1e 100644 --- a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts @@ -7,12 +7,12 @@ import { type SlackGatewayConflict, } from "../../../applier/conflict-detection/slack-socket-mode"; import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; -import type { MessagingSerializableValue } from "../../../manifest"; import type { MessagingHookContext, MessagingHookHandler, MessagingHookRegistration, } from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; export const SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID = "slack.socketModeGatewayConflict"; @@ -115,7 +115,7 @@ function parseRegistryEntries( }, ]; }); - return entries.length > 0 ? entries : null; + return entries; } function normalizeNullableString(value: unknown): string | null { diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index b9c46aa1b6..5c4dd6736f 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -81,7 +81,7 @@ export const slackManifest = { placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", }, ], - policyPresets: ["slack"], + policyPresets: [{ name: "slack", requiredAtCreate: true }], render: [ { id: "slack-openclaw-channel", diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 17727637d2..056fa47558 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -61,7 +61,15 @@ export const telegramManifest = { placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", }, ], - policyPresets: [{ name: "telegram", policyKeys: ["telegram_bot"] }], + policyPresets: [ + { + name: "telegram", + policyKeys: ["telegram_bot"], + agentPolicyKeys: { + hermes: ["telegram"], + }, + }, + ], render: [ { id: "telegram-openclaw-channel", diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 37f682ff0e..b5d5cf12de 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -54,6 +54,8 @@ export interface ChannelPolicyPresetSpec { readonly name: string; readonly policyKeys?: readonly string[]; readonly agentPolicyKeys?: Partial>; + readonly requiredAtCreate?: boolean; + readonly validationWarningLines?: readonly string[]; } /** How a channel obtains credential or session material. */ @@ -81,6 +83,7 @@ interface ChannelInputBaseSpec { readonly validValues?: readonly string[]; readonly formatPattern?: string; readonly formatHint?: string; + readonly envAliases?: readonly string[]; } /** Secret input metadata; values must be referenced, not stored in manifests or plans. */ diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index c7a9d49078..fc919489b1 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -9,6 +9,7 @@ import { getConfiguredChannelIdsFromPlan, getDisabledChannelIdsFromPlan, getMessagingChannelConfigFromPlan, + getMessagingPlanStateValues, parseSandboxMessagingPlan, } from "./plan-validation"; @@ -95,4 +96,178 @@ describe("plan channel derivation", () => { expect(getDisabledChannelIdsFromPlan(plan)).toEqual(["telegram"]); expect(getMessagingChannelConfigFromPlan(plan)).toEqual({ TELEGRAM_ALLOWED_IDS: "123" }); }); + + it("replays manifest-declared state hydration env values from plan inputs", () => { + const plan = makePlan({ + channels: [ + { + ...makePlan().channels[0], + inputs: [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_REQUIRE_MENTION", + statePath: "telegramConfig.requireMention", + value: "1", + }, + ], + }, + { + channelId: "wechat", + displayName: "WeChat", + authMode: "host-qr", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "wechat", + inputId: "accountId", + kind: "config", + required: true, + sourceEnv: "WECHAT_ACCOUNT_ID", + statePath: "wechatConfig.accountId", + value: "wechat-account", + }, + { + channelId: "wechat", + inputId: "baseUrl", + kind: "config", + required: false, + sourceEnv: "WECHAT_BASE_URL", + statePath: "wechatConfig.baseUrl", + value: "https://wechat.example", + }, + ], + hooks: [], + }, + { + channelId: "slack", + displayName: "Slack", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "slack", + inputId: "allowedUsers", + kind: "config", + required: false, + sourceEnv: "SLACK_ALLOWED_USERS", + statePath: "allowedIds.slack", + value: "U01ABC2DEF3", + }, + { + channelId: "slack", + inputId: "allowedChannels", + kind: "config", + required: false, + sourceEnv: "SLACK_ALLOWED_CHANNELS", + statePath: "slackConfig.allowedChannels", + value: "C012AB3CD", + }, + ], + hooks: [], + }, + { + channelId: "discord", + displayName: "Discord", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "discord", + inputId: "serverId", + kind: "config", + required: false, + sourceEnv: "DISCORD_SERVER_ID", + statePath: "discordGuilds.serverId", + value: "guild-1", + }, + { + channelId: "discord", + inputId: "userId", + kind: "config", + required: false, + sourceEnv: "DISCORD_USER_ID", + statePath: "discordGuilds.userIds", + value: "user-1", + }, + ], + hooks: [], + }, + ], + stateUpdates: [ + { + channelId: "telegram", + kind: "rebuild-hydration", + statePath: "telegramConfig.requireMention", + env: "TELEGRAM_REQUIRE_MENTION", + }, + { + channelId: "wechat", + kind: "rebuild-hydration", + statePath: "wechatConfig.accountId", + env: "WECHAT_ACCOUNT_ID", + }, + { + channelId: "wechat", + kind: "rebuild-hydration", + statePath: "wechatConfig.baseUrl", + env: "WECHAT_BASE_URL", + }, + { + channelId: "slack", + kind: "rebuild-hydration", + statePath: "allowedIds.slack", + env: "SLACK_ALLOWED_USERS", + }, + { + channelId: "slack", + kind: "rebuild-hydration", + statePath: "slackConfig.allowedChannels", + env: "SLACK_ALLOWED_CHANNELS", + }, + { + channelId: "discord", + kind: "rebuild-hydration", + statePath: "discordGuilds.serverId", + env: "DISCORD_SERVER_ID", + }, + { + channelId: "discord", + kind: "rebuild-hydration", + statePath: "discordGuilds.userIds", + env: "DISCORD_USER_ID", + }, + ], + }); + + expect(getMessagingPlanStateValues(plan)).toMatchObject({ + "telegramConfig.requireMention": "1", + "wechatConfig.accountId": "wechat-account", + "wechatConfig.baseUrl": "https://wechat.example", + "allowedIds.slack": "U01ABC2DEF3", + "slackConfig.allowedChannels": "C012AB3CD", + "discordGuilds.serverId": "guild-1", + "discordGuilds.userIds": "user-1", + }); + expect(getMessagingChannelConfigFromPlan(plan)).toEqual({ + TELEGRAM_REQUIRE_MENTION: "1", + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://wechat.example", + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + SLACK_ALLOWED_CHANNELS: "C012AB3CD", + DISCORD_SERVER_ID: "guild-1", + DISCORD_USER_ID: "user-1", + }); + }); }); diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index a10c114132..db3d2a1604 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -2,7 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingChannelConfig } from "../messaging-channel-config"; -import type { MessagingAgentId, MessagingChannelId, SandboxMessagingPlan } from "./manifest"; +import type { + MessagingAgentId, + MessagingChannelId, + MessagingSerializableValue, + SandboxMessagingPlan, +} from "./manifest"; export interface SandboxMessagingPlanParseOptions { sandboxName?: string | null; @@ -91,15 +96,52 @@ export function getMessagingChannelConfigFromPlan( ): MessagingChannelConfig | null { if (!plan) return null; const config: MessagingChannelConfig = {}; + const stateValues = getMessagingPlanStateValues(plan); + + for (const update of plan.stateUpdates) { + if (update.kind !== "rebuild-hydration") continue; + const value = stringifyPlanStateValue(stateValues[update.statePath]); + if (value) config[update.env] = value; + } + for (const channel of plan.channels) { for (const input of channel.inputs) { if (input.kind !== "config" || !input.sourceEnv || input.value == null) continue; - config[input.sourceEnv] = String(input.value); + if (config[input.sourceEnv]) continue; + const value = stringifyPlanStateValue(input.value); + if (value) config[input.sourceEnv] = value; } } return Object.keys(config).length > 0 ? config : null; } +export function getMessagingPlanStateValues( + plan: SandboxMessagingPlan | null | undefined, +): Record { + if (!plan) return {}; + const values: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind !== "config" || !input.statePath || input.value == null) continue; + values[input.statePath] = input.value; + } + } + return values; +} + +function stringifyPlanStateValue(value: MessagingSerializableValue | undefined): string | null { + if (value == null) return null; + if (Array.isArray(value)) { + const csv = value + .map((entry) => String(entry).trim()) + .filter(Boolean) + .join(","); + return csv || null; + } + const text = String(value).trim(); + return text.length > 0 ? text : null; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 8deb396976..d9e1627a98 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -111,8 +111,6 @@ const { }: typeof import("./onboard/e2e-failure-injection") = require("./onboard/e2e-failure-injection"); const onboardTracing: typeof import("./onboard/tracing") = require("./onboard/tracing"); const sandboxReadinessTracing: typeof import("./onboard/sandbox-readiness-tracing") = require("./onboard/sandbox-readiness-tracing"); -const { hasWechatConfigDrift } = - require("./onboard/wechat-config") as typeof import("./onboard/wechat-config"); const { setupMessagingChannels: setupMessagingChannelsImpl, readMessagingPlanFromEnv, @@ -402,11 +400,7 @@ const { getMessagingChannelForEnvKey, getRecordedMessagingChannelsForResume: getRecordedMessagingChannelsForResumeFromState, }: typeof import("./onboard/messaging-credentials") = require("./onboard/messaging-credentials"); -const { - computeTelegramRequireMention, - getStoredMessagingChannelConfig, - messagingChannelConfigsEqual, -} = messagingConfig; +const { getStoredMessagingChannelConfig, messagingChannelConfigsEqual } = messagingConfig; const messagingPlanSession: typeof import("./onboard/messaging-plan-session") = require("./onboard/messaging-plan-session"); const { getChannelsFromPlan } = messagingPlanSession; @@ -5197,9 +5191,7 @@ async function onboard(opts: OnboardOptions = {}): Promise { hydrateMessagingChannelConfig, messagingChannelConfigsEqual, getSandboxReuseState, - computeTelegramRequireMention, hasSandboxGpuDrift, - hasWechatConfigDrift, getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways, normalizeHermesToolGatewaySelections, stringSetsEqual, diff --git a/src/lib/onboard/docker-gpu-patch.ts b/src/lib/onboard/docker-gpu-patch.ts index 1a635f62e1..311b4843e3 100644 --- a/src/lib/onboard/docker-gpu-patch.ts +++ b/src/lib/onboard/docker-gpu-patch.ts @@ -529,11 +529,14 @@ export function shouldApplyDockerGpuPatch( const env = options.env ?? process.env; const platform = options.platform ?? process.platform; const dockerDriverGateway = options.dockerDriverGateway ?? platform === "linux"; - if (!(config.sandboxGpuEnabled && platform === "linux" && dockerDriverGateway)) { + const dockerDesktopWsl = options.dockerDesktopWsl === true; + if ( + !(config.sandboxGpuEnabled && (platform === "linux" || dockerDesktopWsl) && dockerDriverGateway) + ) { return false; } const optedOut = String(env.NEMOCLAW_DOCKER_GPU_PATCH || "").trim() === "0"; - if (optedOut && options.dockerDesktopWsl) { + if (optedOut && dockerDesktopWsl) { const log = options.log ?? ((message: string) => console.warn(message)); log( " NEMOCLAW_DOCKER_GPU_PATCH=0 ignored on Docker Desktop WSL: GPU passthrough on this runtime requires the patch.", diff --git a/src/lib/onboard/initial-policy.ts b/src/lib/onboard/initial-policy.ts index 34926309af..0ee5266fe1 100644 --- a/src/lib/onboard/initial-policy.ts +++ b/src/lib/onboard/initial-policy.ts @@ -5,6 +5,7 @@ import fs from "node:fs"; import path from "node:path"; import YAML from "yaml"; +import { getMessagingPolicyKeysByChannel } from "../messaging/channels"; import * as policies from "../policy"; import { requiredMessagingChannelPolicyPresets } from "./messaging-policy-presets"; import { requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; @@ -16,12 +17,7 @@ export type InitialSandboxPolicy = { cleanup?: () => boolean; }; -const HERMES_MESSAGING_POLICY_KEYS: Record = { - discord: ["discord"], - slack: ["slack"], - telegram: ["telegram"], - wechat: ["wechat_bridge"], -}; +const HERMES_MESSAGING_POLICY_KEYS = getMessagingPolicyKeysByChannel({ agent: "hermes" }); const PROC_PATH = "/proc"; const PROC_COMM_READ_WRITE_PATHS = ["/proc/self/comm", "/proc/self/task/*/comm"]; diff --git a/src/lib/onboard/machine/core-flow-phases.test.ts b/src/lib/onboard/machine/core-flow-phases.test.ts index 712c92ad84..19f16c04a7 100644 --- a/src/lib/onboard/machine/core-flow-phases.test.ts +++ b/src/lib/onboard/machine/core-flow-phases.test.ts @@ -144,9 +144,7 @@ function createPhases( hydrateMessagingChannelConfig: (config) => config, messagingChannelConfigsEqual: () => true, getSandboxReuseState: () => "missing", - computeTelegramRequireMention: () => null, hasSandboxGpuDrift: () => false, - hasWechatConfigDrift: () => false, getSandboxHermesToolGateways: () => [], normalizeHermesToolGatewaySelections: (value) => (Array.isArray(value) ? value : []), stringSetsEqual: (left, right) => diff --git a/src/lib/onboard/machine/handlers/sandbox.test.ts b/src/lib/onboard/machine/handlers/sandbox.test.ts index b7d936df72..cd3df4ea5d 100644 --- a/src/lib/onboard/machine/handlers/sandbox.test.ts +++ b/src/lib/onboard/machine/handlers/sandbox.test.ts @@ -136,9 +136,7 @@ function createDeps( hydrateMessagingChannelConfig: (config: MessagingChannelConfig | null) => config, messagingChannelConfigsEqual: () => true, getSandboxReuseState: () => "missing", - computeTelegramRequireMention: () => null, hasSandboxGpuDrift: () => false, - hasWechatConfigDrift: () => false, getSandboxHermesToolGateways: () => [], normalizeHermesToolGatewaySelections: (value: unknown) => Array.isArray(value) ? (value as string[]) : [], @@ -292,12 +290,14 @@ describe("handleSandboxState", () => { expect(result.selectedMessagingChannels).toEqual(["slack"]); }); - it("removes registry state when Telegram mention-mode drift forces sandbox recreation", async () => { - const session = createSession({ telegramConfig: { requireMention: true } }); + it("removes registry state when messaging config drift forces sandbox recreation", async () => { + const session = createSession(); session.steps.sandbox.status = "complete"; const { deps, calls } = createDeps({ getSandboxReuseState: () => "ready", - computeTelegramRequireMention: () => false, + getStoredMessagingChannelConfig: () => ({ TELEGRAM_REQUIRE_MENTION: "1" }), + hydrateMessagingChannelConfig: () => ({ TELEGRAM_REQUIRE_MENTION: "0" }), + messagingChannelConfigsEqual: () => false, }); await handleSandboxState({ @@ -307,7 +307,7 @@ describe("handleSandboxState", () => { }); expect(calls.note).toHaveBeenCalledWith( - " [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox.", + " [resume] Messaging channel configuration changed; recreating sandbox.", ); expect(calls.removeSandbox).toHaveBeenCalledWith("saved"); expect(calls.createSandbox).toHaveBeenCalled(); diff --git a/src/lib/onboard/machine/handlers/sandbox.ts b/src/lib/onboard/machine/handlers/sandbox.ts index 728afe0124..7b0697aca0 100644 --- a/src/lib/onboard/machine/handlers/sandbox.ts +++ b/src/lib/onboard/machine/handlers/sandbox.ts @@ -55,9 +55,7 @@ export interface SandboxStateOptions< right: MessagingChannelConfig | null, ): boolean; getSandboxReuseState(sandboxName: string | null): string; - computeTelegramRequireMention(): boolean | null; hasSandboxGpuDrift(sandboxName: string, config: SandboxGpuConfig): boolean; - hasWechatConfigDrift(session: Session | null): boolean; getSandboxHermesToolGateways(sandboxName: string): unknown; normalizeHermesToolGatewaySelections(value: unknown): string[]; stringSetsEqual(left: string[], right: string[]): boolean; @@ -140,10 +138,6 @@ export interface SandboxStateResult { stateResult: OnboardStateTransitionResult; } -function sameEffectiveTelegramRequireMention(left: boolean | null, right: boolean | null): boolean { - return (left ?? false) === (right ?? false); -} - function refreshCredentialHashesFromEnv(plan: SandboxMessagingPlan): { plan: SandboxMessagingPlan; changed: boolean; @@ -222,19 +216,9 @@ export async function handleSandboxState< const sandboxReuseState = deps.getSandboxReuseState(sandboxName); const webSearchConfigChanged = webSearchSupportDropped || Boolean(session?.webSearchConfig) !== Boolean(webSearchConfig); - const currentTelegramRequireMention = deps.computeTelegramRequireMention(); - const recordedTelegramRequireMention = session?.telegramConfig?.requireMention ?? null; - // Telegram mention-mode is baked into openclaw.json at sandbox build time. - // Compare effective modes because null and false both produce groupPolicy: open - // during config generation. This preserves the original #1737/#2417 drift rule. - const telegramConfigChanged = !sameEffectiveTelegramRequireMention( - currentTelegramRequireMention, - recordedTelegramRequireMention, - ); const sandboxGpuConfigChanged = sandboxName ? deps.hasSandboxGpuDrift(sandboxName, sandboxGpuConfig) : false; - const wechatConfigChanged = deps.hasWechatConfigDrift(session); const recordedHermesToolGateways = sandboxName ? deps.normalizeHermesToolGatewaySelections(deps.getSandboxHermesToolGateways(sandboxName)) : []; @@ -246,9 +230,7 @@ export async function handleSandboxState< resume && !resumeAgentChanged && !webSearchConfigChanged && - !telegramConfigChanged && !sandboxGpuConfigChanged && - !wechatConfigChanged && !messagingChannelConfigChanged && !hermesToolGatewayConfigChanged && session?.steps?.sandbox?.status === "complete" && @@ -267,15 +249,9 @@ export async function handleSandboxState< } else if (webSearchConfigChanged) { deps.note(" [resume] Web Search configuration changed; recreating sandbox."); if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); - } else if (telegramConfigChanged) { - deps.note(" [resume] TELEGRAM_REQUIRE_MENTION changed; recreating sandbox."); - if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); } else if (sandboxGpuConfigChanged) { deps.note(" [resume] Sandbox GPU settings changed; recreating sandbox."); if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); - } else if (wechatConfigChanged) { - deps.note(" [resume] WeChat account metadata changed; recreating sandbox."); - if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); } else if (messagingChannelConfigChanged) { deps.note(" [resume] Messaging channel configuration changed; recreating sandbox."); if (sandboxName) deps.removeSandboxFromRegistry(sandboxName); diff --git a/src/lib/onboard/messaging-config.test.ts b/src/lib/onboard/messaging-config.test.ts new file mode 100644 index 0000000000..5ca1312b71 --- /dev/null +++ b/src/lib/onboard/messaging-config.test.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { SandboxMessagingPlan } from "../messaging/manifest"; +import type { Session } from "../state/onboard-session"; +import { getStoredMessagingChannelConfig } from "./messaging-config"; + +describe("getStoredMessagingChannelConfig", () => { + it("uses legacy Telegram and WeChat session fields as read-only fallback", () => { + expect( + getStoredMessagingChannelConfig(null, { + telegramConfig: { requireMention: true }, + wechatConfig: { + accountId: "wechat-account", + baseUrl: "https://wechat.example", + userId: "wechat-user", + }, + } as Session), + ).toEqual({ + TELEGRAM_REQUIRE_MENTION: "1", + WECHAT_ACCOUNT_ID: "wechat-account", + WECHAT_BASE_URL: "https://wechat.example", + WECHAT_USER_ID: "wechat-user", + }); + }); + + it("prefers messaging plan config over legacy session fields", () => { + expect( + getStoredMessagingChannelConfig(null, { + telegramConfig: { requireMention: true }, + messagingPlan: makePlan(), + } as Session), + ).toEqual({ + TELEGRAM_REQUIRE_MENTION: "0", + }); + }); +}); + +function makePlan(): SandboxMessagingPlan { + return { + schemaVersion: 1, + sandboxName: "demo", + agent: "openclaw", + workflow: "onboard", + channels: [ + { + channelId: "telegram", + displayName: "Telegram", + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [ + { + channelId: "telegram", + inputId: "requireMention", + kind: "config", + required: false, + sourceEnv: "TELEGRAM_REQUIRE_MENTION", + statePath: "telegramConfig.requireMention", + value: "0", + }, + ], + hooks: [], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { + presets: [], + entries: [], + }, + agentRender: [], + buildSteps: [], + stateUpdates: [ + { + channelId: "telegram", + kind: "rebuild-hydration", + statePath: "telegramConfig.requireMention", + env: "TELEGRAM_REQUIRE_MENTION", + }, + ], + healthChecks: [], + }; +} diff --git a/src/lib/onboard/messaging-config.ts b/src/lib/onboard/messaging-config.ts index b4d01d43a6..390f3f3604 100644 --- a/src/lib/onboard/messaging-config.ts +++ b/src/lib/onboard/messaging-config.ts @@ -1,25 +1,14 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { getMessagingChannelConfigFromPlan } from "../messaging/plan-validation"; import { type MessagingChannelConfig, mergeMessagingChannelConfigs, } from "../messaging-channel-config"; -import { getMessagingChannelConfigFromPlan } from "../messaging/plan-validation"; import type { Session } from "../state/onboard-session"; import * as registry from "../state/registry"; -// Read TELEGRAM_REQUIRE_MENTION (set either by the interactive mention prompt -// or by the user's shell) and map it to a boolean, or null when the env var -// is unset / invalid. Used at resume time to detect drift against the recorded -// session state. See #1737 and the CodeRabbit follow-up on #2417. -export function computeTelegramRequireMention(): boolean | null { - const raw = process.env.TELEGRAM_REQUIRE_MENTION; - if (raw === "1") return true; - if (raw === "0") return false; - return null; -} - export function getStoredMessagingChannelConfig( sandboxName: string | null, session: Session | null, @@ -34,7 +23,10 @@ export function getStoredMessagingChannelConfig( const sessionConfig = sessionMatchesSandbox ? getMessagingChannelConfigFromPlan(session?.messagingPlan) : null; - return mergeMessagingChannelConfigs(registryConfig, sessionConfig); + const legacySessionConfig = sessionMatchesSandbox + ? getLegacySessionMessagingChannelConfig(session) + : null; + return mergeMessagingChannelConfigs(legacySessionConfig, registryConfig, sessionConfig); } export function messagingChannelConfigsEqual( @@ -46,3 +38,16 @@ export function messagingChannelConfigsEqual( if (leftKeys.length !== rightKeys.length) return false; return leftKeys.every((key, index) => key === rightKeys[index] && left?.[key] === right?.[key]); } + +function getLegacySessionMessagingChannelConfig( + session: Session | null, +): MessagingChannelConfig | null { + const config: MessagingChannelConfig = {}; + if (typeof session?.telegramConfig?.requireMention === "boolean") { + config.TELEGRAM_REQUIRE_MENTION = session.telegramConfig.requireMention ? "1" : "0"; + } + if (session?.wechatConfig?.accountId) config.WECHAT_ACCOUNT_ID = session.wechatConfig.accountId; + if (session?.wechatConfig?.baseUrl) config.WECHAT_BASE_URL = session.wechatConfig.baseUrl; + if (session?.wechatConfig?.userId) config.WECHAT_USER_ID = session.wechatConfig.userId; + return Object.keys(config).length > 0 ? config : null; +} diff --git a/src/lib/onboard/messaging-conflict-guard.ts b/src/lib/onboard/messaging-conflict-guard.ts index 7199333f58..11489c6089 100644 --- a/src/lib/onboard/messaging-conflict-guard.ts +++ b/src/lib/onboard/messaging-conflict-guard.ts @@ -120,7 +120,7 @@ async function enforceMessagingPreEnableHooks( deps: MessagingConflictGuardDeps, currentPlan: SandboxMessagingPlan, ): Promise { - const requests = MessagingSetupApplier.listHookRequests(currentPlan, "pre-enable"); + const requests = MessagingSetupApplier.listPreEnableChecks(currentPlan); if (requests.length === 0) return; const hookRegistry = createBuiltInMessagingHookRegistry(); @@ -131,7 +131,7 @@ async function enforceMessagingPreEnableHooks( }); try { - await MessagingSetupApplier.applyHooksForPhase(currentPlan, "pre-enable", { + await MessagingSetupApplier.applyPreEnableChecks(currentPlan, { additionalInputs, runHook: (request) => runMessagingHook( diff --git a/src/lib/onboard/messaging-credentials.ts b/src/lib/onboard/messaging-credentials.ts index 9f336ad6b8..0264ccf0b3 100644 --- a/src/lib/onboard/messaging-credentials.ts +++ b/src/lib/onboard/messaging-credentials.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { normalizeCredentialValue } from "../credentials/store"; +import { getMessagingChannelForCredentialEnvKey } from "../messaging/channels"; import { hashCredential } from "../security/credential-hash"; import * as registry from "../state/registry"; @@ -45,11 +46,7 @@ export function getRecordedMessagingChannelsForResume({ } export function getMessagingChannelForEnvKey(envKey: string): string | null { - if (envKey === "DISCORD_BOT_TOKEN") return "discord"; - if (envKey === "SLACK_BOT_TOKEN") return "slack"; - if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; - if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; - return null; + return getMessagingChannelForCredentialEnvKey(envKey); } /** diff --git a/src/lib/onboard/messaging-policy-presets.ts b/src/lib/onboard/messaging-policy-presets.ts index 7b1384d5eb..6b68fc6549 100644 --- a/src/lib/onboard/messaging-policy-presets.ts +++ b/src/lib/onboard/messaging-policy-presets.ts @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL: Record = { - slack: ["slack"], -}; +import { listRequiredCreateTimeMessagingPolicyPresetsByChannel } from "../messaging/channels"; + +const REQUIRED_POLICY_PRESETS_BY_MESSAGING_CHANNEL = + listRequiredCreateTimeMessagingPolicyPresetsByChannel(); function normalizedNames(values: string[] | null | undefined): string[] { if (!Array.isArray(values)) return []; diff --git a/src/lib/onboard/messaging-prep.test.ts b/src/lib/onboard/messaging-prep.test.ts index b5beeae629..cbc28d332d 100644 --- a/src/lib/onboard/messaging-prep.test.ts +++ b/src/lib/onboard/messaging-prep.test.ts @@ -5,8 +5,8 @@ import { describe, expect, it, vi } from "vitest"; import { BRAVE_API_KEY_ENV } from "../../../dist/lib/inference/web-search"; import { - prepareCreateSandboxMessaging, type CreateSandboxMessagingPrepInput, + prepareCreateSandboxMessaging, } from "../../../dist/lib/onboard/messaging-prep"; import { listChannels } from "../../../dist/lib/sandbox/channels"; @@ -31,6 +31,7 @@ function createInput( getMessagingChannelForEnvKey: (envKey) => { if (envKey === "DISCORD_BOT_TOKEN") return "discord"; if (envKey === "SLACK_BOT_TOKEN") return "slack"; + if (envKey === "SLACK_APP_TOKEN") return "slack"; if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; if (envKey === "WECHAT_BOT_TOKEN") return "wechat"; return null; @@ -141,10 +142,10 @@ describe("prepareCreateSandboxMessaging", () => { }), ); - expect(result.messagingTokenDefs.map(({ envKey }) => envKey)).toEqual([ + expect([...result.messagingTokenDefs.map(({ envKey }) => envKey)].sort()).toEqual([ "DISCORD_BOT_TOKEN", - "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", + "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", "WECHAT_BOT_TOKEN", ]); diff --git a/src/lib/onboard/messaging-prep.ts b/src/lib/onboard/messaging-prep.ts index b10a37a44d..1c59fd84e8 100644 --- a/src/lib/onboard/messaging-prep.ts +++ b/src/lib/onboard/messaging-prep.ts @@ -1,9 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import * as webSearch from "../inference/web-search"; import type { WebSearchConfig } from "../inference/web-search"; -import { getChannelTokenKeys, type ChannelDef } from "../sandbox/channels"; +import * as webSearch from "../inference/web-search"; +import { listMessagingCredentialMetadata } from "../messaging/channels"; +import { type ChannelDef, getChannelTokenKeys } from "../sandbox/channels"; import * as braveProviderProfile from "./brave-provider-profile"; export type NamedMessagingChannel = { name: string } & ChannelDef; @@ -46,14 +47,6 @@ export interface CreateSandboxMessagingPrepResult { missingBraveApiKey: boolean; } -const STATIC_MESSAGING_PROVIDER_ENVS = [ - ["discord-bridge", "DISCORD_BOT_TOKEN"], - ["slack-bridge", "SLACK_BOT_TOKEN"], - ["slack-app", "SLACK_APP_TOKEN"], - ["telegram-bridge", "TELEGRAM_BOT_TOKEN"], - ["wechat-bridge", "WECHAT_BOT_TOKEN"], -] as const; - export function prepareCreateSandboxMessaging( input: CreateSandboxMessagingPrepInput, ): CreateSandboxMessagingPrepResult { @@ -73,13 +66,12 @@ export function prepareCreateSandboxMessaging( .flatMap((c) => getChannelTokenKeys(c)), ); - const messagingTokenDefs: MessagingTokenDef[] = STATIC_MESSAGING_PROVIDER_ENVS.map( - ([suffix, envKey]) => ({ - name: `${input.sandboxName}-${suffix}`, - envKey, - token: input.getValidatedMessagingTokenByEnvKey(input.channels, envKey), - }), - ) + const messagingTokenDefs: MessagingTokenDef[] = listMessagingCredentialMetadata() + .map((credential) => ({ + name: credential.providerNameTemplate.replaceAll("{sandboxName}", input.sandboxName), + envKey: credential.providerEnvKey, + token: input.getValidatedMessagingTokenByEnvKey(input.channels, credential.providerEnvKey), + })) .filter(({ envKey }) => !enabledEnvKeys || enabledEnvKeys.has(envKey)) .filter(({ envKey }) => !disabledEnvKeys.has(envKey)); @@ -123,8 +115,7 @@ export function prepareCreateSandboxMessaging( if (input.enabledChannels != null) { for (const { name, envKey, token } of messagingTokenDefs) { if (token) continue; - const channel = - envKey === "SLACK_APP_TOKEN" ? "slack" : input.getMessagingChannelForEnvKey(envKey); + const channel = input.getMessagingChannelForEnvKey(envKey); if (!channel || !input.enabledChannels.includes(channel)) continue; if (!input.providerExistsInGateway(name)) continue; reusableMessagingProviders.push(name); diff --git a/src/lib/onboard/messaging-reuse.ts b/src/lib/onboard/messaging-reuse.ts index 5ebd2735dc..cd4047ffef 100644 --- a/src/lib/onboard/messaging-reuse.ts +++ b/src/lib/onboard/messaging-reuse.ts @@ -1,17 +1,15 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -type MessagingChannel = { name: string; envKey: string }; +import { listMessagingProviderNamesForChannel } from "../messaging/channels"; + +type MessagingChannel = { name: string; envKey?: string }; export function getMessagingProviderNamesForChannel( sandboxName: string, channel: string, ): string[] { - if (channel === "discord") return [`${sandboxName}-discord-bridge`]; - if (channel === "telegram") return [`${sandboxName}-telegram-bridge`]; - if (channel === "wechat") return [`${sandboxName}-wechat-bridge`]; - if (channel === "slack") return [`${sandboxName}-slack-bridge`, `${sandboxName}-slack-app`]; - return []; + return listMessagingProviderNamesForChannel(sandboxName, channel); } function getKnownMessagingChannels( @@ -42,7 +40,7 @@ export function getNonInteractiveStoredMessagingChannels( if ( resume || !sandboxName || - messagingChannels.some((channel) => hasMessagingToken(channel.envKey)) + messagingChannels.some((channel) => channel.envKey && hasMessagingToken(channel.envKey)) ) { return null; } diff --git a/src/lib/onboard/policy-presets.ts b/src/lib/onboard/policy-presets.ts index 1d31cc045c..3c612ecb03 100644 --- a/src/lib/onboard/policy-presets.ts +++ b/src/lib/onboard/policy-presets.ts @@ -3,10 +3,15 @@ import { getCredential } from "../credentials/store"; import type { WebSearchConfig } from "../inference/web-search"; +import { + listMessagingCredentialMetadata, + listMessagingPolicyPresetMetadata, +} from "../messaging/channels"; const { LOCAL_INFERENCE_PROVIDERS } = require("./providers") as { LOCAL_INFERENCE_PROVIDERS: string[]; }; + import { isOpenclawAgent, requiredOpenclawOtelPolicyPresets } from "./openclaw-otel-policy-presets"; export interface SuggestedPolicyPresetOptions { @@ -38,25 +43,38 @@ export function getSuggestedPolicyPresets({ const usesExplicitMessagingSelection = Array.isArray(enabledChannels); const nonInteractive = isNonInteractive?.() ?? process.env.NEMOCLAW_NON_INTERACTIVE === "1"; - const maybeSuggestMessagingPreset = (channel: string, envKey: string | null): void => { + const credentialsByChannel = new Map(); + for (const credential of listMessagingCredentialMetadata()) { + if (!credentialsByChannel.has(credential.channelId)) { + credentialsByChannel.set(credential.channelId, credential.providerEnvKey); + } + } + + const maybeSuggestMessagingPreset = ( + channel: string, + preset: string, + envKey: string | null, + ): void => { if (usesExplicitMessagingSelection) { - if (enabledChannels.includes(channel)) suggestions.push(channel); + if (enabledChannels.includes(channel)) suggestions.push(preset); return; } if (envKey === null) return; if (getCredential(envKey) || process.env[envKey]) { - suggestions.push(channel); + suggestions.push(preset); if (process.stdout.isTTY && !nonInteractive && process.env.CI !== "true") { - console.log(` Auto-detected: ${envKey} -> suggesting ${channel} preset`); + console.log(` Auto-detected: ${envKey} -> suggesting ${preset} preset`); } } }; - maybeSuggestMessagingPreset("telegram", "TELEGRAM_BOT_TOKEN"); - maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); - maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); - maybeSuggestMessagingPreset("wechat", "WECHAT_BOT_TOKEN"); - maybeSuggestMessagingPreset("whatsapp", null); + for (const preset of listMessagingPolicyPresetMetadata()) { + maybeSuggestMessagingPreset( + preset.channelId, + preset.presetName, + credentialsByChannel.get(preset.channelId) ?? null, + ); + } if (webSearchConfig) suggestions.push("brave"); diff --git a/src/lib/onboard/sandbox-build-patch-config.test.ts b/src/lib/onboard/sandbox-build-patch-config.test.ts index 41e7e32978..beabcde3fd 100644 --- a/src/lib/onboard/sandbox-build-patch-config.test.ts +++ b/src/lib/onboard/sandbox-build-patch-config.test.ts @@ -3,109 +3,38 @@ import { describe, expect, it, vi } from "vitest"; -import type { Session } from "../state/onboard-session"; import { prepareSandboxBuildPatchConfig } from "./sandbox-build-patch-config"; describe("prepareSandboxBuildPatchConfig", () => { - it("reads build-time messaging config and persists session snapshots", () => { - const updateSession = vi.fn((mutator: (session: Session) => Session | void) => { - const current = {} as Session; - return mutator(current) ?? current; - }); - const readMessagingChannelConfigFromEnv = vi.fn(); - - const result = prepareSandboxBuildPatchConfig({ - configuredMessagingChannels: ["telegram", "slack"], - env: { - TELEGRAM_ALLOWED_IDS: "123,456", - SLACK_ALLOWED_USERS: "U01ABC2DEF3", - SLACK_ALLOWED_CHANNELS: "C012AB3CD,C987ZY6XW", - WECHAT_ALLOWED_IDS: "wxid-unused", - }, - deps: { - readMessagingChannelConfigFromEnv, - computeTelegramRequireMention: vi.fn(() => true), - loadSession: vi.fn(() => ({ wechatConfig: { accountId: "old" } }) as Session), - gatherWechatConfig: vi.fn(() => ({ - accountId: "acct", - baseUrl: "https://wechat.example", - userId: "wxid-user", - })), - updateSession, - }, - }); - - expect(readMessagingChannelConfigFromEnv).toHaveBeenCalledWith({ + it("validates build-time messaging config without writing legacy session snapshots", () => { + const readMessagingChannelConfigFromEnv = vi.fn(() => ({ + TELEGRAM_ALLOWED_IDS: "123,456", + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + SLACK_ALLOWED_CHANNELS: "C012AB3CD,C987ZY6XW", + })); + const env = { TELEGRAM_ALLOWED_IDS: "123,456", SLACK_ALLOWED_USERS: "U01ABC2DEF3", SLACK_ALLOWED_CHANNELS: "C012AB3CD,C987ZY6XW", WECHAT_ALLOWED_IDS: "wxid-unused", - }); - expect(result.telegramConfig).toEqual({ requireMention: true }); - expect(result.wechatConfig).toEqual({ - accountId: "acct", - baseUrl: "https://wechat.example", - userId: "wxid-user", - }); - expect(updateSession).toHaveReturnedWith({ - telegramConfig: { requireMention: true }, - wechatConfig: { - accountId: "acct", - baseUrl: "https://wechat.example", - userId: "wxid-user", - }, - }); - }); - - it("clears optional persisted config when no active token config is present", () => { - const computeTelegramRequireMention = vi.fn(() => true); - const updateSession = vi.fn((mutator: (session: Session) => Session | void) => { - const current = { - telegramConfig: { requireMention: true }, - wechatConfig: { accountId: "stale" }, - } as unknown as Session; - return mutator(current) ?? current; - }); + }; const result = prepareSandboxBuildPatchConfig({ - configuredMessagingChannels: [], + configuredMessagingChannels: ["telegram", "slack"], + env, deps: { - readMessagingChannelConfigFromEnv: vi.fn(() => null), - computeTelegramRequireMention, - loadSession: vi.fn(() => null), - gatherWechatConfig: vi.fn(() => ({})), - updateSession, + readMessagingChannelConfigFromEnv, }, }); - expect(result.telegramConfig).toEqual({}); - expect(result.wechatConfig).toEqual({}); - expect(computeTelegramRequireMention).not.toHaveBeenCalled(); - expect(updateSession).toHaveReturnedWith({ - telegramConfig: null, - wechatConfig: null, - }); - }); - - it("uses configured channel membership for Telegram mention config", () => { - const computeTelegramRequireMention = vi.fn(() => true); - - const result = prepareSandboxBuildPatchConfig({ - configuredMessagingChannels: ["telegram"], - deps: { - readMessagingChannelConfigFromEnv: vi.fn(() => null), - computeTelegramRequireMention, - loadSession: vi.fn(() => null), - gatherWechatConfig: vi.fn(() => ({})), - updateSession: vi.fn((mutator: (session: Session) => Session | void) => { - const current = {} as Session; - return mutator(current) ?? current; - }), + expect(readMessagingChannelConfigFromEnv).toHaveBeenCalledWith(env); + expect(result).toEqual({ + messagingChannelConfig: { + TELEGRAM_ALLOWED_IDS: "123,456", + SLACK_ALLOWED_USERS: "U01ABC2DEF3", + SLACK_ALLOWED_CHANNELS: "C012AB3CD,C987ZY6XW", }, }); - - expect(result.telegramConfig).toEqual({ requireMention: true }); - expect(computeTelegramRequireMention).toHaveBeenCalledOnce(); }); it("keeps messaging authorization parsing delegated to the central env reader", () => { @@ -115,15 +44,6 @@ describe("prepareSandboxBuildPatchConfig", () => { env: { TELEGRAM_ALLOWED_IDS: "123\n456", } as NodeJS.ProcessEnv, - deps: { - computeTelegramRequireMention: vi.fn(() => null), - loadSession: vi.fn(() => null), - gatherWechatConfig: vi.fn(() => ({})), - updateSession: vi.fn((mutator: (session: Session) => Session | void) => { - const current = {} as Session; - return mutator(current) ?? current; - }), - }, }), ).toThrow("Messaging channel config values must not contain line breaks."); }); diff --git a/src/lib/onboard/sandbox-build-patch-config.ts b/src/lib/onboard/sandbox-build-patch-config.ts index 4a1abc5df5..ea86a2ccaa 100644 --- a/src/lib/onboard/sandbox-build-patch-config.ts +++ b/src/lib/onboard/sandbox-build-patch-config.ts @@ -1,32 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { readMessagingChannelConfigFromEnv } from "../messaging-channel-config"; -import * as onboardSession from "../state/onboard-session"; -import type { Session } from "../state/onboard-session"; -import { computeTelegramRequireMention } from "./messaging-config"; import { - gatherWechatConfig, - toSessionWechatConfig, - type WechatConfigSnapshot, -} from "./wechat-config"; - -type TelegramConfig = { requireMention?: boolean }; + type MessagingChannelConfig, + readMessagingChannelConfigFromEnv, +} from "../messaging-channel-config"; export type SandboxBuildPatchConfig = { - telegramConfig: TelegramConfig; - wechatConfig: WechatConfigSnapshot; + messagingChannelConfig: MessagingChannelConfig | null; }; export type SandboxBuildPatchConfigDeps = { - readMessagingChannelConfigFromEnv?(env?: NodeJS.ProcessEnv): unknown; - computeTelegramRequireMention?(): boolean | null; - loadSession?(): Session | null; - gatherWechatConfig?(session: Session | null): WechatConfigSnapshot; - toSessionWechatConfig?( - cfg: WechatConfigSnapshot, - ): { accountId?: string; baseUrl?: string; userId?: string } | null; - updateSession?(mutator: (session: Session) => Session | void): Session; + readMessagingChannelConfigFromEnv?(env?: NodeJS.ProcessEnv): MessagingChannelConfig | null; }; export type PrepareSandboxBuildPatchConfigInput = { @@ -36,38 +21,16 @@ export type PrepareSandboxBuildPatchConfigInput = { }; export function prepareSandboxBuildPatchConfig({ - configuredMessagingChannels, env = process.env, deps = {}, }: PrepareSandboxBuildPatchConfigInput): SandboxBuildPatchConfig { // Dockerfile messaging rendering is sourced from the manifest plan. Reading - // env config here validates operator-provided channel config before build. - (deps.readMessagingChannelConfigFromEnv ?? readMessagingChannelConfigFromEnv)(env); - const configuredChannelNames = new Set(configuredMessagingChannels); - - const telegramConfig: TelegramConfig = {}; - if (configuredChannelNames.has("telegram")) { - const telegramRequireMention = ( - deps.computeTelegramRequireMention ?? computeTelegramRequireMention - )(); - if (telegramRequireMention !== null) { - telegramConfig.requireMention = telegramRequireMention; - } - } - - const loadSession = deps.loadSession ?? onboardSession.loadSession; - const wechatConfig = (deps.gatherWechatConfig ?? gatherWechatConfig)(loadSession()); - (deps.updateSession ?? onboardSession.updateSession)((current) => { - current.telegramConfig = - typeof telegramConfig.requireMention === "boolean" - ? { requireMention: telegramConfig.requireMention } - : null; - current.wechatConfig = (deps.toSessionWechatConfig ?? toSessionWechatConfig)(wechatConfig); - return current; - }); - + // env config here validates operator-provided channel config before build; + // durable replay lives in SandboxEntry.messaging.plan. + const messagingChannelConfig = ( + deps.readMessagingChannelConfigFromEnv ?? readMessagingChannelConfigFromEnv + )(env); return { - telegramConfig, - wechatConfig, + messagingChannelConfig, }; } diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts index b55be67559..b4a630f3ce 100644 --- a/src/lib/onboard/sandbox-create-plan.ts +++ b/src/lib/onboard/sandbox-create-plan.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { listMessagingCredentialMetadata } from "../messaging/channels"; import type { InitialSandboxPolicy } from "./initial-policy"; import type { MessagingChannel } from "./messaging-state"; import { resolveQrSelectedChannels } from "./messaging-state"; @@ -87,9 +88,7 @@ function resolveActiveMessagingChannels({ | "messagingTokenDefs" | "reusableMessagingChannels" >): string[] { - const tokensByEnvKey = Object.fromEntries( - messagingTokenDefs.map(({ envKey, token }) => [envKey, token]), - ); + const primaryCredentialEnvKeys = getPrimaryCredentialEnvKeys(); const qrSelectedChannels = resolveQrSelectedChannels( channels, enabledChannels, @@ -101,12 +100,7 @@ function resolveActiveMessagingChannels({ .filter(({ token }) => !!token) .flatMap(({ envKey }) => { const channel = getMessagingChannelForEnvKey(envKey); - if (channel) return [channel]; - // SLACK_APP_TOKEN alone does not enable slack; bot token is required. - if (envKey === "SLACK_APP_TOKEN") { - return tokensByEnvKey["SLACK_BOT_TOKEN"] ? ["slack"] : []; - } - return []; + return channel && primaryCredentialEnvKeys.has(envKey) ? [channel] : []; }), ...reusableMessagingChannels, ...qrSelectedChannels, @@ -114,6 +108,17 @@ function resolveActiveMessagingChannels({ ]; } +function getPrimaryCredentialEnvKeys(): Set { + const seenChannels = new Set(); + const envKeys = new Set(); + for (const credential of listMessagingCredentialMetadata()) { + if (seenChannels.has(credential.channelId)) continue; + seenChannels.add(credential.channelId); + envKeys.add(credential.providerEnvKey); + } + return envKeys; +} + export function prepareSandboxCreatePlan({ basePolicyPath, buildCtx, diff --git a/src/lib/onboard/sandbox-provider-cleanup.ts b/src/lib/onboard/sandbox-provider-cleanup.ts index a98ff581cc..cbc441b4ac 100644 --- a/src/lib/onboard/sandbox-provider-cleanup.ts +++ b/src/lib/onboard/sandbox-provider-cleanup.ts @@ -1,6 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { listMessagingProviderSuffixes } from "../messaging/channels"; import { NAME_MAX_LENGTH, NAME_VALID_PATTERN } from "../name-validation"; export type SandboxProviderRunOpenshell = ( @@ -34,15 +35,11 @@ export type SandboxRecreateCleanupDeps = DetachSandboxProvidersDeps & { }; export const SANDBOX_PROVIDER_SUFFIXES = [ - "telegram-bridge", - "discord-bridge", - "slack-bridge", - "slack-app", - "wechat-bridge", + ...listMessagingProviderSuffixes().map((suffix) => suffix.replace(/^-/, "")), "brave-search", -] as const; +] as readonly string[]; -export type SandboxProviderSuffix = (typeof SANDBOX_PROVIDER_SUFFIXES)[number]; +export type SandboxProviderSuffix = string; const TOLERATED_DETACH_OUTPUT_RE = /\bNotAttached\b|\bnot\s+attached\b|provider[^\n]{0,200}?(?:\bNotFound\b|\bnot\s+found\b)/i; diff --git a/src/lib/onboard/wechat-config.ts b/src/lib/onboard/wechat-config.ts deleted file mode 100644 index 07f045abf9..0000000000 --- a/src/lib/onboard/wechat-config.ts +++ /dev/null @@ -1,72 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { normalizeCredentialValue } from "../credentials/store"; -import type { Session } from "../state/onboard-session"; - -export interface WechatConfigSnapshot { - accountId?: string; - baseUrl?: string; - userId?: string; -} - -/** - * Read WeChat per-account metadata. Prefers fresh values from - * `process.env` (set by the host-qr handler this run, or by - * `rebuildSandbox`'s env-stash); falls back to the recorded session for - * the resume case where `setupMessagingChannels` short-circuits the - * host-qr handler because the bot token is already cached. - * - * Non-secret — the bot token lives in the OpenShell provider, not here. - * The metadata is serialized into the messaging manifest plan so the - * `wechat.seedOpenClawAccount` post-agent-install hook can write - * `/openclaw-weixin/accounts/.json` at image-build time. - */ -export function gatherWechatConfig(session: Session | null): WechatConfigSnapshot { - const cfg: WechatConfigSnapshot = {}; - const accountId = normalizeCredentialValue(process.env.WECHAT_ACCOUNT_ID || ""); - const baseUrl = normalizeCredentialValue(process.env.WECHAT_BASE_URL || ""); - const userId = normalizeCredentialValue(process.env.WECHAT_USER_ID || ""); - if (accountId) cfg.accountId = accountId; - if (baseUrl) cfg.baseUrl = baseUrl; - if (userId) cfg.userId = userId; - if (Object.keys(cfg).length === 0 && session?.wechatConfig) { - if (session.wechatConfig.accountId) cfg.accountId = session.wechatConfig.accountId; - if (session.wechatConfig.baseUrl) cfg.baseUrl = session.wechatConfig.baseUrl; - if (session.wechatConfig.userId) cfg.userId = session.wechatConfig.userId; - } - return cfg; -} - -/** - * Detect WeChat account drift on resume: a fresh host-qr login (or env - * stash) produced an accountId/baseUrl/userId triple that differs from - * what was recorded in the session. Forces a sandbox recreate because - * the per-account base URL is baked into `openclaw.json` at build time — - * an unchanged image would keep talking to the previous IDC host. - */ -export function hasWechatConfigDrift(session: Session | null): boolean { - const recorded = session?.wechatConfig ?? null; - const accountId = normalizeCredentialValue(process.env.WECHAT_ACCOUNT_ID || ""); - if (!accountId) return false; - const baseUrl = normalizeCredentialValue(process.env.WECHAT_BASE_URL || ""); - const userId = normalizeCredentialValue(process.env.WECHAT_USER_ID || ""); - return ( - (recorded?.accountId ?? "") !== accountId || - (recorded?.baseUrl ?? "") !== baseUrl || - (recorded?.userId ?? "") !== userId - ); -} - -/** - * Build the `Session.wechatConfig` payload for `updateSession`. Returns - * `null` when the snapshot has no fields so the session field stays - * normalized (matches `parseWechatConfig`'s null-on-empty contract). - */ -export function toSessionWechatConfig( - cfg: WechatConfigSnapshot, -): { accountId?: string; baseUrl?: string; userId?: string } | null { - return Object.keys(cfg).length > 0 - ? { accountId: cfg.accountId, baseUrl: cfg.baseUrl, userId: cfg.userId } - : null; -} diff --git a/src/lib/policy/index.ts b/src/lib/policy/index.ts index b347d8ed94..354270d053 100644 --- a/src/lib/policy/index.ts +++ b/src/lib/policy/index.ts @@ -3,7 +3,13 @@ // // Policy preset management — list, load, merge, and apply presets. -import type { JsonValue, JsonObject } from "../core/json-types"; +import type { JsonObject, JsonValue } from "../core/json-types"; +import { + getMessagingPolicyKeyAliases, + getMessagingPolicyPresetValidationWarnings, + listBuiltInMessagingChannelManifests, + listMessagingPolicyPresetMetadata, +} from "../messaging/channels"; const fs = require("fs"); const path = require("path"); @@ -108,9 +114,8 @@ function parsePresetPolicyKeys(presetContent: string | null | undefined): string return Object.keys(parseNetworkPolicies(`network_policies:\n${presetEntries}`) || {}); } -const AGENT_PRESET_KEY_ALIASES: Record = { - wechat: ["wechat_bridge"], -}; +const AGENT_PRESET_KEY_ALIASES: Readonly> = + getMessagingPolicyKeyAliases(); function selectAgentPolicyKeys( agentPolicies: PolicyObject, @@ -203,13 +208,17 @@ function getPresetEndpoints(content: string): string[] { * having enabled the channel opens the firewall but leaves the sandbox * without a running bridge. See #1691. */ -const MESSAGING_PRESET_LABELS: Record = { - telegram: "Telegram", - discord: "Discord", - slack: "Slack", - wechat: "WeChat", - whatsapp: "WhatsApp", -}; +const MESSAGING_PRESET_LABELS: Readonly> = Object.fromEntries( + listMessagingPolicyPresetMetadata().flatMap((preset) => { + const manifest = listBuiltInMessagingChannelManifests().find( + (entry) => entry.id === preset.channelId, + ); + return manifest ? [[preset.presetName, manifest.displayName]] : []; + }), +); + +const MESSAGING_PRESET_VALIDATION_WARNING_LINES: Readonly> = + getMessagingPolicyPresetValidationWarnings(); function getPresetValidationWarning(presetName: string): string | null { if (presetName === "jira") { @@ -237,17 +246,7 @@ function getPresetValidationWarning(presetName: string): string | null { "configuration are wired up at onboard time and are not added by applying", "this preset alone.", ]; - - if (presetName === "discord") { - lines.push( - "For Discord preset validation, do not use curl as the success signal:", - "curl is not in the preset binary allowlist, so curl probes can fail even", - "when the policy is working. Use Node HTTPS against", - "https://discord.com/api/v10/gateway or validate the configured", - 'messaging bridge/gateway path. DNS-only checks such as dns.resolve("gateway.discord.gg")', - "can also be inconclusive behind a proxy.", - ); - } + lines.push(...(MESSAGING_PRESET_VALIDATION_WARNING_LINES[presetName] ?? [])); return lines.join("\n "); } @@ -1297,35 +1296,35 @@ function applyPermissivePolicy(sandboxName: string): void { } export { - PRESETS_DIR, - PERMISSIVE_POLICY_PATH, - listPresets, - loadPreset, + applyPermissivePolicy, + applyPreset, + applyPresetContent, + applyPresets, + assertOpenshellResolvable, + buildPolicyGetCommand, + buildPolicySetCommand, + clampSetupPolicyPresetNames, + extractPresetEntries, + filterSetupPolicyPresets, + getAppliedPresets, + getGatewayPresets, getPresetEndpoints, getPresetValidationWarning, - setupPolicyPresetSupported, - filterSetupPolicyPresets, + listCustomPresets, + listPresets, listSetupPolicyPresets, - clampSetupPolicyPresetNames, - extractPresetEntries, - parsePresetPolicyKeys, - parseCurrentPolicy, - buildPolicySetCommand, - buildPolicyGetCommand, - assertOpenshellResolvable, + loadPreset, + loadPresetFromFile, mergePresetIntoPolicy, mergePresetNamesIntoPolicy, - removePresetFromPolicy, - applyPreset, - applyPresets, - applyPresetContent, - loadPresetFromFile, + PERMISSIVE_POLICY_PATH, + PRESETS_DIR, + parseCurrentPolicy, + parsePresetPolicyKeys, removePreset, - applyPermissivePolicy, + removePresetFromPolicy, resolvePermissivePolicyPath, - getAppliedPresets, - getGatewayPresets, - listCustomPresets, - selectFromList, selectForRemoval, + selectFromList, + setupPolicyPresetSupported, }; diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 176fd84795..479385ea78 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -3,15 +3,16 @@ import { describe, expect, it } from "vitest"; +import { slackManifest } from "../messaging/channels"; import { - KNOWN_CHANNELS, + type ChannelDef, channelHasStaticToken, channelUsesInSandboxQrPairing, getChannelDef, getChannelTokenKeys, + KNOWN_CHANNELS, knownChannelNames, listChannels, - type ChannelDef, } from "./channels"; describe("sandbox-channels KNOWN_CHANNELS", () => { @@ -82,6 +83,24 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { expect(slack?.channelIdHelp).toContain("Slack channel IDs"); }); + it("derives Slack credential metadata from the channel manifest", () => { + const slack = getChannelDef("slack"); + const botTokenInput = slackManifest.inputs.find((input) => input.id === "botToken"); + const appTokenInput = slackManifest.inputs.find((input) => input.id === "appToken"); + expect(slack?.envKey).toBe(slackManifest.credentials[0]?.providerEnvKey); + expect(slack?.label).toBe(botTokenInput?.prompt?.label); + expect(slack?.help).toBe(botTokenInput?.prompt?.help); + expect(slack?.tokenFormatHint).toBe(botTokenInput?.formatHint); + expect(slack?.tokenFormat?.test("xoxb-valid_TOKEN-123")).toBe(true); + expect(slack?.tokenFormat?.test("xapp-invalid-token")).toBe(false); + expect(slack?.appTokenEnvKey).toBe(slackManifest.credentials[1]?.providerEnvKey); + expect(slack?.appTokenLabel).toBe(appTokenInput?.prompt?.label); + expect(slack?.appTokenHelp).toBe(appTokenInput?.prompt?.help); + expect(slack?.appTokenFormatHint).toBe(appTokenInput?.formatHint); + expect(slack?.appTokenFormat?.test("xapp-valid_TOKEN-123")).toBe(true); + expect(slack?.appTokenFormat?.test("xoxb-invalid-token")).toBe(false); + }); + it("normalises case and whitespace when resolving a channel name", () => { expect(getChannelDef(" Telegram ")).toBe(KNOWN_CHANNELS.telegram); expect(getChannelDef("DISCORD")).toBe(KNOWN_CHANNELS.discord); diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 7316989546..7962cc8b90 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { deleteCredential, saveCredential } from "../credentials/store"; +import { listBuiltInMessagingChannelManifests } from "../messaging/channels"; +import type { + ChannelCredentialSpec, + ChannelInputSpec, + ChannelManifest, +} from "../messaging/manifest"; export interface ChannelBase { description: string; @@ -55,83 +61,12 @@ export interface InSandboxQrChannelDef extends ChannelBase { export type ChannelDef = CredentialBackedChannelDef | InSandboxQrChannelDef; -export const KNOWN_CHANNELS: Record = { - telegram: { - envKey: "TELEGRAM_BOT_TOKEN", - description: "Telegram bot messaging", - help: "Create a bot via @BotFather on Telegram, then copy the token.", - label: "Telegram Bot Token", - setupNotes: [ - "For Telegram group chats, disable privacy mode in @BotFather (/setprivacy -> your bot -> Disable).", - "After changing privacy mode, remove and re-add the bot to each group before testing @mentions.", - ], - userIdEnvKey: "TELEGRAM_ALLOWED_IDS", - userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", - userIdLabel: "Telegram User ID (for DM access)", - allowIdsMode: "dm", - requireMentionEnvKey: "TELEGRAM_REQUIRE_MENTION", - requireMentionHelp: - "Controls Telegram group-chat behavior only — reply only when @mentioned vs. to all group messages. Direct messages are unaffected by this setting and remain subject to pairing and TELEGRAM_ALLOWED_IDS.", - }, - discord: { - envKey: "DISCORD_BOT_TOKEN", - description: "Discord bot messaging", - help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", - label: "Discord Bot Token", - serverIdEnvKey: "DISCORD_SERVER_ID", - serverIdHelp: - "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", - serverIdLabel: "Discord Server ID (for guild workspace access)", - requireMentionEnvKey: "DISCORD_REQUIRE_MENTION", - requireMentionHelp: - "Choose whether the bot should reply only when @mentioned or to all messages in this server.", - userIdEnvKey: "DISCORD_USER_ID", - userIdHelp: - "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", - userIdLabel: "Discord User ID (optional guild allowlist)", - allowIdsMode: "guild", - }, - wechat: { - envKey: "WECHAT_BOT_TOKEN", - description: "WeChat (personal) bot messaging", - help: "Captured automatically via a host-side QR scan during onboard — pair the bot by scanning the QR with WeChat on your phone (Discover → Scan). DM-only.", - label: "WeChat Bot Token", - userIdEnvKey: "WECHAT_ALLOWED_IDS", - userIdHelp: - "Optional: restrict who can DM the bot. The WeChat user id of the operator who scanned is added automatically; supply additional ids as a comma-separated list.", - userIdLabel: "WeChat User ID(s) (DM allowlist)", - allowIdsMode: "dm", - loginMethod: "host-qr", - }, - slack: { - envKey: "SLACK_BOT_TOKEN", - description: "Slack bot messaging", - help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", - label: "Slack Bot Token", - tokenFormat: /^xoxb-[A-Za-z0-9_-]+$/, - tokenFormatHint: "Slack bot tokens start with 'xoxb-' (e.g. xoxb-1234-5678-abcdef).", - appTokenEnvKey: "SLACK_APP_TOKEN", - appTokenHelp: "Slack API → Your Apps → Basic Information → App-Level Tokens (xapp-...).", - appTokenLabel: "Slack App Token (Socket Mode)", - appTokenFormat: /^xapp-[A-Za-z0-9_-]+$/, - appTokenFormatHint: "Slack app tokens start with 'xapp-' (e.g. xapp-1-A0000-12345-abcdef).", - userIdEnvKey: "SLACK_ALLOWED_USERS", - userIdHelp: - "In Slack, open each allowed human user's profile -> More -> Copy member ID. Enter one or more comma-separated member IDs, not the app or bot user ID. Member IDs look like U01ABC2DEF3.", - userIdLabel: "Slack Member IDs (comma-separated allowlist)", - allowIdsMode: "dm", - channelIdEnvKey: "SLACK_ALLOWED_CHANNELS", - channelIdHelp: - "Optional: enter comma-separated Slack channel IDs where the bot may answer @mentions. Channel IDs look like C012AB3CD.", - channelIdLabel: "Slack Channel IDs (comma-separated allowlist)", - }, - whatsapp: { - description: "WhatsApp Web messaging (QR pairing)", - help: "WhatsApp Web pairs via QR code scanned with your phone — no host-side token. After the sandbox is running, connect to it (e.g. `openshell sandbox connect `) and run `openclaw channels login --channel whatsapp` for OpenClaw or `hermes whatsapp` for Hermes. NemoClaw renders the OpenClaw QR in compact (scan-friendly) form and validates the gateway before pairing, so a gateway close (e.g. `1008`) is reported separately from the QR (issue #4522).", - label: "WhatsApp", - loginMethod: "in-sandbox-qr", - }, -}; +export const KNOWN_CHANNELS: Record = Object.fromEntries( + listBuiltInMessagingChannelManifests().map((manifest) => [ + manifest.id, + channelDefFromManifest(manifest), + ]), +); export function getChannelDef(name: string): ChannelDef | undefined { return KNOWN_CHANNELS[name.trim().toLowerCase()]; @@ -171,3 +106,125 @@ export function clearChannelTokens(channel: ChannelDef): void { deleteCredential(key); } } + +function channelDefFromManifest(manifest: ChannelManifest): ChannelDef { + const credentials = manifest.credentials; + const primaryCredential = credentials[0]; + const primaryInput = primaryCredential + ? findInput(manifest, primaryCredential.sourceInput) + : undefined; + const appCredential = credentials[1]; + const appInput = appCredential ? findInput(manifest, appCredential.sourceInput) : undefined; + const base: ChannelBase = { + description: manifest.description ?? `${manifest.displayName} messaging`, + help: + primaryInput?.prompt?.help ?? + manifest.enrollmentHelp ?? + manifest.description ?? + `${manifest.displayName} messaging`, + label: primaryInput?.prompt?.label ?? manifest.displayName, + ...(manifest.enrollmentNotes ? { setupNotes: manifest.enrollmentNotes } : {}), + ...(manifest.auth.mode === "in-sandbox-qr" ? {} : configFieldMetadata(manifest)), + }; + + if (manifest.auth.mode === "in-sandbox-qr") { + return { + ...base, + loginMethod: "in-sandbox-qr", + }; + } + + return { + ...base, + ...(manifest.auth.mode === "host-qr" ? { loginMethod: "host-qr" as const } : {}), + ...(primaryCredential ? credentialFieldMetadata(primaryCredential, primaryInput) : {}), + ...(appCredential ? appCredentialFieldMetadata(appCredential, appInput) : {}), + }; +} + +function credentialFieldMetadata( + credential: ChannelCredentialSpec, + input: ChannelInputSpec | undefined, +): Pick { + const tokenFormat = input?.formatPattern ? safeRegExp(input.formatPattern) : undefined; + return { + envKey: credential.providerEnvKey, + ...(tokenFormat ? { tokenFormat } : {}), + ...(input?.formatHint ? { tokenFormatHint: input.formatHint } : {}), + }; +} + +function appCredentialFieldMetadata( + credential: ChannelCredentialSpec, + input: ChannelInputSpec | undefined, +): Pick< + CredentialBackedChannelDef, + "appTokenEnvKey" | "appTokenHelp" | "appTokenLabel" | "appTokenFormat" | "appTokenFormatHint" +> { + const appTokenFormat = input?.formatPattern ? safeRegExp(input.formatPattern) : undefined; + return { + appTokenEnvKey: credential.providerEnvKey, + ...(input?.prompt?.help ? { appTokenHelp: input.prompt.help } : {}), + ...(input?.prompt?.label ? { appTokenLabel: input.prompt.label } : {}), + ...(appTokenFormat ? { appTokenFormat } : {}), + ...(input?.formatHint ? { appTokenFormatHint: input.formatHint } : {}), + }; +} + +function configFieldMetadata(manifest: ChannelManifest): Partial { + const metadata: Partial = {}; + const allowedUsers = findFirstInput(manifest, ["allowedUsers", "allowedIds", "userId"]); + if (allowedUsers?.envKey) { + metadata.userIdEnvKey = allowedUsers.envKey; + metadata.allowIdsMode = inferAllowIdsMode(allowedUsers); + if (allowedUsers.prompt?.help) metadata.userIdHelp = allowedUsers.prompt.help; + if (allowedUsers.prompt?.label) metadata.userIdLabel = allowedUsers.prompt.label; + } + + const allowedChannels = findInput(manifest, "allowedChannels"); + if (allowedChannels?.envKey) { + metadata.channelIdEnvKey = allowedChannels.envKey; + if (allowedChannels.prompt?.help) metadata.channelIdHelp = allowedChannels.prompt.help; + if (allowedChannels.prompt?.label) metadata.channelIdLabel = allowedChannels.prompt.label; + } + + const serverId = findInput(manifest, "serverId"); + if (serverId?.envKey) { + metadata.serverIdEnvKey = serverId.envKey; + if (serverId.prompt?.help) metadata.serverIdHelp = serverId.prompt.help; + if (serverId.prompt?.label) metadata.serverIdLabel = serverId.prompt.label; + } + + const requireMention = findInput(manifest, "requireMention"); + if (requireMention?.envKey) { + metadata.requireMentionEnvKey = requireMention.envKey; + if (requireMention.prompt?.help) metadata.requireMentionHelp = requireMention.prompt.help; + } + + return metadata; +} + +function findInput(manifest: ChannelManifest, inputId: string): ChannelInputSpec | undefined { + return manifest.inputs.find((input) => input.id === inputId); +} + +function findFirstInput( + manifest: ChannelManifest, + inputIds: readonly string[], +): ChannelInputSpec | undefined { + return inputIds + .map((inputId) => findInput(manifest, inputId)) + .find((input): input is ChannelInputSpec => Boolean(input)); +} + +function inferAllowIdsMode(input: ChannelInputSpec): ChannelBase["allowIdsMode"] { + return input.statePath?.startsWith("discordGuilds.") ? "guild" : "dm"; +} + +function safeRegExp(pattern: string): RegExp | undefined { + try { + return new RegExp(pattern); + } catch { + return undefined; + } +} diff --git a/src/lib/security/redact.ts b/src/lib/security/redact.ts index 8503e6b834..14171cf303 100644 --- a/src/lib/security/redact.ts +++ b/src/lib/security/redact.ts @@ -16,7 +16,25 @@ * Ref: https://github.com/NVIDIA/NemoClaw/issues/2381 */ -import { TOKEN_PREFIX_PATTERNS, SECRET_PATTERNS } from "./secret-patterns"; +import { listMessagingCredentialMetadata } from "../messaging/channels"; +import { SECRET_PATTERNS, TOKEN_PREFIX_PATTERNS } from "./secret-patterns"; + +const SENSITIVE_ENV_ASSIGNMENT_KEYS = [ + "NVIDIA_API_KEY", + "NOUS_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_API_KEY", + "BRAVE_API_KEY", + ...listMessagingCredentialMetadata().map((credential) => credential.providerEnvKey), +]; + +const SENSITIVE_ENV_ASSIGNMENT_PATTERN = new RegExp( + `(${SENSITIVE_ENV_ASSIGNMENT_KEYS.map(escapeRegExp).join("|")})=\\S+`, + "gi", +); // ── Partial redaction (runner.ts style) ───────────────────────── @@ -107,10 +125,7 @@ export function redactFull(text: string): string { export function redactSensitiveText(value: unknown): string | null { if (typeof value !== "string") return null; let result = value - .replace( - /(NVIDIA_API_KEY|NOUS_API_KEY|OPENAI_API_KEY|ANTHROPIC_API_KEY|GEMINI_API_KEY|COMPATIBLE_API_KEY|COMPATIBLE_ANTHROPIC_API_KEY|BRAVE_API_KEY|SLACK_BOT_TOKEN|SLACK_APP_TOKEN|DISCORD_BOT_TOKEN|TELEGRAM_BOT_TOKEN)=\S+/gi, - "$1=", - ) + .replace(SENSITIVE_ENV_ASSIGNMENT_PATTERN, "$1=") .replace(/Bearer\s+\S+/gi, "Bearer "); for (const pattern of TOKEN_PREFIX_PATTERNS) { pattern.lastIndex = 0; @@ -119,6 +134,10 @@ export function redactSensitiveText(value: unknown): string | null { return result.slice(0, 240); } +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + export function redactUrl(value: unknown): string | null { if (typeof value !== "string" || value.length === 0) return null; try { diff --git a/test/channels-add-preset.test.ts b/test/channels-add-preset.test.ts index 5dc34829e0..15b1f7bcf7 100644 --- a/test/channels-add-preset.test.ts +++ b/test/channels-add-preset.test.ts @@ -1589,10 +1589,14 @@ const processRecovery = require(${j("actions/sandbox/process-recovery.js")}); const execCalls = []; processRecovery.executeSandboxExecCommand = (name, command) => { execCalls.push({ name, command }); - if (typeof command === "string" && command.startsWith("cat /sandbox/.openclaw/openclaw.json")) { + if (typeof command === "string" && command.includes("/sandbox/.openclaw/openclaw.json")) { return { status: 0, stdout: JSON.stringify(global.__testConfig || {}), stderr: "" }; } - if (typeof command === "string" && command.indexOf("tail -n 400 /tmp/gateway.log") !== -1) { + if ( + typeof command === "string" && + command.includes("tail -n 400") && + command.includes("/tmp/gateway.log") + ) { return { status: 0, stdout: global.__testLog || "", stderr: "" }; } return { status: 0, stdout: "", stderr: "" }; @@ -1698,9 +1702,7 @@ const ctx = module.exports; assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); const payload = parseResultPayload(result); assert.ok( - payload.logs.some((line: string) => - line.includes("was not marked enabled in baked openclaw.json"), - ), + payload.logs.some((line: string) => line.includes("was not marked enabled in baked")), `expected enabled-flag warning; got:\n${payload.logs.join("\n")}`, ); }); @@ -1798,9 +1800,7 @@ const ctx = module.exports; const payload = parseResultPayload(result); assert.equal(payload.execCalls, 0, "verifier must not run any sandbox exec probes for Hermes"); assert.ok( - !payload.logs.some((line: string) => - line.includes("was not marked enabled in baked openclaw.json"), - ), + !payload.logs.some((line: string) => line.includes("was not marked enabled in baked")), `Hermes sandbox should not see OpenClaw-shaped warning; got:\n${payload.logs.join("\n")}`, ); assert.ok( diff --git a/test/cli/connect-recovery.test.ts b/test/cli/connect-recovery.test.ts index 3a8b28e318..cd0c8f5b1e 100644 --- a/test/cli/connect-recovery.test.ts +++ b/test/cli/connect-recovery.test.ts @@ -1,10 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { execTimeout, @@ -455,81 +455,87 @@ describe("CLI dispatch", () => { }, ); - it("connect --probe-only falls back to SSH when sandbox exec times out after starting", () => { - const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-probe-exec-timeout-")); - const localBin = path.join(home, "bin"); - const openshellCalls = path.join(home, "openshell-calls"); - const sshCalls = path.join(home, "ssh-calls"); - const stateFile = path.join(home, "probe-state"); - fs.mkdirSync(localBin, { recursive: true }); - writeSandboxRegistry(home); - fs.writeFileSync(stateFile, "stopped"); - fs.writeFileSync( - path.join(localBin, "openshell"), - [ - "#!/usr/bin/env bash", - `calls=${JSON.stringify(openshellCalls)}`, - 'printf \'%s\\n\' "$*" >> "$calls"', - 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', - " echo 'Sandbox:'", - " echo", - " echo ' Id: abc'", - " echo ' Name: alpha'", - " echo ' Namespace: openshell'", - " echo ' Phase: Ready'", - " exit 0", - "fi", - 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ]; then', - " echo '__NEMOCLAW_SANDBOX_EXEC_STARTED__'", - " sleep 1", - " exit 0", - "fi", - 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "alpha" ]; then', - " echo 'Host openshell-alpha'", - " echo ' HostName 127.0.0.1'", - " echo ' User sandbox'", - " exit 0", - "fi", - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - fs.writeFileSync( - path.join(localBin, "ssh"), - [ - "#!/usr/bin/env bash", - `calls=${JSON.stringify(sshCalls)}`, - `state_file=${JSON.stringify(stateFile)}`, - 'cmd="${@: -1}"', - 'printf \'CMD %s\\n\' "$cmd" >> "$calls"', - 'if [[ "$cmd" == *"OPENCLAW="* ]]; then', - ' echo recovered > "$state_file"', - " echo 'GATEWAY_PID=789'", - " exit 0", - "fi", - 'if [[ "$cmd" == *"curl -so"* ]]; then', - ' if [ "$(cat "$state_file")" = recovered ]; then echo RUNNING; else echo STOPPED; fi', - " exit 0", - "fi", - "exit 1", - ].join("\n"), - { mode: 0o755 }, - ); + it( + "connect --probe-only falls back to SSH when sandbox exec times out after starting", + testTimeoutOptions(15_000), + () => { + const home = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-cli-connect-probe-exec-timeout-"), + ); + const localBin = path.join(home, "bin"); + const openshellCalls = path.join(home, "openshell-calls"); + const sshCalls = path.join(home, "ssh-calls"); + const stateFile = path.join(home, "probe-state"); + fs.mkdirSync(localBin, { recursive: true }); + writeSandboxRegistry(home); + fs.writeFileSync(stateFile, "stopped"); + fs.writeFileSync( + path.join(localBin, "openshell"), + [ + "#!/usr/bin/env bash", + `calls=${JSON.stringify(openshellCalls)}`, + 'printf \'%s\\n\' "$*" >> "$calls"', + 'if [ "$1" = "sandbox" ] && [ "$2" = "get" ] && [ "$3" = "alpha" ]; then', + " echo 'Sandbox:'", + " echo", + " echo ' Id: abc'", + " echo ' Name: alpha'", + " echo ' Namespace: openshell'", + " echo ' Phase: Ready'", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ]; then', + " echo '__NEMOCLAW_SANDBOX_EXEC_STARTED__'", + " sleep 1", + " exit 0", + "fi", + 'if [ "$1" = "sandbox" ] && [ "$2" = "ssh-config" ] && [ "$3" = "alpha" ]; then', + " echo 'Host openshell-alpha'", + " echo ' HostName 127.0.0.1'", + " echo ' User sandbox'", + " exit 0", + "fi", + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + fs.writeFileSync( + path.join(localBin, "ssh"), + [ + "#!/usr/bin/env bash", + `calls=${JSON.stringify(sshCalls)}`, + `state_file=${JSON.stringify(stateFile)}`, + 'cmd="${@: -1}"', + 'printf \'CMD %s\\n\' "$cmd" >> "$calls"', + 'if [[ "$cmd" == *"OPENCLAW="* ]]; then', + ' echo recovered > "$state_file"', + " echo 'GATEWAY_PID=789'", + " exit 0", + "fi", + 'if [[ "$cmd" == *"curl -so"* ]]; then', + ' if [ "$(cat "$state_file")" = recovered ]; then echo RUNNING; else echo STOPPED; fi', + " exit 0", + "fi", + "exit 1", + ].join("\n"), + { mode: 0o755 }, + ); - const r = runWithEnv("alpha connect --probe-only", { - HOME: home, - PATH: `${localBin}:${process.env.PATH || ""}`, - NEMOCLAW_SANDBOX_EXEC_TIMEOUT_MS: "50", - }); + const r = runWithEnv("alpha connect --probe-only", { + HOME: home, + PATH: `${localBin}:${process.env.PATH || ""}`, + NEMOCLAW_SANDBOX_EXEC_TIMEOUT_MS: "50", + }); - expect(r.code).toBe(0); - expect(r.out).toContain("Probe complete: recovered OpenClaw gateway"); - const openshellLog = fs.readFileSync(openshellCalls, "utf8"); - const sshLog = fs.readFileSync(sshCalls, "utf8"); - expect(openshellLog).toContain("sandbox exec --name alpha -- sh -c"); - expect(openshellLog).toContain("sandbox ssh-config alpha"); - expect(sshLog).toContain('OPENCLAW="$(command -v openclaw)"'); - }); + expect(r.code).toBe(0); + expect(r.out).toContain("Probe complete: recovered OpenClaw gateway"); + const openshellLog = fs.readFileSync(openshellCalls, "utf8"); + const sshLog = fs.readFileSync(sshCalls, "utf8"); + expect(openshellLog).toContain("sandbox exec --name alpha -- sh -c"); + expect(openshellLog).toContain("sandbox ssh-config alpha"); + expect(sshLog).toContain('OPENCLAW="$(command -v openclaw)"'); + }, + ); it("recovers non-OpenClaw agents over SSH instead of root sandbox exec", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-probe-agent-")); @@ -620,7 +626,7 @@ describe("CLI dispatch", () => { expect(sshLog).not.toMatch(/(^|\s)-tt?(\s|$)/); }); - it("waits for sandbox readiness before connecting", () => { + it("waits for sandbox readiness before connecting", testTimeoutOptions(15_000), () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-connect-wait-")); const localBin = path.join(home, "bin"); const registryDir = path.join(home, ".nemoclaw"); diff --git a/test/cli/destroy-detach-order.test.ts b/test/cli/destroy-detach-order.test.ts index caf248bdb6..4c20122eef 100644 --- a/test/cli/destroy-detach-order.test.ts +++ b/test/cli/destroy-detach-order.test.ts @@ -72,9 +72,9 @@ describe("CLI dispatch", () => { const expectedDetachLines = [ "sandbox provider detach alpha alpha-telegram-bridge", "sandbox provider detach alpha alpha-discord-bridge", + "sandbox provider detach alpha alpha-wechat-bridge", "sandbox provider detach alpha alpha-slack-bridge", "sandbox provider detach alpha alpha-slack-app", - "sandbox provider detach alpha alpha-wechat-bridge", "sandbox provider detach alpha alpha-brave-search", ]; for (const line of expectedDetachLines) { diff --git a/test/cli/doctor-gateway-token.test.ts b/test/cli/doctor-gateway-token.test.ts index 94ba686edb..48fa9448ad 100644 --- a/test/cli/doctor-gateway-token.test.ts +++ b/test/cli/doctor-gateway-token.test.ts @@ -1,11 +1,11 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import { spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { createCloudflaredServiceDir, @@ -89,7 +89,7 @@ function parseJsonPrefix(output: string): T { } describe("CLI dispatch", () => { - it("gateway-token help uses native oclif usage", () => { + it("gateway-token help uses native oclif usage", testTimeoutOptions(15_000), () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-token-help-")); writeSandboxRegistry(home); @@ -100,7 +100,7 @@ describe("CLI dispatch", () => { expect(r.out).toContain("Print the OpenClaw gateway auth token"); }); - it("doctor fails a present sandbox that is not Ready", () => { + it("doctor fails a present sandbox that is not Ready", testTimeoutOptions(15_000), () => { const setup = createDoctorTestSetup("nemoclaw-cli-doctor-not-ready-", [ 'case "$*" in', ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', @@ -125,203 +125,227 @@ describe("CLI dispatch", () => { ); }); - it("doctor does not inspect the legacy k3s gateway container in Docker-driver mode", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-docker-driver-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - // Docker-driver sandbox: no legacy `openshell-cluster-*` container exists. - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "docker" }); - // Record docker argv and make `docker inspect` fail like an absent legacy - // container would. The doctor must not even attempt the inspect, so this - // should never produce a failure — and we assert the call was skipped, not - // merely that its failure was tolerated. - const dockerCalls = path.join(setup.home, "docker-calls"); - fs.writeFileSync( - path.join(setup.localBin, "docker"), - [ - "#!/usr/bin/env bash", - `printf '%s\\n' "$*" >> ${JSON.stringify(dockerCalls)}`, - 'if [ "$1" = "info" ]; then echo "24.0.0"; exit 0; fi', - 'if [ "$1" = "inspect" ]; then echo "Error: No such object: $3" >&2; exit 1; fi', - "exit 0", - ].join("\n"), - { mode: 0o755 }, - ); - // Healthy curl so the unrelated provider-health probe does not fail the - // report and mask the gateway-only assertions below. - fs.writeFileSync( - path.join(setup.localBin, "curl"), - ["#!/usr/bin/env bash", 'echo "{}"', "exit 0"].join("\n"), - { mode: 0o755 }, - ); + it( + "doctor does not inspect the legacy k3s gateway container in Docker-driver mode", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-docker-driver-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ]); + // Docker-driver sandbox: no legacy `openshell-cluster-*` container exists. + writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "docker" }); + // Record docker argv and make `docker inspect` fail like an absent legacy + // container would. The doctor must not even attempt the inspect, so this + // should never produce a failure — and we assert the call was skipped, not + // merely that its failure was tolerated. + const dockerCalls = path.join(setup.home, "docker-calls"); + fs.writeFileSync( + path.join(setup.localBin, "docker"), + [ + "#!/usr/bin/env bash", + `printf '%s\\n' "$*" >> ${JSON.stringify(dockerCalls)}`, + 'if [ "$1" = "info" ]; then echo "24.0.0"; exit 0; fi', + 'if [ "$1" = "inspect" ]; then echo "Error: No such object: $3" >&2; exit 1; fi', + "exit 0", + ].join("\n"), + { mode: 0o755 }, + ); + // Healthy curl so the unrelated provider-health probe does not fail the + // report and mask the gateway-only assertions below. + fs.writeFileSync( + path.join(setup.localBin, "curl"), + ["#!/usr/bin/env bash", 'echo "{}"', "exit 0"].join("\n"), + { mode: 0o755 }, + ); - const r = setup.runDoctor("alpha doctor --json"); + const r = setup.runDoctor("alpha doctor --json"); - expect(r.out).not.toContain("openshell-cluster"); - const report = JSON.parse(r.out) as { - status: string; - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); - // Core contract: the legacy k3s container inspect must be skipped entirely, - // not attempted-and-ignored. - const recordedDockerCalls = fs.existsSync(dockerCalls) - ? fs.readFileSync(dockerCalls, "utf8") - : ""; - expect(recordedDockerCalls).not.toMatch(/\binspect\b/); - const openshellStatus = report.checks.find((check) => check.label === "OpenShell status"); - expect(openshellStatus).toEqual( - expect.objectContaining({ group: "Gateway", status: "ok", detail: "connected to nemoclaw" }), - ); - // The Docker-driver gateway is healthy, so no Gateway check should fail. - expect(report.checks.filter((c) => c.group === "Gateway" && c.status === "fail")).toEqual([]); - expect(report.status).toBe("ok"); - expect(r.code).toBe(0); - }); + expect(r.out).not.toContain("openshell-cluster"); + const report = JSON.parse(r.out) as { + status: string; + checks: Array<{ group: string; label: string; status: string; detail: string }>; + }; + expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); + // Core contract: the legacy k3s container inspect must be skipped entirely, + // not attempted-and-ignored. + const recordedDockerCalls = fs.existsSync(dockerCalls) + ? fs.readFileSync(dockerCalls, "utf8") + : ""; + expect(recordedDockerCalls).not.toMatch(/\binspect\b/); + const openshellStatus = report.checks.find((check) => check.label === "OpenShell status"); + expect(openshellStatus).toEqual( + expect.objectContaining({ + group: "Gateway", + status: "ok", + detail: "connected to nemoclaw", + }), + ); + // The Docker-driver gateway is healthy, so no Gateway check should fail. + expect(report.checks.filter((c) => c.group === "Gateway" && c.status === "fail")).toEqual([]); + expect(report.status).toBe("ok"); + expect(r.code).toBe(0); + }, + ); - it("doctor still inspects the legacy k3s gateway container for the kubernetes driver", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-k8s-driver-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); + it( + "doctor still inspects the legacy k3s gateway container for the kubernetes driver", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-k8s-driver-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ]); + writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); - const r = setup.runDoctor("alpha doctor --json"); + const r = setup.runDoctor("alpha doctor --json"); - const report = JSON.parse(r.out) as { - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - const dockerContainer = report.checks.find((check) => check.label === "Docker container"); - expect(dockerContainer).toEqual( - expect.objectContaining({ - group: "Gateway", - status: "ok", - detail: expect.stringContaining("openshell-cluster-nemoclaw"), - }), - ); - }); + const report = JSON.parse(r.out) as { + checks: Array<{ group: string; label: string; status: string; detail: string }>; + }; + const dockerContainer = report.checks.find((check) => check.label === "Docker container"); + expect(dockerContainer).toEqual( + expect.objectContaining({ + group: "Gateway", + status: "ok", + detail: expect.stringContaining("openshell-cluster-nemoclaw"), + }), + ); + }, + ); - it("doctor accepts a local openshell-gateway process when legacy inspect fails", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-local-gateway-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); + it( + "doctor accepts a local openshell-gateway process when legacy inspect fails", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-local-gateway-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ]); + writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); - const hostCalls = path.join(setup.home, "host-calls"); - writeDockerInspectFailureStub(setup, hostCalls); - writeLocalGatewayProbeStubs(setup, hostCalls); - writeHealthyCurlStub(setup); + const hostCalls = path.join(setup.home, "host-calls"); + writeDockerInspectFailureStub(setup, hostCalls); + writeLocalGatewayProbeStubs(setup, hostCalls); + writeHealthyCurlStub(setup); - const r = setup.runDoctor("alpha doctor --json"); + const r = setup.runDoctor("alpha doctor --json"); - expect(r.code).toBe(0); - const report = JSON.parse(r.out) as { - status: string; - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); - expect(report.checks.find((check) => check.label === "Local gateway process")).toEqual( - expect.objectContaining({ - group: "Gateway", - status: "ok", - detail: "openshell-gateway is running, listening on port 8080, and verified by OpenShell", - }), - ); - expect( - report.checks.filter((check) => check.group === "Gateway" && check.status === "fail"), - ).toEqual([]); - expect(report.status).toBe("ok"); - - const calls = fs.readFileSync(hostCalls, "utf8"); - expect(calls).toContain("pgrep:-f ^(/[^ ]*/)?openshell-gateway( |$)"); - expect(calls).not.toContain("pgrep:-af openshell-gateway"); - expect(calls).not.toContain("docker:port"); - }); + expect(r.code).toBe(0); + const report = JSON.parse(r.out) as { + status: string; + checks: Array<{ group: string; label: string; status: string; detail: string }>; + }; + expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); + expect(report.checks.find((check) => check.label === "Local gateway process")).toEqual( + expect.objectContaining({ + group: "Gateway", + status: "ok", + detail: "openshell-gateway is running, listening on port 8080, and verified by OpenShell", + }), + ); + expect( + report.checks.filter((check) => check.group === "Gateway" && check.status === "fail"), + ).toEqual([]); + expect(report.status).toBe("ok"); + + const calls = fs.readFileSync(hostCalls, "utf8"); + expect(calls).toContain("pgrep:-f ^(/[^ ]*/)?openshell-gateway( |$)"); + expect(calls).not.toContain("pgrep:-af openshell-gateway"); + expect(calls).not.toContain("docker:port"); + }, + ); - it("doctor treats local gateway process evidence as informational until OpenShell verifies the named gateway", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-unverified-local-gateway-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "gateway select nemoclaw") exit 1 ;;', - ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', - "esac", - ]); - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); - const hostCalls = path.join(setup.home, "host-calls"); - writeDockerInspectFailureStub(setup, hostCalls); - writeLocalGatewayProbeStubs(setup, hostCalls); + it( + "doctor treats local gateway process evidence as informational until OpenShell verifies the named gateway", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-unverified-local-gateway-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "gateway select nemoclaw") exit 1 ;;', + ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', + ' "sandbox list") echo "should not query sandbox list" >> "$marker_file"; exit 0 ;;', + "esac", + ]); + writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); + const hostCalls = path.join(setup.home, "host-calls"); + writeDockerInspectFailureStub(setup, hostCalls); + writeLocalGatewayProbeStubs(setup, hostCalls); - const r = setup.runDoctor("alpha doctor --json"); + const r = setup.runDoctor("alpha doctor --json"); - expect(r.code).toBe(1); - const report = parseJsonPrefix<{ - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }>(r.out); - expect(report.checks.find((check) => check.label === "Local gateway process")).toEqual( - expect.objectContaining({ - group: "Gateway", - status: "info", - detail: - "openshell-gateway process and port 8080 are present, but the named gateway is not verified", - }), - ); - expect(report.checks.find((check) => check.label === "OpenShell status")).toEqual( - expect.objectContaining({ group: "Gateway", status: "fail" }), - ); - }); + expect(r.code).toBe(1); + const report = parseJsonPrefix<{ + checks: Array<{ group: string; label: string; status: string; detail: string }>; + }>(r.out); + expect(report.checks.find((check) => check.label === "Local gateway process")).toEqual( + expect.objectContaining({ + group: "Gateway", + status: "info", + detail: + "openshell-gateway process and port 8080 are present, but the named gateway is not verified", + }), + ); + expect(report.checks.find((check) => check.label === "OpenShell status")).toEqual( + expect.objectContaining({ group: "Gateway", status: "fail" }), + ); + }, + ); - it("doctor reports unavailable local gateway probe tools while trusting a verified named gateway", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-missing-local-probe-tools-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', - ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', - "esac", - ]); - writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); - const hostCalls = path.join(setup.home, "host-calls"); - writeDockerInspectFailureStub(setup, hostCalls); - writeLocalGatewayProbeStubs(setup, hostCalls, { - pgrepUnavailable: true, - ssUnavailable: true, - }); - writeHealthyCurlStub(setup); + it( + "doctor reports unavailable local gateway probe tools while trusting a verified named gateway", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-missing-local-probe-tools-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: nemoclaw\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "sandbox list") printf "NAME STATUS\\nalpha Ready\\n"; exit 0 ;;', + ' "inference get") printf "Provider: nvidia-prod\\nModel: test-model\\n"; exit 0 ;;', + "esac", + ]); + writeSandboxRegistry(setup.home, "alpha", { openshellDriver: "kubernetes" }); + const hostCalls = path.join(setup.home, "host-calls"); + writeDockerInspectFailureStub(setup, hostCalls); + writeLocalGatewayProbeStubs(setup, hostCalls, { + pgrepUnavailable: true, + ssUnavailable: true, + }); + writeHealthyCurlStub(setup); - const r = setup.runDoctor("alpha doctor --json"); + const r = setup.runDoctor("alpha doctor --json"); - expect(r.code).toBe(0); - const report = JSON.parse(r.out) as { - status: string; - checks: Array<{ group: string; label: string; status: string; detail: string }>; - }; - expect(report.checks.find((check) => check.label === "Local gateway probe")).toEqual( - expect.objectContaining({ - group: "Gateway", - status: "info", - detail: - "local probe skipped (pgrep, ss unavailable); OpenShell reports the named gateway connected", - }), - ); - expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); - expect(report.status).toBe("ok"); - }); + expect(r.code).toBe(0); + const report = JSON.parse(r.out) as { + status: string; + checks: Array<{ group: string; label: string; status: string; detail: string }>; + }; + expect(report.checks.find((check) => check.label === "Local gateway probe")).toEqual( + expect.objectContaining({ + group: "Gateway", + status: "info", + detail: + "local probe skipped (pgrep, ss unavailable); OpenShell reports the named gateway connected", + }), + ); + expect(report.checks.find((check) => check.label === "Docker container")).toBeUndefined(); + expect(report.status).toBe("ok"); + }, + ); it( "doctor reports fresh shields state as not configured instead of down", @@ -352,26 +376,30 @@ describe("CLI dispatch", () => { }, ); - it("doctor does not query sandbox state from a different active gateway", () => { - const setup = createDoctorTestSetup("nemoclaw-cli-doctor-wrong-gateway-", [ - 'case "$*" in', - ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', - ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', - ' "gateway select nemoclaw") exit 1 ;;', - ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', - ' "sandbox list") echo "queried wrong gateway sandbox list" >> "$marker_file"; exit 0 ;;', - "esac", - ]); + it( + "doctor does not query sandbox state from a different active gateway", + testTimeoutOptions(15_000), + () => { + const setup = createDoctorTestSetup("nemoclaw-cli-doctor-wrong-gateway-", [ + 'case "$*" in', + ' "status") printf "Server Status\\n\\n Gateway: other\\n Status: Connected\\n"; exit 0 ;;', + ' "gateway info -g nemoclaw") printf "Gateway: nemoclaw\\n"; exit 0 ;;', + ' "gateway select nemoclaw") exit 1 ;;', + ' "gateway start --name nemoclaw --port 8080") exit 1 ;;', + ' "sandbox list") echo "queried wrong gateway sandbox list" >> "$marker_file"; exit 0 ;;', + "esac", + ]); - const r = setup.runDoctor("alpha doctor"); + const r = setup.runDoctor("alpha doctor"); - expect(r.code).toBe(1); - expect(r.out).toContain("OpenShell status"); - expect(r.out).toContain("Gateway: other"); - expect(setup.readCalls().some((call) => /^sandbox list(\s|$)/.test(call))).toBe(false); - }); + expect(r.code).toBe(1); + expect(r.out).toContain("OpenShell status"); + expect(r.out).toContain("Gateway: other"); + expect(setup.readCalls().some((call) => /^sandbox list(\s|$)/.test(call))).toBe(false); + }, + ); - it("doctor treats a live non-cloudflared PID as stale", () => { + it("doctor treats a live non-cloudflared PID as stale", testTimeoutOptions(15_000), () => { const { sandboxName, serviceDir } = createCloudflaredServiceDir("doctorpid-"); const setup = createDoctorTestSetup( "nemoclaw-cli-doctor-wrong-cloudflared-pid-", diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index cf1e9b22c7..0d25029319 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -1,17 +1,17 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import { spawn, spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; import { CLI, + createLogsTestSetup, FAKE_OPENCLAW_LOG_LINE, FAKE_OPENSHELL_LOG_LINE, - createLogsTestSetup, isChildRunning, runWithEnv, testTimeout, @@ -364,14 +364,12 @@ describe("CLI dispatch", () => { " exit 0", "fi", 'if [ "$1" = "sandbox" ] && [ "$2" = "exec" ]; then', - ' if [ "$8" = "tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE \\"getUpdates conflict|409[[:space:]:]+Conflict\\" || true" ]; then', - " echo 1", - " exit 0", - " fi", - ' if [ "$8" = "tail -n 10 /tmp/gateway.log 2>/dev/null" ]; then', - " echo 'getUpdates conflict'", - " exit 0", - " fi", + ' case "$8" in', + ' *"tail -n 200"*"/tmp/gateway.log"*)', + " echo 'getUpdates conflict'", + " exit 0", + " ;;", + " esac", "fi", "exit 0", ].join("\n"), @@ -386,9 +384,9 @@ describe("CLI dispatch", () => { const log = fs.readFileSync(markerFile, "utf8"); expect(r.code).toBe(0); expect(log).toContain( - 'sandbox exec -n alpha -- sh -c tail -n 200 /tmp/gateway.log 2>/dev/null | grep -cE "getUpdates conflict|409[[:space:]:]+Conflict" || true', + "sandbox exec -n alpha -- sh -c tail -n 200 '/tmp/gateway.log' 2>/dev/null || true", ); - expect(log).toContain("sandbox exec -n alpha -- sh -c tail -n 10 /tmp/gateway.log 2>/dev/null"); + expect(log).not.toContain("grep -cE"); expect(log).not.toContain("sandbox exec alpha sh -c"); }); diff --git a/test/cli/maintenance-command.test.ts b/test/cli/maintenance-command.test.ts index e49d3b877c..142a5fd778 100644 --- a/test/cli/maintenance-command.test.ts +++ b/test/cli/maintenance-command.test.ts @@ -6,25 +6,29 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { run, runWithEnv } from "./helpers"; +import { run, runWithEnv, testTimeoutOptions } from "./helpers"; describe("maintenance CLI dispatch", () => { - it("maintenance command help exits 0 and shows migrated usage", () => { - const backup = run("backup-all --help"); - expect(backup.code).toBe(0); - expect(backup.out).toContain("backup-all"); - expect(backup.out).toContain("Back up all sandbox state before upgrade"); - - const upgrade = run("upgrade-sandboxes --help"); - expect(upgrade.code).toBe(0); - expect(upgrade.out).toContain("upgrade-sandboxes [--check] [--auto] [--yes|-y]"); - expect(upgrade.out).toContain("Detect and rebuild stale sandboxes"); - - const gc = run("gc --help"); - expect(gc.code).toBe(0); - expect(gc.out).toContain("gc [--dry-run] [--yes|-y|--force]"); - expect(gc.out).toContain("Remove orphaned sandbox Docker images"); - }); + it( + "maintenance command help exits 0 and shows migrated usage", + testTimeoutOptions(30_000), + () => { + const backup = run("backup-all --help"); + expect(backup.code).toBe(0); + expect(backup.out).toContain("backup-all"); + expect(backup.out).toContain("Back up all sandbox state before upgrade"); + + const upgrade = run("upgrade-sandboxes --help"); + expect(upgrade.code).toBe(0); + expect(upgrade.out).toContain("upgrade-sandboxes [--check] [--auto] [--yes|-y]"); + expect(upgrade.out).toContain("Detect and rebuild stale sandboxes"); + + const gc = run("gc --help"); + expect(gc.code).toBe(0); + expect(gc.out).toContain("gc [--dry-run] [--yes|-y|--force]"); + expect(gc.out).toContain("Remove orphaned sandbox Docker images"); + }, + ); it("maintenance commands dispatch through oclif", () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-maintenance-")); diff --git a/test/cli/snapshot-shields.test.ts b/test/cli/snapshot-shields.test.ts index aaec781b6a..a265ae7b9b 100644 --- a/test/cli/snapshot-shields.test.ts +++ b/test/cli/snapshot-shields.test.ts @@ -1,12 +1,12 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect } from "vitest"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { describe, expect, it } from "vitest"; -import { runWithEnv, writeSandboxRegistry } from "./helpers"; +import { runWithEnv, testTimeoutOptions, writeSandboxRegistry } from "./helpers"; describe("CLI dispatch", () => { it("shields help uses native oclif usage", () => { @@ -26,7 +26,7 @@ describe("CLI dispatch", () => { expect(status.out).toContain("$ nemoclaw sandbox shields status "); }); - it("snapshot subcommand help uses native oclif usage", () => { + it("snapshot subcommand help uses native oclif usage", testTimeoutOptions(30_000), () => { const home = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-cli-snapshot-help-")); writeSandboxRegistry(home); diff --git a/test/destroy-cleanup-sandbox-services.test.ts b/test/destroy-cleanup-sandbox-services.test.ts index a0c8d54569..83707cd112 100644 --- a/test/destroy-cleanup-sandbox-services.test.ts +++ b/test/destroy-cleanup-sandbox-services.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it, vi } from "vitest"; import type { CleanupSandboxServicesDeps } from "../dist/lib/actions/sandbox/destroy.js"; import { cleanupSandboxServices } from "../dist/lib/actions/sandbox/destroy.js"; +import { SANDBOX_PROVIDER_SUFFIXES } from "../dist/lib/onboard/sandbox-provider-cleanup.js"; type SandboxLike = { provider?: string | null } | null; @@ -93,13 +94,8 @@ describe("cleanupSandboxServices Ollama unload (#2717)", () => { .mocked(harness.deps.runOpenshell) .mock.calls.map((args) => args[0]) .filter((argv) => argv[0] === "provider" && argv[1] === "delete"); - expect(providerDeleteCalls.map((argv) => argv[2])).toEqual([ - "regression-2717-telegram-bridge", - "regression-2717-discord-bridge", - "regression-2717-slack-bridge", - "regression-2717-slack-app", - "regression-2717-wechat-bridge", - "regression-2717-brave-search", - ]); + expect(providerDeleteCalls.map((argv) => argv[2])).toEqual( + SANDBOX_PROVIDER_SUFFIXES.map((suffix) => `regression-2717-${suffix}`), + ); }); }); diff --git a/test/e2e-scenario/support-tests/e2e-fixture-context.test.ts b/test/e2e-scenario/support-tests/e2e-fixture-context.test.ts index 69db753193..1853d57af5 100644 --- a/test/e2e-scenario/support-tests/e2e-fixture-context.test.ts +++ b/test/e2e-scenario/support-tests/e2e-fixture-context.test.ts @@ -13,8 +13,8 @@ import { test as e2eTest } from "../fixtures/e2e-test.ts"; import { SecretStore } from "../fixtures/secrets.ts"; import { ShellProbe, - trustedShellCommand, type TrustedShellCommand, + trustedShellCommand, } from "../fixtures/shell-probe.ts"; const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -398,7 +398,7 @@ describe("E2E fixture primitives", () => { expect(Date.now() - started).toBeLessThan(2_000); expect(result.timedOut).toBe(false); - expect(result.signal).toBe("SIGKILL"); + expect(result.signal).toMatch(/^SIG(TERM|KILL)$/); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -469,7 +469,6 @@ describe("E2E fixture primitives", () => { grandchildPid = Number(fs.readFileSync(pidFile, "utf8").trim()); expect(Number.isInteger(grandchildPid)).toBe(true); expect(result.timedOut).toBe(true); - expect(result.signal).toBeTruthy(); await expectProcessToExit(grandchildPid); } finally { if (grandchildPid && isProcessAlive(grandchildPid)) { diff --git a/test/e2e-scenario/support-tests/e2e-shell-supervisor.test.ts b/test/e2e-scenario/support-tests/e2e-shell-supervisor.test.ts index 35c031d1c7..7b15a914df 100644 --- a/test/e2e-scenario/support-tests/e2e-shell-supervisor.test.ts +++ b/test/e2e-scenario/support-tests/e2e-shell-supervisor.test.ts @@ -122,7 +122,6 @@ describe("fixtures/shell/supervisor", () => { // killGraceMs + a small scheduling margin; if escalation never // ran this would take ~30 seconds. expect(elapsed).toBeLessThan(5_000); - expect(result.signal === "SIGKILL" || result.exitCode !== 0).toBe(true); }); it("honors an AbortSignal without flagging the run as a timeout", async () => { diff --git a/test/nemoclaw-start-slack-runtime.test.ts b/test/nemoclaw-start-slack-runtime.test.ts new file mode 100644 index 0000000000..ddd2617a4f --- /dev/null +++ b/test/nemoclaw-start-slack-runtime.test.ts @@ -0,0 +1,222 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +function messagingRuntimePreloadSection(src: string, planPath: string): string { + const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); + const end = src.indexOf("_read_gateway_token()", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return src + .slice(start, end) + .replace( + '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', + `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(planPath)}`, + ); +} + +function encodeRuntimePreloadPlan(channelId: string, value: Record): string { + return Buffer.from( + JSON.stringify({ + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [ + { + channelId, + id: `${channelId}-runtime-preload`, + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "runtimePreload", + kind: "runtime-preload", + required: true, + value, + }, + ], + onFailure: "abort", + }, + ], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }), + ).toString("base64"); +} + +describe("Slack runtime env normalization (#4274)", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + function runNormalize(env: Record = {}): { + bot: string; + app: string; + result: ReturnType; + } { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-runtime-env-")); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const scriptPath = path.join(tmpDir, "run.sh"); + const runtimeValue = { + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + message: + "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + { + envKey: "SLACK_APP_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", + value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + message: + "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + ], + }; + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, + messagingRuntimePreloadSection(src, planPath), + "write_messaging_runtime_preload_plan", + "apply_messaging_runtime_env_aliases", + 'printf "BOT=%s\\n" "${SLACK_BOT_TOKEN-__UNSET__}"', + 'printf "APP=%s\\n" "${SLACK_APP_TOKEN-__UNSET__}"', + ].join("\n"), + { mode: 0o700 }, + ); + + const childEnv: Record = { PATH: process.env.PATH || "" }; + for (const [key, value] of Object.entries(env)) { + if (value !== undefined) childEnv[key] = value; + } + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: childEnv, + timeout: 5000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + const bot = (result.stdout.match(/^BOT=(.*)$/m)?.[1] ?? "").trimEnd(); + const app = (result.stdout.match(/^APP=(.*)$/m)?.[1] ?? "").trimEnd(); + return { bot, app, result }; + } + + it("normalizes revision-scoped Slack placeholders to Bolt-compatible aliases", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("does not leak the revision suffix into the normalized env or logs", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).not.toContain("v51_"); + expect(run.app).not.toContain("v51_"); + expect(run.result.stderr).not.toContain("v51_"); + expect(run.bot).not.toContain("openshell:resolve:env:"); + expect(run.app).not.toContain("openshell:resolve:env:"); + }); + + it("normalizes the canonical non-revision placeholder too", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("leaves already-aliased Slack tokens unchanged", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); + }); + + it("leaves real Slack tokens untouched", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "xoxb-123-real-bot-token", + SLACK_APP_TOKEN: "xapp-1-real-app-token", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("xoxb-123-real-bot-token"); + expect(run.app).toBe("xapp-1-real-app-token"); + }); + + it("does not create Slack env vars that were never set", () => { + const run = runNormalize(); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("__UNSET__"); + expect(run.app).toBe("__UNSET__"); + }); + + it("leaves a placeholder that resolves a different key untouched", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", + SLACK_APP_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); + expect(run.app).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); + }); + + it("leaves a suffix-collision key untouched", () => { + const run = runNormalize({ + SLACK_BOT_TOKEN: "openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN", + SLACK_APP_TOKEN: "openshell:resolve:env:MY_SLACK_APP_TOKEN", + }); + + expect(run.result.status, run.result.stderr).toBe(0); + expect(run.bot).toBe("openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN"); + expect(run.app).toBe("openshell:resolve:env:MY_SLACK_APP_TOKEN"); + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 587220c6fa..0feabc1eab 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -2270,9 +2270,9 @@ exit 2 ...process.env, OPENCLAW_BIN: fakeOpenclaw, NEMOCLAW_AUTO_PAIR_FAST_DEADLINE_SECS: "0.0001", - NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "1", + NEMOCLAW_AUTO_PAIR_DEADLINE_SECS: "3", NEMOCLAW_AUTO_PAIR_SLOW_INTERVAL_SECS: "0.05", - NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "0.25", + NEMOCLAW_AUTO_PAIR_RUN_TIMEOUT_SECS: "0.75", }, timeout: 30_000, }); @@ -3126,6 +3126,14 @@ describe("provider placeholder refresh (#4251)", () => { return { config: updatedConfig, hash, result }; } + function placeholderPlan(envKeys: string[]): string { + return Buffer.from( + JSON.stringify({ + credentialBindings: envKeys.map((envKey) => ({ providerEnvKey: envKey })), + }), + ).toString("base64"); + } + it("rewrites Telegram canonical placeholders to OpenShell runtime-scoped placeholders", () => { const scoped = "openshell:resolve:env:v42_TELEGRAM_BOT_TOKEN"; const run = runRefresh( @@ -3293,7 +3301,7 @@ describe("provider placeholder refresh (#4251)", () => { expect(run.result.status, run.result.stderr).toBe(0); expect(run.result.stderr).toContain( - "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- Slack token", + "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- token", ); }); @@ -3317,7 +3325,7 @@ describe("provider placeholder refresh (#4251)", () => { expect(run.result.status, run.result.stderr).toBe(0); expect(run.result.stderr).toContain( - "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- Slack token", + "slack.default.botToken runtime SLACK_BOT_TOKEN is neither the SLACK_BOT_TOKEN OpenShell placeholder nor a xoxb- token", ); }); @@ -3333,6 +3341,7 @@ describe("provider placeholder refresh (#4251)", () => { }, }, { + NEMOCLAW_MESSAGING_PLAN_B64: placeholderPlan(["TELEGRAM_BOT_TOKEN", "SLACK_BOT_TOKEN"]), NEMOCLAW_EXTRA_PLACEHOLDER_KEYS: "TELEGRAM_BOT_TOKEN_AGENT_A SLACK_BOT_TOKEN_AGENT_B", }, ); @@ -3523,7 +3532,7 @@ describe("provider placeholder refresh (#4251)", () => { ); }); - it("refuses arbitrary host secret names that do not extend a canonical channel envKey inside the sandbox", () => { + it("refuses arbitrary host secret names that do not extend a discovered provider envKey inside the sandbox", () => { // Defence-in-depth: even if an operator clobbers NEMOCLAW_EXTRA_PLACEHOLDER_KEYS // inside a running sandbox after the host-side parser already filtered it, // the container-side refresh helper must mirror the host's canonical-prefix @@ -3564,7 +3573,7 @@ describe("provider placeholder refresh (#4251)", () => { "NEMOCLAW_EXTRA_PLACEHOLDER_KEYS", ]) { expect(run.result.stderr).toContain( - `[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '${blocked}' — must extend a canonical channel envKey such as TELEGRAM_BOT_TOKEN_`, + `[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '${blocked}' — must extend a discovered provider envKey such as TELEGRAM_BOT_TOKEN_`, ); } expect(run.result.stderr).not.toContain( @@ -3583,11 +3592,10 @@ describe("provider placeholder refresh (#4251)", () => { expect(JSON.stringify(run.config)).not.toContain("aws-host-secret-would-leak"); }); - it("mirrors every canonical channel envKey from the TypeScript parser as an extension prefix", () => { - // Behavioural parity guard: the in-container parser hardcodes its - // canonical-prefix allowlist, so a future channel addition in - // src/lib/sandbox/channels.ts must show up in scripts/nemoclaw-start.sh - // for the runtime side to keep accepting the same per-profile keys. + it("accepts every manifest credential envKey from the messaging plan as an extension prefix", () => { + // Behavioural parity guard: the in-container parser should not hardcode + // channel env keys. It consumes the messaging plan's credentialBindings, + // then accepts per-profile extensions for those discovered keys. // For each TypeScript-derived canonical envKey, plant a `_PARITY` // extension and assert that the bash refresh accepts and revision- // collapses it. Drift in either direction (new channel added but bash @@ -3618,6 +3626,7 @@ describe("provider placeholder refresh (#4251)", () => { }, }, { + NEMOCLAW_MESSAGING_PLAN_B64: placeholderPlan([canonical]), NEMOCLAW_EXTRA_PLACEHOLDER_KEYS: extension, [extension]: scoped, }, @@ -3626,7 +3635,7 @@ describe("provider placeholder refresh (#4251)", () => { expect(run.result.status, run.result.stderr).toBe(0); expect( run.result.stderr, - `bash refresh refused canonical extension '${extension}' — parity drift with src/lib/onboard/extra-placeholder-keys.ts`, + `bash refresh refused manifest credential extension '${extension}'`, ).not.toContain(`[config] Ignoring NEMOCLAW_EXTRA_PLACEHOLDER_KEYS entry '${extension}'`); expect(run.config.channels.telegram.accounts.parity.botToken).toBe(scoped); } @@ -3688,164 +3697,6 @@ describe("provider placeholder refresh (#4251)", () => { }); }); -describe("Slack runtime env normalization (#4274)", () => { - const src = fs.readFileSync(START_SCRIPT, "utf-8"); - - // Exercises apply_messaging_runtime_env_aliases() through the real shell - // function so we prove the exported process-env values the OpenClaw child - // inherits are Bolt-compatible, not the canonical "openshell:resolve:env:*" - // placeholder. - function runNormalize(env: Record = {}): { - bot: string; - app: string; - result: ReturnType; - } { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-runtime-env-")); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const scriptPath = path.join(tmpDir, "run.sh"); - const runtimeValue = { - envAliases: [ - { - envKey: "SLACK_BOT_TOKEN", - match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", - value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - message: - "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", - }, - { - envKey: "SLACK_APP_TOKEN", - match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", - value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - message: - "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", - }, - ], - }; - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', - 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, - messagingRuntimePreloadSection(src, { planPath }), - "write_messaging_runtime_preload_plan", - "apply_messaging_runtime_env_aliases", - 'printf "BOT=%s\\n" "${SLACK_BOT_TOKEN-__UNSET__}"', - 'printf "APP=%s\\n" "${SLACK_APP_TOKEN-__UNSET__}"', - ].join("\n"), - { mode: 0o700 }, - ); - // A clean env so an inherited SLACK_* from the host can't mask an "unset" case. - const childEnv: Record = { PATH: process.env.PATH || "" }; - for (const [key, value] of Object.entries(env)) { - if (value !== undefined) childEnv[key] = value; - } - const result = spawnSync("bash", [scriptPath], { - encoding: "utf-8", - env: childEnv, - timeout: 5000, - }); - fs.rmSync(tmpDir, { recursive: true, force: true }); - const bot = (result.stdout.match(/^BOT=(.*)$/m)?.[1] ?? "").trimEnd(); - const app = (result.stdout.match(/^APP=(.*)$/m)?.[1] ?? "").trimEnd(); - return { bot, app, result }; - } - - it("normalizes revision-scoped Slack placeholders to Bolt-compatible aliases", () => { - const run = runNormalize({ - SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", - SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); - expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); - }); - - it("does not leak the revision suffix into the normalized env or logs", () => { - const run = runNormalize({ - SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SLACK_BOT_TOKEN", - SLACK_APP_TOKEN: "openshell:resolve:env:v51_SLACK_APP_TOKEN", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).not.toContain("v51_"); - expect(run.app).not.toContain("v51_"); - expect(run.result.stderr).not.toContain("v51_"); - expect(run.bot).not.toContain("openshell:resolve:env:"); - expect(run.app).not.toContain("openshell:resolve:env:"); - }); - - it("normalizes the canonical (non-revision) placeholder too", () => { - const run = runNormalize({ - SLACK_BOT_TOKEN: "openshell:resolve:env:SLACK_BOT_TOKEN", - SLACK_APP_TOKEN: "openshell:resolve:env:SLACK_APP_TOKEN", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); - expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); - }); - - it("leaves already-aliased Slack tokens unchanged (idempotent)", () => { - const run = runNormalize({ - SLACK_BOT_TOKEN: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - SLACK_APP_TOKEN: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); - expect(run.app).toBe("xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN"); - }); - - it("leaves real Slack tokens untouched", () => { - const run = runNormalize({ - SLACK_BOT_TOKEN: "xoxb-123-real-bot-token", - SLACK_APP_TOKEN: "xapp-1-real-app-token", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("xoxb-123-real-bot-token"); - expect(run.app).toBe("xapp-1-real-app-token"); - }); - - it("does not create Slack env vars that were never set", () => { - const run = runNormalize(); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("__UNSET__"); - expect(run.app).toBe("__UNSET__"); - }); - - it("leaves a placeholder that resolves a different key untouched", () => { - // OpenShell injects self-referential placeholders. A placeholder resolving - // some other secret must not be silently rebound to the Slack alias. - const run = runNormalize({ - SLACK_BOT_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", - SLACK_APP_TOKEN: "openshell:resolve:env:v51_SOME_OTHER_KEY", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); - expect(run.app).toBe("openshell:resolve:env:v51_SOME_OTHER_KEY"); - }); - - it("leaves a suffix-collision key (…_NOT_SLACK_BOT_TOKEN) untouched", () => { - // The match is anchored: only the canonical key or its v_ form is - // rebound, never a key that merely ends with the same suffix. - const run = runNormalize({ - SLACK_BOT_TOKEN: "openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN", - SLACK_APP_TOKEN: "openshell:resolve:env:MY_SLACK_APP_TOKEN", - }); - - expect(run.result.status, run.result.stderr).toBe(0); - expect(run.bot).toBe("openshell:resolve:env:v51_NOT_SLACK_BOT_TOKEN"); - expect(run.app).toBe("openshell:resolve:env:MY_SLACK_APP_TOKEN"); - }); -}); - describe("Telegram diagnostics (#2766)", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); const telegramDiagnosticsScript = startScriptHeredoc(src, "TELEGRAM_DIAGNOSTICS_EOF"); diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index 60b180a201..c6a17509ec 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -82,8 +82,8 @@ describe("onboard policy preset suggestions", () => { "pypi", "npm", "openclaw-pricing", - "slack", "discord", + "slack", ]); expect(getSuggestedPolicyPresets({ enabledChannels: ["whatsapp"] })).toEqual([ "pypi", diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 5c7f825b89..579c89d7dd 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -1,22 +1,22 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { execFileSync } from "node:child_process"; import { - mkdtempSync, - writeFileSync, - readFileSync, - mkdirSync, - symlinkSync, - lstatSync, chmodSync, existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, renameSync, rmSync, + symlinkSync, + writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; const SANDBOX_INIT = join(import.meta.dirname, "../scripts/lib/sandbox-init.sh"); @@ -850,24 +850,31 @@ EOF }); describe("configure_messaging_channels", () => { - it("returns silently when no tokens are set", () => { + function messagingPlanEnv(channels: string[]): string { + return Buffer.from( + JSON.stringify({ + schemaVersion: 1, + channels: channels.map((channelId) => ({ + channelId, + active: true, + disabled: false, + })), + }), + ).toString("base64"); + } + + it("returns silently when no messaging plan is set", () => { const { stderr } = runWithLib("configure_messaging_channels", { - env: { - TELEGRAM_BOT_TOKEN: "", - DISCORD_BOT_TOKEN: "", - SLACK_BOT_TOKEN: "", - }, + env: {}, }); expect(stderr).not.toContain("[channels]"); }); - it("logs active channels when tokens are present", () => { + it("logs active channels from the messaging plan", () => { // configure_messaging_channels writes to stderr; redirect to stdout to capture it const { stdout } = runWithLib("configure_messaging_channels 2>&1", { env: { - TELEGRAM_BOT_TOKEN: "fake-token", - DISCORD_BOT_TOKEN: "", - SLACK_BOT_TOKEN: "fake-slack", + NEMOCLAW_MESSAGING_PLAN_B64: messagingPlanEnv(["telegram", "slack"]), }, }); expect(stdout).toContain("telegram"); diff --git a/test/sandbox-provider-cleanup.test.ts b/test/sandbox-provider-cleanup.test.ts index 3eb54554e7..f301ebd9b3 100644 --- a/test/sandbox-provider-cleanup.test.ts +++ b/test/sandbox-provider-cleanup.test.ts @@ -4,13 +4,13 @@ import { describe, expect, it, vi } from "vitest"; import { - SANDBOX_PROVIDER_SUFFIXES, deleteProviderWithRecovery, detachSandboxProviders, emitProviderDetachResidualHint, parseAttachedSandboxes, recoverAttachedProvider, runSandboxProviderPreDeleteCleanup, + SANDBOX_PROVIDER_SUFFIXES, } from "../dist/lib/onboard/sandbox-provider-cleanup.js"; type Argv = string[]; @@ -31,14 +31,16 @@ function buildRunOpenshell( describe("SANDBOX_PROVIDER_SUFFIXES", () => { it("covers the full set of per-sandbox messaging and search providers", () => { - expect(SANDBOX_PROVIDER_SUFFIXES).toEqual([ - "telegram-bridge", - "discord-bridge", - "slack-bridge", - "slack-app", - "wechat-bridge", - "brave-search", - ]); + expect([...SANDBOX_PROVIDER_SUFFIXES].sort()).toEqual( + [ + "telegram-bridge", + "discord-bridge", + "wechat-bridge", + "slack-bridge", + "slack-app", + "brave-search", + ].sort(), + ); }); }); @@ -51,14 +53,15 @@ describe("detachSandboxProviders", () => { const detachCalls = calls.filter( (argv) => argv[0] === "sandbox" && argv[1] === "provider" && argv[2] === "detach", ); - expect(detachCalls).toEqual([ - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-telegram-bridge"], - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-discord-bridge"], - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-slack-bridge"], - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-slack-app"], - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-wechat-bridge"], - ["sandbox", "provider", "detach", "spark-nemo", "spark-nemo-brave-search"], - ]); + expect(detachCalls).toEqual( + SANDBOX_PROVIDER_SUFFIXES.map((suffix) => [ + "sandbox", + "provider", + "detach", + "spark-nemo", + `spark-nemo-${suffix}`, + ]), + ); expect(result.detached).toHaveLength(SANDBOX_PROVIDER_SUFFIXES.length); expect(result.failures).toEqual([]); }); diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index be91ced714..ac5aed9f25 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -242,10 +242,14 @@ describe("WhatsApp pairing guard (channels login --channel whatsapp)", () => { const preloadPath = path.join(tempDir, "nemoclaw-whatsapp-qr-compact.js"); if (opts.preloadPresent) fs.writeFileSync(preloadPath, "// stub preload\n"); - - // The guard body hardcodes the literal /tmp path (single-quoted heredoc); - // redirect it to the temp file for the test. - const guardBody = guard.replaceAll("/tmp/nemoclaw-whatsapp-qr-compact.js", preloadPath); + const connectPreloadsPath = path.join(tempDir, "nemoclaw-messaging-connect-preloads.list"); + if (opts.preloadPresent) fs.writeFileSync(connectPreloadsPath, `${preloadPath}\n`); + + // The guard body hardcodes literal /tmp paths (single-quoted heredoc); + // redirect them to temp files for the test. + const guardBody = guard + .replaceAll("/tmp/nemoclaw-whatsapp-qr-compact.js", preloadPath) + .replaceAll("/tmp/nemoclaw-messaging-connect-preloads.list", connectPreloadsPath); const wrapperLines = [ "#!/usr/bin/env bash", From 067ad6056a3117f545d24f0912c97f8b376153d4 Mon Sep 17 00:00:00 2001 From: San Dang Date: Fri, 12 Jun 2026 23:00:51 +0700 Subject: [PATCH 03/23] fix(messaging): address manifest hook review feedback --- scripts/nemoclaw-start.sh | 18 +- src/lib/actions/sandbox/doctor.ts | 2 +- .../sandbox/policy-channel-conflict.test.ts | 32 + src/lib/actions/sandbox/policy-channel.ts | 6 +- src/lib/inventory/index.test.ts | 2 +- src/lib/inventory/index.ts | 12 +- src/lib/messaging-channel-config.test.ts | 2 +- src/lib/messaging/MIGRATION.md | 698 ------------------ src/lib/messaging/applier/hook-phases.test.ts | 44 ++ src/lib/messaging/applier/hook-phases.ts | 6 + src/lib/messaging/channels/manifests.test.ts | 1 + src/lib/messaging/channels/metadata.test.ts | 73 ++ src/lib/messaging/channels/metadata.ts | 36 +- .../socket-mode-gateway-conflict.test.ts | 9 +- .../hooks/socket-mode-gateway-conflict.ts | 5 +- src/lib/messaging/channels/slack/manifest.ts | 1 + src/lib/messaging/hooks/errors.ts | 23 + src/lib/messaging/hooks/index.ts | 1 + src/lib/messaging/manifest/types.ts | 1 + src/lib/messaging/status-outputs.test.ts | 46 ++ src/lib/messaging/status-outputs.ts | 7 +- src/lib/onboard/docker-gpu-patch-wsl.test.ts | 13 + src/lib/onboard/docker-gpu-patch.ts | 3 +- .../onboard/messaging-conflict-guard.test.ts | 28 + src/lib/onboard/messaging-conflict-guard.ts | 7 +- src/lib/onboard/policy-presets.ts | 24 +- .../sandbox-build-patch-config.test.ts | 1 + src/lib/onboard/sandbox-build-patch-config.ts | 25 +- src/lib/onboard/sandbox-create-plan.ts | 32 +- src/lib/sandbox/channels.test.ts | 2 + src/lib/sandbox/channels.ts | 2 +- src/lib/status-command-deps.ts | 60 +- test/nemoclaw-start-runtime-env-alias.test.ts | 121 +++ test/nemoclaw-start.test.ts | 10 +- test/onboard-policy-suggestions.test.ts | 16 + test/sandbox-init.test.ts | 2 +- 36 files changed, 602 insertions(+), 769 deletions(-) delete mode 100644 src/lib/messaging/MIGRATION.md create mode 100644 src/lib/messaging/hooks/errors.ts create mode 100644 src/lib/messaging/status-outputs.test.ts create mode 100644 test/nemoclaw-start-runtime-env-alias.test.ts diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 1bbf71f557..63c27970d1 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1598,14 +1598,17 @@ apply_messaging_runtime_env_aliases() { _rows="$( python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGALIASES' import json +import os +import re import sys with open(sys.argv[1], encoding="utf-8") as handle: plan = json.load(handle) for alias in plan.get("envAliases", []): + if not re.search(alias["match"], os.environ.get(alias["envKey"], "")): + continue print("\t".join([ alias["envKey"], - alias["match"], alias["value"], alias.get("message", ""), ])) @@ -1613,13 +1616,10 @@ PYMESSAGINGALIASES )" || return $? [ -n "$_rows" ] || return 0 - local _env_key _match _value _message _current - while IFS=$'\t' read -r _env_key _match _value _message; do - _current="$(printenv "$_env_key" 2>/dev/null || true)" - if [[ "$_current" =~ $_match ]]; then - export "$_env_key=$_value" - [ -n "$_message" ] && printf '%s\n' "$_message" >&2 - fi + local _env_key _value _message + while IFS=$'\t' read -r _env_key _value _message; do + export "$_env_key=$_value" + [ -n "$_message" ] && printf '%s\n' "$_message" >&2 done <<<"$_rows" } @@ -3573,6 +3573,8 @@ if [ "$(id -u)" -ne 0 ]; then apply_messaging_runtime_env_aliases if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then + install_messaging_runtime_preloads + verify_messaging_runtime_secret_scans exec "${NEMOCLAW_CMD[@]}" fi diff --git a/src/lib/actions/sandbox/doctor.ts b/src/lib/actions/sandbox/doctor.ts index 77c5d74915..03f4f5b7a2 100644 --- a/src/lib/actions/sandbox/doctor.ts +++ b/src/lib/actions/sandbox/doctor.ts @@ -454,7 +454,7 @@ function messagingDoctorCheck(sandboxName: string, sb: SandboxEntry): DoctorChec } const statusDeps = buildStatusCommandDeps(ROOT); - const degraded = statusDeps.checkMessagingBridgeHealth?.(sandboxName, channels) || []; + const degraded = statusDeps.checkMessagingBridgeHealth?.(sandboxName, channels, sb.agent) || []; const overlaps = (statusDeps.findMessagingOverlaps?.() ?? []).filter( (overlap) => channels.includes(overlap.channel) && overlap.sandboxes.includes(sandboxName), ); diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 43a2deaaca..363cbfc761 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -687,6 +687,38 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(upsertMock).not.toHaveBeenCalled(); // aborted before registering }); + it("slack: uses the current sandbox gateway when checking Socket Mode conflicts", async () => { + const bob = makePlanEntry("bob", "slack", [ + { + providerEnvKey: "SLACK_BOT_TOKEN", + credentialHash: hashCredential("xoxb-bob-bot") as string, + }, + { + providerEnvKey: "SLACK_APP_TOKEN", + credentialHash: hashCredential("xapp-bob-app") as string, + }, + ]); + (bob as { gatewayName?: string }).gatewayName = "nemoclaw-9090"; + arrangeRegistry({ + current: { name: "alpha", gatewayName: "nemoclaw-9090" } as SandboxEntry, + others: [bob], + }); + getCredentialMock.mockImplementation((key: string) => + key === "SLACK_BOT_TOKEN" + ? "xoxb-alpha-bot" + : key === "SLACK_APP_TOKEN" + ? "xapp-alpha-app" + : null, + ); + promptMock.mockResolvedValue("n"); + + await addSandboxChannel("alpha", { channel: "slack" }); + + expect(loggedText()).toContain("Slack Socket Mode is already enabled for sandbox 'bob'"); + expect(conflictPromptShown()).toBe(true); + expect(upsertMock).not.toHaveBeenCalled(); + }); + it("slack: shared token on the same gateway reports the credential conflict first (#4953)", async () => { // The credential axis runs before the gateway axis, so a shared Slack token // surfaces the gateway-independent "same slack credential" warning (more diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 86ba2ba75e..32207a0656 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -15,6 +15,7 @@ import { createBuiltInRenderTemplateResolver, createMessagingPreEnableHookInputs, getMessagingManifestAvailabilityContext, + isMessagingHookConflictError, type MessagingHookOutputValue, MessagingHostStateApplier, type MessagingSerializableValue, @@ -488,9 +489,11 @@ async function checkMessagingPreEnableHooks( } const hookRegistry = createBuiltInMessagingHookRegistry(); + const currentGatewayName = + registryEntries.find((entry) => entry.name === sandboxName)?.gatewayName || BASE_GATEWAY_NAME; const additionalInputs = createMessagingPreEnableHookInputs({ currentSandbox: sandboxName, - currentGatewayName: BASE_GATEWAY_NAME, + currentGatewayName, registryEntries, }); @@ -516,6 +519,7 @@ async function checkMessagingPreEnableHooks( ), }); } catch (err) { + if (!isMessagingHookConflictError(err)) throw err; const message = err instanceof Error ? err.message : String(err); for (const line of message.split("\n").filter((line) => line.trim().length > 0)) { console.log(` ${YW}⚠${R} ${line}`); diff --git a/src/lib/inventory/index.test.ts b/src/lib/inventory/index.test.ts index e178ba1e66..137ac0577e 100644 --- a/src/lib/inventory/index.test.ts +++ b/src/lib/inventory/index.test.ts @@ -404,7 +404,7 @@ describe("inventory commands", () => { log: (message = "") => lines.push(message), }); - expect(checkMessagingBridgeHealth).toHaveBeenCalledWith("alpha", ["telegram"]); + expect(checkMessagingBridgeHealth).toHaveBeenCalledWith("alpha", ["telegram"], undefined); expect(lines).toContain( " ⚠ telegram bridge: degraded (7 conflict errors in /tmp/gateway.log)", ); diff --git a/src/lib/inventory/index.ts b/src/lib/inventory/index.ts index bc3efc33a3..39db033464 100644 --- a/src/lib/inventory/index.ts +++ b/src/lib/inventory/index.ts @@ -117,7 +117,11 @@ export interface ShowStatusCommandDeps { * detect the degraded state from `$?` (#3386). */ getGatewayHealth?: () => GatewayHealth; - checkMessagingBridgeHealth?: (sandboxName: string, channels: string[]) => MessagingBridgeHealth[]; + checkMessagingBridgeHealth?: ( + sandboxName: string, + channels: string[], + agent?: string | null, + ) => MessagingBridgeHealth[]; findMessagingOverlaps?: () => MessagingOverlap[]; readGatewayLog?: (sandboxName: string) => string | null; log?: (message?: string) => void; @@ -500,7 +504,11 @@ export function showStatusCommand(deps: ShowStatusCommandDeps): void { const defaultEntry = refreshed.find((sb) => sb.name === resolvedDefault); const channels = getActiveChannelIdsFromPlan(defaultEntry?.messaging?.plan); if (channels.length > 0) { - const degraded = deps.checkMessagingBridgeHealth(resolvedDefault, channels); + const degraded = deps.checkMessagingBridgeHealth( + resolvedDefault, + channels, + defaultEntry?.agent, + ); if (degraded.length > 0) { log(""); for (const { channel, conflicts } of degraded) { diff --git a/src/lib/messaging-channel-config.test.ts b/src/lib/messaging-channel-config.test.ts index 0e2cd94705..9600a07a8c 100644 --- a/src/lib/messaging-channel-config.test.ts +++ b/src/lib/messaging-channel-config.test.ts @@ -21,10 +21,10 @@ describe("messaging channel config", () => { "WECHAT_ALLOWED_IDS", "SLACK_ALLOWED_USERS", "SLACK_ALLOWED_CHANNELS", + "WHATSAPP_ALLOWED_IDS", "WECHAT_ACCOUNT_ID", "WECHAT_BASE_URL", "WECHAT_USER_ID", - "WHATSAPP_ALLOWED_IDS", ]); }); diff --git a/src/lib/messaging/MIGRATION.md b/src/lib/messaging/MIGRATION.md deleted file mode 100644 index 16dcd44c54..0000000000 --- a/src/lib/messaging/MIGRATION.md +++ /dev/null @@ -1,698 +0,0 @@ - - -# Messaging Channel Manifest Migration - -This plan tracks the remaining migration from concrete channel logic in core code -to manifest-owned metadata and hooks under `src/lib/messaging/channels/*`. - -Required channels for this pass: - -- Telegram -- WeChat -- Slack -- Discord - -WhatsApp is optional for this pass, but shared helpers must remain generic so -WhatsApp can continue using the same framework. - -## Goals - -- Keep all channel-specific metadata in channel manifests. -- Keep all channel-specific behavior in channel hook implementations. -- Keep common orchestration, persistence, provider binding, policy application, - and conflict detection in shared messaging framework code. -- Treat `SandboxEntry.messaging.plan` as the durable messaging source of truth. -- Do not introduce another durable state source for channel config. -- Do not persist raw messaging secrets in host-side state. -- Preserve current CLI behavior while replacing hard-coded channel branches. - -## Non-Goals - -- Do not rewrite the full messaging compiler/applier architecture. -- Do not migrate unrelated inference, gateway, or policy behavior. -- Do not require WhatsApp behavior changes unless a generic helper naturally - affects it. -- Do not remove legacy compatibility fields until the registry-backed plan path - is verified for existing sandboxes. - -## Hook-First Migration Model - -The migration should define the complete hook phase contract first, then migrate -each concrete behavior into either a common hook or a channel-specific hook. Core -code should call `src/lib/messaging/applier` phase runners instead of directly -calling Telegram, WeChat, Slack, or Discord implementation details. - -Execution model: - -1. The compiler produces a serializable `SandboxMessagingPlan`. -2. The caller invokes a phase runner from `src/lib/messaging/applier`. -3. The applier selects enabled channel hooks for the requested phase. -4. Common hooks run where the behavior is shared across channels. -5. Channel-specific hooks run only from the owning channel directory. -6. The applier returns structured results; CLI code handles prompts, output, and - exit behavior at the edge. - -Direct calls from core to concrete channel implementation should disappear. A -core call site may call `MessagingSetupApplier`, `MessagingHostStateApplier`, or -another applier entrypoint, but should not import channel-specific hook files. - -## Required Hook Phases - -### Existing Phases To Keep - -- `enroll` - - collects channel inputs and secrets - - already used by token-paste and WeChat QR login hooks -- `reachability-check` - - validates freshly collected enrollment inputs - - already used by Telegram getMe-style checks -- `agent-install` - - produces build-time package install steps - - already used by OpenClaw channel package installation -- `render` - - produces agent config render fragments - - keep as a compiler/render phase -- `apply` - - applies config into an existing sandbox - - already partially handled by `applyAgentConfigAtOpenShell` -- `post-agent-install` - - runs after package install/config render when a channel needs generated - files or final config patching - - already used by WeChat account seeding -- `health-check` - - runs after create/rebuild and before lifecycle success is reported -- `status` - - cheap bounded status checks for `status` and `channels status` -- `diagnostic` - - deeper or slower diagnostics used by `doctor` or explicit channel checks - -### New Phases To Add - -- `pre-enable` - - host-side checks after plan compilation and before provider, policy, - registry, or sandbox mutation - - used for channel-specific blocking/warning checks such as Slack Socket Mode - gateway conflict -- `runtime-preload` - - sandbox-side startup behavior that must be installed before OpenClaw starts - - used for Telegram diagnostics preload, Slack channel guard, Slack runtime - normalization/tripwire, and WeChat diagnostics preload - -State replay is not a hook phase. `SandboxEntry.messaging.plan` remains the -durable source of truth, and `plan.stateUpdates` should be applied by common -applier code. Legacy session fields such as `telegramConfig` and `wechatConfig` -can remain read-only compatibility fallbacks while the plan path is completed. - -## Phase Ownership - -### `pre-enable` - -Applier responsibilities: - -- preserve common credential conflict detection as shared guard logic, not as a - hook -- run channel-specific `pre-enable` hooks -- normalize failure into a structured result: - - proceed - - warn and ask - - abort - - skipped because phase is not relevant - -Call sites: - -- `src/lib/onboard/sandbox-messaging-preflight.ts` - - after reading the staged `SandboxMessagingPlan` - - before create/recreate continues into provider/policy setup -- `src/lib/actions/sandbox/policy-channel.ts` - - `channels add`: after `planSandboxChannelAdd()`, before provider/policy - registration and before `MessagingHostStateApplier.applyPlanToRegistry()` - - `channels start`: before persisting the re-enabled plan - -Concrete behavior to migrate: - -- common credential conflict checks from: - - `src/lib/onboard/messaging-conflict-guard.ts` - - `src/lib/actions/sandbox/policy-channel.ts` - - keep this as shared guard behavior because it applies to every credentialed - channel -- Slack Socket Mode gateway conflict from: - - `src/lib/messaging/applier/conflict-detection/slack-socket-mode.ts` - - `src/lib/onboard/messaging-conflict-guard.ts` - - `src/lib/actions/sandbox/policy-channel.ts` - - migrate this channel-specific axis into a Slack `pre-enable` hook - -### `runtime-preload` - -Applier responsibilities: - -- collect runtime preload hooks for enabled channels -- stage any preload scripts or generated shell fragments needed by the sandbox -- expose a shell-consumable plan artifact for `scripts/nemoclaw-start.sh` -- keep startup behavior channel-owned even when shell performs the final install - -Concrete behavior to migrate: - -- Telegram diagnostics preload: - - `scripts/nemoclaw-start.sh` - - `nemoclaw-blueprint/scripts/telegram-diagnostics.js` -- Slack channel guard preload: - - `scripts/nemoclaw-start.sh` - - `nemoclaw-blueprint/scripts/slack-channel-guard.js` -- Slack runtime env normalization and secret tripwire: - - `scripts/nemoclaw-start.sh` -- WeChat diagnostics preload: - - `nemoclaw-blueprint/scripts/wechat-diagnostics.js` - -Implementation note: - -- Shell cannot import TypeScript manifests directly. -- The applier should generate/stage a compact runtime artifact that shell can - consume without knowing channel details. - -### `health-check` - -Applier responsibilities: - -- run `plan.healthChecks` after create/rebuild readiness -- call hook handlers through the central hook runner -- keep checks bounded and deterministic -- return structured check results to the lifecycle caller - -Concrete behavior to migrate: - -- WeChat health check application: - - `src/lib/messaging/channels/wechat/hooks/health-check.ts` -- Telegram bridge startup and DM allowlist warnings: - - moved into `telegram-openclaw-bridge-health` manifest output -- OpenClaw bridge startup verification for Telegram/Discord/Slack: - - moved into static `health-check` hook outputs consumed by - `src/lib/actions/sandbox/policy-channel.ts` - -### `status` - -Applier responsibilities: - -- expose a cheap status runner for configured/enabled channels -- use manifest/hook-provided runtime aliases and log signatures -- avoid deep probes or long waits - -Concrete behavior to migrate: - -- runtime config key aliases and log patterns: - - `src/lib/channel-runtime-status.ts` -- Telegram conflict log signatures: - - `src/lib/status-command-deps.ts` -- Slack gateway overlap reporting: - - `src/lib/status-command-deps.ts` - - `src/lib/messaging/applier/conflict-detection/slack-socket-mode.ts` -- channel display/known-channel validation: - - `src/lib/actions/sandbox/channel-status.ts` - -### `diagnostic` - -Applier responsibilities: - -- run deeper channel diagnostics when explicitly requested -- allow channel-specific diagnostic output while keeping the CLI orchestration - common -- keep common parsers generic and channel signatures hook-owned - -Concrete behavior to migrate: - -- detailed runtime channel checks currently split across: - - `src/lib/channel-runtime-status.ts` - - `src/lib/actions/sandbox/channel-status.ts` - - `src/lib/actions/sandbox/doctor.ts` - -### State Updates - -State updates stay in common applier code, not in concrete core branches. - -Applier responsibilities: - -- apply `plan.stateUpdates` -- persist serializable channel config into `SandboxEntry.messaging.plan` -- replay config for rebuild planning from the plan -- keep secrets out of host-side state - -Concrete behavior to migrate: - -- Telegram mention mode drift/config handling in: - - `src/lib/onboard/messaging-config.ts` - - `src/lib/onboard/sandbox-build-patch-config.ts` - - `src/lib/onboard/machine/handlers/sandbox.ts` -- WeChat config gather/hydration/drift handling in: - - `src/lib/onboard/wechat-config.ts` - - `src/lib/actions/sandbox/rebuild.ts` - - `src/lib/actions/sandbox/policy-channel.ts` - - `src/lib/onboard/sandbox-build-patch-config.ts` - - `src/lib/onboard/machine/handlers/sandbox.ts` - -## Straggler Inventory - -### Channel Catalog and Metadata - -- `src/lib/sandbox/channels.ts` - - old channel catalog with env keys, prompts, token formats, labels, login - modes, and config env keys -- `src/lib/messaging-channel-config.ts` - - config env aliases, including Discord aliases -- `src/lib/onboard/messaging-prep.ts` - - static provider/env mapping -- `src/lib/onboard/messaging-reuse.ts` - - hard-coded provider names -- `src/lib/onboard/messaging-credentials.ts` - - env-key-to-channel mapping -- `src/lib/onboard/extra-placeholder-keys.ts` - - messaging credential placeholder keys -- `src/lib/onboard/sandbox-provider-cleanup.ts` - - hard-coded provider suffix cleanup -- `src/lib/actions/sandbox/snapshot.ts` - - hard-coded provider suffixes -- `src/lib/credentials/store.ts` - - static messaging credential env keys -- `src/lib/credentials/command-support.ts` - - concrete bridge provider suffixes -- `src/lib/security/redact.ts` - - concrete messaging token redaction keys - -### Policy - -- `src/lib/policy/index.ts` - - policy labels, aliases, and Discord-specific messaging -- `src/lib/onboard/policy-presets.ts` - - explicit channel env to preset suggestions -- `src/lib/onboard/initial-policy.ts` - - Hermes messaging policy key mapping -- `src/lib/onboard/messaging-policy-presets.ts` - - Slack-specific required preset mapping - -### Build and Agent Install - -- `src/lib/messaging/applier/build/messaging-build-applier.mts` - - OpenClaw package allowlist for Discord, Slack, WeChat -- `src/lib/sandbox/build-context.ts` - - stages Slack-specific patch script -- `scripts/patch-openclaw-slack-deny-feedback.mts` - - Slack package compatibility patch - -### Runtime Scripts - -- `scripts/nemoclaw-start.sh` - - placeholder key lists - - Telegram/Discord/OpenClaw credential field mapping - - Slack env normalization - - Slack secret tripwire - - Telegram diagnostics install - - Slack guard install - - hard-coded channel command help -- `scripts/lib/sandbox-init.sh` - - active channel logging for Telegram/Discord/Slack -- `scripts/install.sh` - - non-interactive env help for Discord/Slack/Telegram - -### WeChat Host-Side Logic - -- `src/lib/host-qr-handlers.ts` - - old host QR handler registry -- `src/ext/wechat/login.ts` - - WeChat login implementation -- `src/ext/wechat/qr.ts` - - WeChat QR rendering/helper implementation - -Keep implementation helpers if useful, but invoke them only from WeChat channel -hooks. - -### Runtime Status - -- `src/lib/channel-runtime-status.ts` - - runtime config key map and gateway log patterns -- `src/lib/status-command-deps.ts` - - Telegram and Slack concrete status signatures -- `src/lib/actions/sandbox/channel-status.ts` - - known channel validation and WhatsApp-specialized diagnostics - -## Implementation Sequence - -### Step 0: Plan Approval - -- Add this migration plan. -- Do not change runtime behavior. -- Wait for maintainer approval before implementation. - -### Step 1: Define Phase Contracts - -Add all required phase names to `ChannelHookPhase` before migrating behavior. - -Required additions: - -- `pre-enable` -- `runtime-preload` - -Keep existing phases: - -- `enroll` -- `reachability-check` -- `agent-install` -- `render` -- `apply` -- `post-agent-install` -- `health-check` -- `status` -- `diagnostic` - -Do not add separate applier phase unions or speculative phase result types in -this step. Applier execution should use `ChannelHookPhase`, -`MessagingHookApplyRequest`, and `MessagingHookRunResult` unless a later runner -needs a concrete additional contract. - -Validation: - -- `npm run typecheck:cli` -- manifest type tests -- hook runner tests - -### Step 2: Centralize Phase Execution In Applier - -Create applier entrypoints that all core call sites can use. - -Expected entrypoints: - -- `applyPreEnableChecks(plan, context)` -- `applyRuntimePreloads(plan, context)` -- `applyHealthChecks(plan, context)` -- `applyStatusChecks(plan, context)` -- `applyDiagnostics(plan, context)` -- shared hook request builder for all phases - -The applier should: - -- select enabled plan channels -- select hooks matching the phase -- run common phase hooks before channel-specific hooks when both apply -- honor hook failure policy -- return structured results instead of printing or exiting directly -- introduce result/context types only when the first real runner needs them - -Validation: - -- hook runner tests -- applier tests with fake plans and fake hooks -- plan-filter tests - -### Step 3: Implement Common Hooks - -Implement common hooks for behavior shared across channels. - -Initial common hooks: - -- plan state update/replay helper, invoked by applier state code rather than - concrete core branches -- generic runtime channel config/log comparison for `status` -- generic provider/policy metadata helpers where they are currently hard-coded - -Do not move the shared credential conflict guard into a hook. It applies to -every credentialed channel and should remain a shared guard that runs before -channel-specific `pre-enable` hooks. - -Validation: - -- conflict detection tests -- host-state applier tests -- channel runtime status tests - -### Step 4: Implement Channel-Specific Hooks - -Move channel-specific behavior into the owning channel directory. - -Custom hook inventory by phase: - -| Phase | Channel | Hook | Migration status | -|---|---|---|---| -| `pre-enable` | Slack | `slack-socket-mode-gateway-conflict` | migrated | -| `runtime-preload` | Slack | `slack-runtime-preload` | migrated | -| `runtime-preload` | Telegram | `telegram-runtime-preload` | migrated | -| `runtime-preload` | WeChat | `wechat-runtime-preload` | migrated | -| `runtime-preload` | WhatsApp | `whatsapp-runtime-preload` | migrated, optional channel | -| `runtime-preload` | Discord | none | no current runtime preload behavior found | -| `health-check` | Telegram | `telegram-openclaw-bridge-health` | migrated | -| `health-check` | WeChat | `wechat-health-check` | migrated caller path | -| `health-check` | Slack | `slack-openclaw-bridge-health` | migrated | -| `health-check` | Discord | `discord-openclaw-bridge-health` | migrated | -| `status` | Telegram | getUpdates conflict/status signatures | migrated | -| `status` | Slack | gateway overlap reporting | migrated | -| `status` | Discord | runtime alias/log signatures | migrated | -| `diagnostic` | Telegram | common channel status/policy diagnostics | migrated, common helper | -| `diagnostic` | Slack | common channel status plus manifest status overlap | migrated, common helper | -| `diagnostic` | Discord | common policy/config diagnostics | migrated, common helper | -| `diagnostic` | WeChat | common channel status/policy diagnostics | migrated, common helper | -| `diagnostic` | WhatsApp | common metadata plus existing optional QR deep probe | migrated, optional channel | - -Slack hooks: - -- `pre-enable`: Socket Mode gateway conflict -- `runtime-preload`: channel guard install, runtime placeholder normalization, - secret-on-disk tripwire -- `status`: gateway overlap reporting - -Telegram hooks: - -- `reachability-check`: keep getMe-style verification -- `runtime-preload`: diagnostics preload -- `health-check`: bridge startup and DM allowlist warnings -- `status`: getUpdates conflict signature - -WeChat hooks: - -- `enroll`: keep host QR login -- `post-agent-install`: keep account seeding -- `runtime-preload`: diagnostics preload -- `health-check`: account/iLink sanity - -Discord hooks: - -- `agent-install`: package install metadata -- `status`: runtime alias/log signature -- `diagnostic`: handled by common manifest-derived channel status helper - -Validation: - -- channel hook unit tests -- existing Telegram/Slack/WeChat tests -- manifest tests confirming hooks are declared by channel manifests - -### Step 5: Migrate Core Call Sites To Applier Calls - -Replace concrete channel calls in core with applier phase calls. - -Primary migrations: - -- onboard/create preflight calls `applyPreEnableChecks` -- `channels add` calls `applyPreEnableChecks` -- `channels start` calls `applyPreEnableChecks` -- create/rebuild finalization calls `applyHealthChecks` -- status commands consume common manifest-derived status outputs -- doctor/deep diagnostics consume common manifest-derived diagnostic specs -- sandbox build/start path consumes `applyRuntimePreloads` outputs - -Validation: - -- onboard messaging tests -- channels add/start/stop/remove tests -- status and doctor tests -- rebuild tests - -### Step 6: Plan-Owned State Replay - -Make `SandboxEntry.messaging.plan` the authoritative source for channel config -replay through common applier state handling. - -Migrate: - -- Telegram mention mode -- WeChat account/base URL/user ID -- Slack allowed users/channels -- Discord allowed IDs/server IDs - -Keep old session fields as read-only compatibility fallback where needed, but -stop writing new channel state there once the plan path is active. - -Validation: - -- onboard session plan tests -- rebuild plan tests -- WeChat manifest tests -- Telegram config tests - -### Step 7: Manifest Metadata Adapter - -After phase runners are in place, replace remaining metadata-only hard-coded -lists with manifest-backed helpers. - -Shared helpers should resolve: - -- available channels by agent -- credential env keys -- channel for env key -- provider names and suffixes -- config env keys and aliases -- policy presets and policy key aliases -- OpenClaw runtime channel keys and aliases -- package install specs - -Replace `src/lib/sandbox/channels.ts` with a compatibility adapter over this -metadata. Keep its public shape stable for existing callers. - -Validation: - -- `npm run typecheck:cli` -- targeted tests for `src/lib/sandbox/channels.ts` -- manifest registry/compiler tests - -### Step 8: Remove Remaining Hard-Coded Lists - -Replace remaining concrete channel lists with manifest/applier-derived helpers. - -Primary files: - -- `src/lib/onboard/messaging-prep.ts` -- `src/lib/onboard/messaging-reuse.ts` -- `src/lib/onboard/messaging-credentials.ts` -- `src/lib/onboard/policy-presets.ts` -- `src/lib/onboard/initial-policy.ts` -- `src/lib/onboard/messaging-policy-presets.ts` -- `src/lib/actions/sandbox/snapshot.ts` -- `src/lib/credentials/store.ts` -- `src/lib/credentials/command-support.ts` -- `src/lib/security/redact.ts` -- `scripts/lib/sandbox-init.sh` -- `scripts/install.sh` - -Treat `src/lib/deploy/index.ts` as optional/legacy unless deploy messaging -support is confirmed in scope. - -Validation: - -- `npm run typecheck:cli` -- targeted messaging tests -- `npm test` when behavior changes cross CLI boundaries - -## Approval Gate - -Implementation should start only after this hook-first plan is approved. - -Proposed first implementation task after approval: - -1. Add `pre-enable` and `runtime-preload` to `ChannelHookPhase`. -2. Add applier phase context/result types. -3. Add no-op applier phase runners with tests. -4. Keep runtime behavior unchanged until concrete hooks are migrated. - -## Implementation Progress - -Completed: - -- Added `pre-enable` and `runtime-preload` to `ChannelHookPhase`. -- Added `MessagingSetupApplier.applyHooksForPhase()` and phase helpers that run - manifest-declared hooks through the existing hook runner contract. -- Added named `MessagingSetupApplier` phase methods for core-owned phase - orchestration: - - `listPreEnableChecks()` / `applyPreEnableChecks()` - - `listRuntimePreloads()` / `applyRuntimePreloads()` - - `listHealthChecks()` / `applyHealthChecks()` -- Kept `enforceMessagingChannelConflicts` as shared guard behavior. Do not move - the common credential conflict axis into a hook. -- Added Slack `pre-enable` hook `slack.socketModeGatewayConflict` for the - Socket Mode gateway axis and declared it in the Slack manifest. -- Migrated `channels add` from direct Slack Socket Mode helper calls to - `MessagingSetupApplier.applyPreEnableChecks()`. -- Migrated onboard's `enforceMessagingChannelConflicts` Slack gateway axis to - the same Slack `pre-enable` hook while keeping the shared credential-conflict - guard in place. -- Declared OpenClaw `runtime-preload` hook outputs in channel manifests for: - - Slack runtime env aliasing, channel guard preload, and secret scan - - Telegram diagnostics preload - - WeChat diagnostics preload - - WhatsApp compact-QR connect preload -- Migrated `scripts/nemoclaw-start.sh` from concrete channel preload functions - to a generic runtime-preload consumer that reads active channel hook outputs - from `NEMOCLAW_MESSAGING_PLAN_B64`. -- Declared static OpenClaw bridge startup health outputs for Telegram, Slack, - and Discord, including Telegram DM allowlist warning metadata. -- Migrated `channels add` post-rebuild checks to - `MessagingSetupApplier.applyHealthChecks()` and a generic health-check output - consumer. The old Telegram action helper was removed. -- Declared static status outputs for: - - OpenClaw runtime config aliases and gateway-log patterns for Telegram, - Slack, Discord, WeChat, and WhatsApp - - Telegram gateway-log conflict signatures - - Slack single-gateway overlap reporting -- Migrated `channel-runtime-status`, bare `status` bridge checks, and status - overlap reporting from concrete channel maps/branches to manifest-derived - status outputs. -- Migrated common diagnostic metadata to a shared manifest-derived helper - instead of channel hooks, since required channel diagnostics are the same - registration/policy/status surface. -- Migrated `channels status` channel validation, default channel selection, and - policy coverage from the legacy concrete channel registry to that common - manifest helper. -- Migrated `doctor` messaging diagnostics to use common manifest-derived - deep-probe hints and manifest-derived gateway-overlap status outputs. -- Migrated remaining core `pre-enable` and `health-check` hook execution call - sites from raw `applyHooksForPhase(..., "")` calls to the named applier - phase methods. -- Added common plan-state replay helpers that derive persisted state values from - `SandboxMessagingPlan.channels[].inputs` and replay env config through - manifest-declared `stateUpdates` / `rebuild-hydration`. -- Migrated resume drift detection to the common manifest-derived messaging - config comparison for Telegram, WeChat, Slack, and Discord. The old - Telegram/WeChat-specific drift checks were removed. -- Migrated `channels add` and sandbox `rebuild` config hydration to plan-backed - `getStoredMessagingChannelConfig()` plus `hydrateMessagingChannelConfig()`. -- Stopped writing new `session.telegramConfig` / `session.wechatConfig` values - from the build-patch and channel-add paths. Those old fields remain read-only - compatibility fallback when no plan config exists. -- Removed the obsolete WeChat host-side state helper from `src/lib/onboard`. -- Added manifest-backed channel metadata helpers for: - - available channel IDs by agent - - credential env keys and env-key-to-channel lookup - - provider name suffixes and sandbox-scoped provider names - - config env keys and manifest-declared env aliases - - policy preset-to-policy-key aliases - - OpenClaw runtime channel config/log keys - - package install hook outputs -- Moved Discord config env aliases into the Discord manifest. -- Replaced `src/lib/sandbox/channels.ts` with a compatibility adapter over - built-in manifests while preserving its public exports. -- Routed the conflict-detection metadata shim, onboard env-key channel lookup, - and credentials bridge-provider suffix detection through the manifest-backed - metadata helpers. -- Migrated remaining Step 8 metadata lists to manifest-backed helpers: - - create-time messaging token provider definitions in `messaging-prep` - - reusable provider names in `messaging-reuse` - - policy preset suggestions, Hermes policy-key aliases, required create-time - presets, and messaging preset validation notes - - sandbox snapshot/provider cleanup suffixes - - credential-store known env keys and redaction env assignment keys - - messaging config env aliases -- Moved Slack's create-time required policy preset flag into the Slack - manifest. -- Moved Discord's policy validation warning text into the Discord manifest. -- Moved Telegram's OpenClaw/Hermes policy key difference into the Telegram - manifest. -- Migrated `scripts/lib/sandbox-init.sh` active-channel logging from concrete - token env checks to `NEMOCLAW_MESSAGING_PLAN_B64`. -- Migrated `scripts/nemoclaw-start.sh` provider-placeholder refresh from - concrete credential/channel maps to provider env keys discovered from the - messaging plan and current OpenClaw config. -- Replaced the install help's concrete messaging env list with a generic - messaging credential note. - -Pending: - -- Optional follow-up: relocate the existing WhatsApp in-sandbox QR probe body - from `actions/sandbox/channel-status.ts` into a channel-owned diagnostic hook - implementation if WhatsApp is pulled into required scope. diff --git a/src/lib/messaging/applier/hook-phases.test.ts b/src/lib/messaging/applier/hook-phases.test.ts index 1662a3b788..63abe8e32c 100644 --- a/src/lib/messaging/applier/hook-phases.test.ts +++ b/src/lib/messaging/applier/hook-phases.test.ts @@ -146,11 +146,44 @@ describe("messaging applier hook phases", () => { }, ]); }); + + it("stops later hooks for the skipped channel after a skip-channel failure", async () => { + const calls: string[] = []; + const runHook: MessagingHookApplyRunner = (request) => { + calls.push(`${request.channelId}:${request.hookId}`); + if (request.hookId === "telegram-pre-enable") { + throw new Error("telegram skipped"); + } + return { + hookId: request.hookId, + handlerId: request.handler, + phase: request.phase, + outputs: {}, + }; + }; + + const result = await MessagingSetupApplier.applyPreEnableChecks( + makePlan({ + telegramOnFailure: "skip-channel", + includeTelegramSecondPreEnable: true, + includeDiscordPreEnable: true, + }), + { runHook }, + ); + + expect(calls).toEqual(["telegram:telegram-pre-enable", "discord:discord-pre-enable"]); + expect(result.skippedHooks).toEqual([ + "telegram:telegram-pre-enable", + "telegram:telegram-second-pre-enable", + ]); + expect(result.appliedHooks).toEqual(["discord:discord-pre-enable"]); + }); }); function makePlan( options: { readonly telegramOnFailure?: "abort" | "skip-channel"; + readonly includeTelegramSecondPreEnable?: boolean; readonly includeDiscordPreEnable?: boolean; } = {}, ): SandboxMessagingPlan { @@ -171,6 +204,17 @@ function makePlan( ], onFailure: options.telegramOnFailure, }, + ...(options.includeTelegramSecondPreEnable + ? [ + { + channelId: "telegram" as MessagingChannelId, + id: "telegram-second-pre-enable", + phase: "pre-enable" as const, + handler: "telegram.secondPreEnable", + onFailure: "abort" as const, + }, + ] + : []), { channelId: "telegram", id: "telegram-runtime-preload", diff --git a/src/lib/messaging/applier/hook-phases.ts b/src/lib/messaging/applier/hook-phases.ts index 9898e34fdd..962303c40d 100644 --- a/src/lib/messaging/applier/hook-phases.ts +++ b/src/lib/messaging/applier/hook-phases.ts @@ -63,7 +63,12 @@ export async function applyMessagingHooksForPhase( const hookResults: MessagingHookRunResult[] = []; const appliedHooks: string[] = []; const skippedHooks: string[] = []; + const skippedChannelIds = new Set(); for (const request of hookRequests) { + if (skippedChannelIds.has(request.channelId)) { + skippedHooks.push(formatHookKey(request)); + continue; + } const requestWithInputs = withAdditionalInputs(request, options.additionalInputs); try { const result = await options.runHook?.(requestWithInputs); @@ -71,6 +76,7 @@ export async function applyMessagingHooksForPhase( hookResults.push(normalizeHookRunResult(requestWithInputs, result)); } catch (error) { if (requestWithInputs.onFailure === "skip-channel") { + skippedChannelIds.add(requestWithInputs.channelId); skippedHooks.push(formatHookKey(requestWithInputs)); continue; } diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 0c80e17377..0a11476e5e 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -431,6 +431,7 @@ describe("built-in channel manifests", () => { providerName: "{sandboxName}-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + primary: true, }, { id: "slackAppToken", diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index b582e72a99..a8adce873c 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; +import type { ChannelManifest } from "../manifest"; import { getMessagingChannelForCredentialEnvKey, getMessagingConfigEnvAliases, @@ -125,4 +126,76 @@ describe("built-in messaging channel metadata", () => { }); expect(listMessagingPackageInstallSpecs({ agent: "hermes" })).toEqual([]); }); + + it("merges duplicate policy preset metadata by preset name", () => { + const manifests: ChannelManifest[] = [ + manifestWithPreset("alpha", { + name: "shared", + policyKeys: ["alpha_key"], + agentPolicyKeys: { hermes: ["alpha_hermes"] }, + validationWarningLines: ["alpha warning"], + }), + manifestWithPreset("beta", { + name: "shared", + policyKeys: ["beta_key"], + validationWarningLines: ["beta warning"], + }), + ]; + + expect(getMessagingPolicyKeyAliases({ manifests }).shared).toEqual([ + "alpha_key", + "alpha_hermes", + "beta_key", + ]); + expect(getMessagingPolicyPresetValidationWarnings({ manifests }).shared).toEqual([ + "alpha warning", + "beta warning", + ]); + }); + + it("expands universal package-install hooks to the manifest supported agents", () => { + const manifests: ChannelManifest[] = [ + { + ...manifestWithPreset("alpha", "alpha"), + hooks: [ + { + id: "alpha-install", + phase: "agent-install", + handler: "common.staticOutputs", + outputs: [ + { + id: "alphaPackage", + kind: "package-install", + value: { manager: "npm", spec: "alpha@1.0.0" }, + }, + ], + }, + ], + }, + ]; + + expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual([ + "openclaw", + "hermes", + ]); + }); }); + +function manifestWithPreset( + id: string, + preset: NonNullable[number], +): ChannelManifest { + return { + schemaVersion: 1, + id, + displayName: id, + supportedAgents: ["openclaw", "hermes"], + auth: { mode: "none" }, + inputs: [], + credentials: [], + policyPresets: [preset], + render: [], + state: {}, + hooks: [], + }; +} diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index c52ff759a1..a90fd4cc19 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -25,6 +25,7 @@ export interface MessagingCredentialMetadata { readonly providerNameSuffix: string; readonly providerEnvKey: string; readonly placeholder: string; + readonly primary: boolean; } export interface MessagingConfigEnvMetadata { @@ -87,6 +88,7 @@ export function listMessagingCredentialMetadata( providerNameSuffix: providerNameSuffix(credential.providerName), providerEnvKey: credential.providerEnvKey, placeholder: credential.placeholder, + primary: credential.primary === true, })), ); } @@ -240,25 +242,29 @@ export function listRequiredCreateTimeMessagingPolicyPresetsByChannel( export function getMessagingPolicyKeyAliases( options: MessagingManifestMetadataOptions = {}, ): Readonly> { - return Object.fromEntries( - listMessagingPolicyPresetMetadata(options).map((preset) => [ - preset.presetName, - uniqueStrings([ - ...preset.policyKeys, - ...Object.values(preset.agentPolicyKeys).flatMap((keys) => keys ?? []), - ]), - ]), - ); + const result: Record = {}; + for (const preset of listMessagingPolicyPresetMetadata(options)) { + result[preset.presetName] = uniqueStrings([ + ...(result[preset.presetName] ?? []), + ...preset.policyKeys, + ...Object.values(preset.agentPolicyKeys).flatMap((keys) => keys ?? []), + ]); + } + return result; } export function getMessagingPolicyPresetValidationWarnings( options: MessagingManifestMetadataOptions = {}, ): Readonly> { - return Object.fromEntries( - listMessagingPolicyPresetMetadata(options) - .filter((preset) => preset.validationWarningLines.length > 0) - .map((preset) => [preset.presetName, preset.validationWarningLines]), - ); + const result: Record = {}; + for (const preset of listMessagingPolicyPresetMetadata(options)) { + if (preset.validationWarningLines.length === 0) continue; + result[preset.presetName] = uniqueStrings([ + ...(result[preset.presetName] ?? []), + ...preset.validationWarningLines, + ]); + } + return result; } export function listOpenClawRuntimeChannelMetadata( @@ -300,7 +306,7 @@ export function listMessagingPackageInstallSpecs( channelId: manifest.id, hookId: hook.id, outputId: output.id, - agents: hook.agents ?? [], + agents: hook.agents ?? manifest.supportedAgents, ...packageInstallValue(value), }, ]; diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts index 4b850181fa..1533e21647 100644 --- a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.test.ts @@ -10,7 +10,11 @@ import { slackChannel, } from "../../../../../../test/helpers/messaging-conflict-fixtures"; import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; -import { MessagingHookRegistry, runMessagingHook } from "../../../hooks"; +import { + MESSAGING_HOOK_CONFLICT_CODE, + MessagingHookRegistry, + runMessagingHook, +} from "../../../hooks"; import type { ChannelHookSpec, MessagingSerializableValue } from "../../../manifest"; import { createSlackSocketModeGatewayConflictHookRegistration, @@ -66,6 +70,9 @@ describe("slack.socketModeGatewayConflict hook", () => { "Slack Socket Mode is already enabled for sandbox 'alice' on this gateway; " + "only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.", ); + await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).rejects.toMatchObject({ + code: MESSAGING_HOOK_CONFLICT_CODE, + }); }); it("accepts serialized applier inputs for registry-scoped checks", async () => { diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts index f3cca33f1e..86bb4ce48c 100644 --- a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts @@ -7,6 +7,7 @@ import { type SlackGatewayConflict, } from "../../../applier/conflict-detection/slack-socket-mode"; import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; +import { MessagingHookConflictError } from "../../../hooks/errors"; import type { MessagingHookContext, MessagingHookHandler, @@ -50,7 +51,9 @@ export function createSlackSocketModeGatewayConflictHook( if (conflicts.length === 0) return {}; const formatConflict = options.formatConflict ?? formatSlackSocketModeConflictMessage; - throw new Error(conflicts.map(({ sandbox }) => formatConflict(sandbox)).join("\n")); + throw new MessagingHookConflictError( + conflicts.map(({ sandbox }) => formatConflict(sandbox)).join("\n"), + ); }; } diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 5c4dd6736f..6d7f6e8077 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -72,6 +72,7 @@ export const slackManifest = { providerName: "{sandboxName}-slack-bridge", providerEnvKey: "SLACK_BOT_TOKEN", placeholder: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + primary: true, }, { id: "slackAppToken", diff --git a/src/lib/messaging/hooks/errors.ts b/src/lib/messaging/hooks/errors.ts new file mode 100644 index 0000000000..3c28bd3660 --- /dev/null +++ b/src/lib/messaging/hooks/errors.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +export const MESSAGING_HOOK_CONFLICT_CODE = "MESSAGING_HOOK_CONFLICT"; + +export class MessagingHookConflictError extends Error { + readonly code = MESSAGING_HOOK_CONFLICT_CODE; + + constructor(message: string) { + super(message); + this.name = "MessagingHookConflictError"; + } +} + +export function isMessagingHookConflictError(error: unknown): error is MessagingHookConflictError { + return ( + error instanceof MessagingHookConflictError || + (typeof error === "object" && + error !== null && + "code" in error && + error.code === MESSAGING_HOOK_CONFLICT_CODE) + ); +} diff --git a/src/lib/messaging/hooks/index.ts b/src/lib/messaging/hooks/index.ts index a90e7d2d8a..0db686d5b0 100644 --- a/src/lib/messaging/hooks/index.ts +++ b/src/lib/messaging/hooks/index.ts @@ -5,4 +5,5 @@ export * from "./hook-runner"; export * from "./registry"; export * from "./common"; export * from "./builtins"; +export * from "./errors"; export type * from "./types"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index b5d5cf12de..744c00f072 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -109,6 +109,7 @@ export interface ChannelCredentialSpec { readonly providerName: MessagingTemplateString; readonly providerEnvKey: string; readonly placeholder: MessagingTemplateString; + readonly primary?: boolean; } /** Manifest render declaration for supported output formats. */ diff --git a/src/lib/messaging/status-outputs.test.ts b/src/lib/messaging/status-outputs.test.ts new file mode 100644 index 0000000000..040d403e6a --- /dev/null +++ b/src/lib/messaging/status-outputs.test.ts @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import type { ChannelManifest } from "./manifest"; +import { collectMessagingStatusOutputs } from "./status-outputs"; + +describe("messaging status outputs", () => { + it("does not collect status hooks from manifests outside the requested agent", () => { + const manifests: ChannelManifest[] = [ + { + schemaVersion: 1, + id: "hermes-only", + displayName: "Hermes Only", + supportedAgents: ["hermes"], + auth: { mode: "none" }, + inputs: [], + credentials: [], + render: [], + state: {}, + hooks: [ + { + id: "hermes-status", + phase: "status", + handler: "common.staticOutputs", + outputs: [ + { + id: "gatewayOverlap", + kind: "status", + value: { + type: "single-gateway-channel-overlap", + reason: "hermes-only", + message: "Hermes-only overlap", + }, + }, + ], + }, + ], + }, + ]; + + expect(collectMessagingStatusOutputs(manifests, { agent: "openclaw" })).toEqual([]); + expect(collectMessagingStatusOutputs(manifests, { agent: "hermes" })).toHaveLength(1); + }); +}); diff --git a/src/lib/messaging/status-outputs.ts b/src/lib/messaging/status-outputs.ts index d8a209e7c1..ecd3dd593e 100644 --- a/src/lib/messaging/status-outputs.ts +++ b/src/lib/messaging/status-outputs.ts @@ -38,7 +38,11 @@ export interface SingleGatewayChannelOverlapStatusOutput extends MessagingStatus export function collectBuiltInMessagingStatusOutputs( options: { readonly agent?: MessagingAgentId } = {}, ): MessagingStatusOutput[] { - return collectMessagingStatusOutputs(createBuiltInChannelManifestRegistry().list(), options); + const registry = createBuiltInChannelManifestRegistry(); + return collectMessagingStatusOutputs( + options.agent ? registry.listAvailable({ agent: options.agent }) : registry.list(), + options, + ); } export function collectMessagingStatusOutputs( @@ -49,6 +53,7 @@ export function collectMessagingStatusOutputs( ): MessagingStatusOutput[] { const outputs: MessagingStatusOutput[] = []; for (const manifest of manifests) { + if (options.agent && !manifest.supportedAgents.includes(options.agent)) continue; for (const hook of manifest.hooks) { if (hook.phase !== "status") continue; if (options.agent && hook.agents && !hook.agents.includes(options.agent)) continue; diff --git a/src/lib/onboard/docker-gpu-patch-wsl.test.ts b/src/lib/onboard/docker-gpu-patch-wsl.test.ts index 07582f33b6..a172f42128 100644 --- a/src/lib/onboard/docker-gpu-patch-wsl.test.ts +++ b/src/lib/onboard/docker-gpu-patch-wsl.test.ts @@ -39,4 +39,17 @@ describe("shouldApplyDockerGpuPatch on Docker Desktop WSL", () => { ), ).toBe(false); }); + + it("defaults the driver-gateway path on for Docker Desktop WSL", () => { + expect( + shouldApplyDockerGpuPatch( + { sandboxGpuEnabled: true }, + { + env: {}, + platform: "darwin", + dockerDesktopWsl: true, + }, + ), + ).toBe(true); + }); }); diff --git a/src/lib/onboard/docker-gpu-patch.ts b/src/lib/onboard/docker-gpu-patch.ts index 311b4843e3..e48bff8c98 100644 --- a/src/lib/onboard/docker-gpu-patch.ts +++ b/src/lib/onboard/docker-gpu-patch.ts @@ -528,8 +528,9 @@ export function shouldApplyDockerGpuPatch( ): boolean { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; - const dockerDriverGateway = options.dockerDriverGateway ?? platform === "linux"; const dockerDesktopWsl = options.dockerDesktopWsl === true; + const dockerDriverGateway = + options.dockerDriverGateway ?? (platform === "linux" || dockerDesktopWsl); if ( !(config.sandboxGpuEnabled && (platform === "linux" || dockerDesktopWsl) && dockerDriverGateway) ) { diff --git a/src/lib/onboard/messaging-conflict-guard.test.ts b/src/lib/onboard/messaging-conflict-guard.test.ts index 04f7593076..27f9e7561a 100644 --- a/src/lib/onboard/messaging-conflict-guard.test.ts +++ b/src/lib/onboard/messaging-conflict-guard.test.ts @@ -123,6 +123,34 @@ describe("enforceMessagingChannelConflicts — Slack Socket Mode gateway axis (# expect(error).not.toHaveBeenCalled(); }); + it("rethrows unexpected pre-enable hook infrastructure failures", async () => { + const badPlan = makePlan("bob", { + channels: [ + { + ...slackChannel(), + hooks: [ + { + ...SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK, + handler: "slack.missingHandler", + }, + ], + }, + ], + credentialBindings: [], + }); + const { deps, log, error, promptContinue } = makeDeps({ + currentPlan: badPlan, + isNonInteractive: () => false, + }); + + await expect(enforceMessagingChannelConflicts(deps as never)).rejects.toThrow( + "Missing messaging hook handler 'slack.missingHandler'", + ); + expect(log).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + expect(promptContinue).not.toHaveBeenCalled(); + }); + it("is a no-op when the current plan does not enable Slack", async () => { const { deps, log } = makeDeps({ currentPlan: makePlan("bob") }); await expect(enforceMessagingChannelConflicts(deps as never)).resolves.toBeUndefined(); diff --git a/src/lib/onboard/messaging-conflict-guard.ts b/src/lib/onboard/messaging-conflict-guard.ts index 11489c6089..e542ef3ec6 100644 --- a/src/lib/onboard/messaging-conflict-guard.ts +++ b/src/lib/onboard/messaging-conflict-guard.ts @@ -19,7 +19,11 @@ import { findChannelConflictsFromPlan, MessagingSetupApplier, } from "../messaging/applier"; -import { createBuiltInMessagingHookRegistry, runMessagingHook } from "../messaging/hooks"; +import { + createBuiltInMessagingHookRegistry, + isMessagingHookConflictError, + runMessagingHook, +} from "../messaging/hooks"; import type { SandboxMessagingPlan } from "../messaging/manifest"; export interface MessagingConflictGuardDeps { @@ -152,6 +156,7 @@ async function enforceMessagingPreEnableHooks( ), }); } catch (error) { + if (!isMessagingHookConflictError(error)) throw error; const message = error instanceof Error ? error.message : String(error); for (const line of message.split("\n").filter((entry) => entry.trim().length > 0)) { deps.log(` ⚠ ${line}`); diff --git a/src/lib/onboard/policy-presets.ts b/src/lib/onboard/policy-presets.ts index 3c612ecb03..92517cef4f 100644 --- a/src/lib/onboard/policy-presets.ts +++ b/src/lib/onboard/policy-presets.ts @@ -43,27 +43,29 @@ export function getSuggestedPolicyPresets({ const usesExplicitMessagingSelection = Array.isArray(enabledChannels); const nonInteractive = isNonInteractive?.() ?? process.env.NEMOCLAW_NON_INTERACTIVE === "1"; - const credentialsByChannel = new Map(); + const credentialsByChannel = new Map(); for (const credential of listMessagingCredentialMetadata()) { - if (!credentialsByChannel.has(credential.channelId)) { - credentialsByChannel.set(credential.channelId, credential.providerEnvKey); - } + const envKeys = credentialsByChannel.get(credential.channelId) ?? []; + envKeys.push(credential.providerEnvKey); + credentialsByChannel.set(credential.channelId, envKeys); } const maybeSuggestMessagingPreset = ( channel: string, preset: string, - envKey: string | null, + envKeys: readonly string[], ): void => { if (usesExplicitMessagingSelection) { if (enabledChannels.includes(channel)) suggestions.push(preset); return; } - if (envKey === null) return; - if (getCredential(envKey) || process.env[envKey]) { - suggestions.push(preset); - if (process.stdout.isTTY && !nonInteractive && process.env.CI !== "true") { - console.log(` Auto-detected: ${envKey} -> suggesting ${preset} preset`); + for (const envKey of envKeys) { + if (getCredential(envKey) || env[envKey]) { + if (!suggestions.includes(preset)) suggestions.push(preset); + if (process.stdout.isTTY && !nonInteractive && process.env.CI !== "true") { + console.log(` Auto-detected: ${envKey} -> suggesting ${preset} preset`); + } + return; } } }; @@ -72,7 +74,7 @@ export function getSuggestedPolicyPresets({ maybeSuggestMessagingPreset( preset.channelId, preset.presetName, - credentialsByChannel.get(preset.channelId) ?? null, + credentialsByChannel.get(preset.channelId) ?? [], ); } diff --git a/src/lib/onboard/sandbox-build-patch-config.test.ts b/src/lib/onboard/sandbox-build-patch-config.test.ts index beabcde3fd..4a16ef0aa5 100644 --- a/src/lib/onboard/sandbox-build-patch-config.test.ts +++ b/src/lib/onboard/sandbox-build-patch-config.test.ts @@ -11,6 +11,7 @@ describe("prepareSandboxBuildPatchConfig", () => { TELEGRAM_ALLOWED_IDS: "123,456", SLACK_ALLOWED_USERS: "U01ABC2DEF3", SLACK_ALLOWED_CHANNELS: "C012AB3CD,C987ZY6XW", + WECHAT_ALLOWED_IDS: "wxid-unused", })); const env = { TELEGRAM_ALLOWED_IDS: "123,456", diff --git a/src/lib/onboard/sandbox-build-patch-config.ts b/src/lib/onboard/sandbox-build-patch-config.ts index ea86a2ccaa..42159a552d 100644 --- a/src/lib/onboard/sandbox-build-patch-config.ts +++ b/src/lib/onboard/sandbox-build-patch-config.ts @@ -5,6 +5,7 @@ import { type MessagingChannelConfig, readMessagingChannelConfigFromEnv, } from "../messaging-channel-config"; +import { listMessagingConfigEnvMetadata } from "../messaging/channels"; export type SandboxBuildPatchConfig = { messagingChannelConfig: MessagingChannelConfig | null; @@ -21,6 +22,7 @@ export type PrepareSandboxBuildPatchConfigInput = { }; export function prepareSandboxBuildPatchConfig({ + configuredMessagingChannels = [], env = process.env, deps = {}, }: PrepareSandboxBuildPatchConfigInput): SandboxBuildPatchConfig { @@ -31,6 +33,27 @@ export function prepareSandboxBuildPatchConfig({ deps.readMessagingChannelConfigFromEnv ?? readMessagingChannelConfigFromEnv )(env); return { - messagingChannelConfig, + messagingChannelConfig: filterMessagingChannelConfig( + messagingChannelConfig, + configuredMessagingChannels, + ), }; } + +function filterMessagingChannelConfig( + config: MessagingChannelConfig | null, + configuredMessagingChannels: readonly string[], +): MessagingChannelConfig | null { + if (!config) return null; + const configured = new Set(configuredMessagingChannels); + if (configured.size === 0) return null; + const allowedKeys = new Set( + listMessagingConfigEnvMetadata() + .filter((metadata) => configured.has(metadata.channelId)) + .map((metadata) => metadata.envKey), + ); + const filtered = Object.fromEntries( + Object.entries(config).filter(([key]) => allowedKeys.has(key)), + ); + return Object.keys(filtered).length > 0 ? filtered : null; +} diff --git a/src/lib/onboard/sandbox-create-plan.ts b/src/lib/onboard/sandbox-create-plan.ts index b4a630f3ce..9c41d86b5f 100644 --- a/src/lib/onboard/sandbox-create-plan.ts +++ b/src/lib/onboard/sandbox-create-plan.ts @@ -1,7 +1,10 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { listMessagingCredentialMetadata } from "../messaging/channels"; +import { + type MessagingCredentialMetadata, + listMessagingCredentialMetadata, +} from "../messaging/channels"; import type { InitialSandboxPolicy } from "./initial-policy"; import type { MessagingChannel } from "./messaging-state"; import { resolveQrSelectedChannels } from "./messaging-state"; @@ -109,16 +112,33 @@ function resolveActiveMessagingChannels({ } function getPrimaryCredentialEnvKeys(): Set { - const seenChannels = new Set(); - const envKeys = new Set(); + const credentialsByChannel = new Map(); for (const credential of listMessagingCredentialMetadata()) { - if (seenChannels.has(credential.channelId)) continue; - seenChannels.add(credential.channelId); - envKeys.add(credential.providerEnvKey); + const credentials = credentialsByChannel.get(credential.channelId) ?? []; + credentials.push(credential); + credentialsByChannel.set(credential.channelId, credentials); + } + + const envKeys = new Set(); + for (const credentials of credentialsByChannel.values()) { + const primary = + credentials.find((credential) => credential.primary) ?? + [...credentials].sort(compareCredentialsForPrimarySelection)[0]; + if (primary) envKeys.add(primary.providerEnvKey); } return envKeys; } +function compareCredentialsForPrimarySelection( + left: MessagingCredentialMetadata, + right: MessagingCredentialMetadata, +): number { + return ( + left.credentialId.localeCompare(right.credentialId) || + left.providerEnvKey.localeCompare(right.providerEnvKey) + ); +} + export function prepareSandboxCreatePlan({ basePolicyPath, buildCtx, diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 479385ea78..4d8ad87b64 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -50,6 +50,8 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { it("omits envKey for in-sandbox QR-paired channels (whatsapp)", () => { expect(getChannelDef("whatsapp")?.envKey).toBeUndefined(); + expect(getChannelDef("whatsapp")?.userIdEnvKey).toBe("WHATSAPP_ALLOWED_IDS"); + expect(getChannelDef("whatsapp")?.allowIdsMode).toBe("dm"); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.whatsapp)).toBe(true); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.wechat)).toBe(false); expect(channelUsesInSandboxQrPairing(KNOWN_CHANNELS.slack)).toBe(false); diff --git a/src/lib/sandbox/channels.ts b/src/lib/sandbox/channels.ts index 7962cc8b90..d7d40bd32f 100644 --- a/src/lib/sandbox/channels.ts +++ b/src/lib/sandbox/channels.ts @@ -124,7 +124,7 @@ function channelDefFromManifest(manifest: ChannelManifest): ChannelDef { `${manifest.displayName} messaging`, label: primaryInput?.prompt?.label ?? manifest.displayName, ...(manifest.enrollmentNotes ? { setupNotes: manifest.enrollmentNotes } : {}), - ...(manifest.auth.mode === "in-sandbox-qr" ? {} : configFieldMetadata(manifest)), + ...configFieldMetadata(manifest), }; if (manifest.auth.mode === "in-sandbox-qr") { diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 2d4e75c674..2ff261f1bf 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -15,9 +15,11 @@ import type { ShowStatusCommandDeps, } from "./inventory"; import { findAllOverlaps } from "./messaging/applier"; +import type { MessagingAgentId } from "./messaging/manifest"; import { getActiveChannelIdsFromPlan } from "./messaging/plan-validation"; import { collectBuiltInMessagingStatusOutputs, + type MessagingStatusOutput, type GatewayLogConflictCounterStatusOutput, type SingleGatewayChannelOverlapStatusOutput, } from "./messaging/status-outputs"; @@ -26,8 +28,6 @@ import * as registry from "./state/registry"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; -const STATUS_OUTPUTS = collectBuiltInMessagingStatusOutputs(); - function captureOpenshell( rootDir: string, args: string[], @@ -48,11 +48,12 @@ function checkMessagingBridgeHealth( rootDir: string, sandboxName: string, channels: string[], + agent: string | null | undefined = "openclaw", ): MessagingBridgeHealth[] { const channelSet = new Set(Array.isArray(channels) ? channels : []); - const specs = STATUS_OUTPUTS.filter(isGatewayLogConflictCounterStatusOutput).filter((spec) => - channelSet.has(spec.channelId), - ); + const specs = getStatusOutputsForAgent(agent) + .filter(isGatewayLogConflictCounterStatusOutput) + .filter((spec) => channelSet.has(spec.channelId)); if (specs.length === 0) return []; const openshell = resolveOpenshell(); if (!openshell) return []; @@ -89,9 +90,9 @@ function findMessagingOverlaps() { const credentialOverlaps = findAllOverlaps({ listSandboxes: () => ({ sandboxes }), }); - const singleGatewayOverlaps = STATUS_OUTPUTS.filter( - isSingleGatewayChannelOverlapStatusOutput, - ).flatMap((spec) => detectSingleGatewayChannelOverlaps(sandboxes, spec)); + const singleGatewayOverlaps = listSingleGatewayOverlapSpecsForEntries(sandboxes).flatMap( + ({ spec, agents }) => detectSingleGatewayChannelOverlaps(sandboxes, spec, agents), + ); return [...credentialOverlaps, ...singleGatewayOverlaps]; } catch { return []; @@ -99,17 +100,25 @@ function findMessagingOverlaps() { } function isGatewayLogConflictCounterStatusOutput( - output: (typeof STATUS_OUTPUTS)[number], + output: MessagingStatusOutput, ): output is GatewayLogConflictCounterStatusOutput { return output.type === "gateway-log-conflict-counter"; } function isSingleGatewayChannelOverlapStatusOutput( - output: (typeof STATUS_OUTPUTS)[number], + output: MessagingStatusOutput, ): output is SingleGatewayChannelOverlapStatusOutput { return output.type === "single-gateway-channel-overlap"; } +function getStatusOutputsForAgent(agent: string | null | undefined): MessagingStatusOutput[] { + return collectBuiltInMessagingStatusOutputs({ agent: normalizeMessagingAgentId(agent) }); +} + +function normalizeMessagingAgentId(agent: string | null | undefined): MessagingAgentId { + return agent === "hermes" ? "hermes" : "openclaw"; +} + function readSandboxFileTail( rootDir: string, openshell: string, @@ -146,9 +155,11 @@ function countRegexMatchesByLine(logTail: string, pattern: string, flags: string function detectSingleGatewayChannelOverlaps( entries: readonly registry.SandboxEntry[], spec: SingleGatewayChannelOverlapStatusOutput, + agents: ReadonlySet, ): MessagingOverlap[] { const byGateway = new Map(); for (const entry of entries) { + if (!agents.has(normalizeMessagingAgentId(entry.agent))) continue; if (!entry.messaging?.plan) continue; if (!getActiveChannelIdsFromPlan(entry.messaging.plan).includes(spec.channelId)) continue; const gatewayName = entry.gatewayName ?? BASE_GATEWAY_NAME; @@ -174,6 +185,31 @@ function detectSingleGatewayChannelOverlaps( return overlaps; } +function listSingleGatewayOverlapSpecsForEntries(entries: readonly registry.SandboxEntry[]): Array<{ + readonly spec: SingleGatewayChannelOverlapStatusOutput; + readonly agents: ReadonlySet; +}> { + const byKey = new Map< + string, + { spec: SingleGatewayChannelOverlapStatusOutput; agents: Set } + >(); + for (const entry of entries) { + const agent = normalizeMessagingAgentId(entry.agent); + for (const spec of getStatusOutputsForAgent(agent).filter( + isSingleGatewayChannelOverlapStatusOutput, + )) { + const key = `${spec.channelId}\0${spec.reason}\0${spec.message}`; + const existing = byKey.get(key); + if (existing) { + existing.agents.add(agent); + } else { + byKey.set(key, { spec, agents: new Set([agent]) }); + } + } + } + return [...byKey.values()]; +} + function shellQuote(value: string): string { return `'${value.replace(/'/g, "'\\''")}'`; } @@ -269,8 +305,8 @@ export function buildStatusCommandDeps(rootDir: string): ShowStatusCommandDeps { } } : undefined, - checkMessagingBridgeHealth: (sandboxName, channels) => - checkMessagingBridgeHealth(rootDir, sandboxName, channels), + checkMessagingBridgeHealth: (sandboxName, channels, agent) => + checkMessagingBridgeHealth(rootDir, sandboxName, channels, agent), findMessagingOverlaps, readGatewayLog: (sandboxName) => readGatewayLog(rootDir, sandboxName), log: console.log, diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts new file mode 100644 index 0000000000..0729b384ba --- /dev/null +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); + +function messagingRuntimePreloadSection(src: string, planPath: string): string { + const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); + const end = src.indexOf("_read_gateway_token()", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return src + .slice(start, end) + .replace( + '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', + `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(planPath)}`, + ); +} + +function encodeRuntimePreloadPlan(channelId: string, value: Record): string { + return Buffer.from( + JSON.stringify({ + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active: true, + selected: true, + configured: true, + disabled: false, + inputs: [], + hooks: [ + { + channelId, + id: `${channelId}-runtime-preload`, + phase: "runtime-preload", + handler: "common.staticOutputs", + agents: ["openclaw"], + outputs: [ + { + id: "runtimePreload", + kind: "runtime-preload", + required: true, + value, + }, + ], + onFailure: "abort", + }, + ], + }, + ], + disabledChannels: [], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + stateUpdates: [], + healthChecks: [], + }), + ).toString("base64"); +} + +describe("messaging runtime env aliases", () => { + it("uses Python regex semantics consistently when applying aliases", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-env-alias-")); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const scriptPath = path.join(tmpDir, "run.sh"); + const runtimeValue = { + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "(?<=openshell:)resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + message: "[channels] normalized Slack alias", + }, + ], + }; + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, + messagingRuntimePreloadSection(src, planPath), + "write_messaging_runtime_preload_plan", + "apply_messaging_runtime_env_aliases", + 'printf "SLACK_BOT_TOKEN=%s\\n" "$SLACK_BOT_TOKEN"', + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: { + ...process.env, + SLACK_BOT_TOKEN: "openshell:resolve:env:v42_SLACK_BOT_TOKEN", + }, + timeout: 5000, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toContain("SLACK_BOT_TOKEN=xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN"); + expect(result.stderr).toContain("[channels] normalized Slack alias"); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 0feabc1eab..0f42fdd2ff 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -431,7 +431,7 @@ describe("nemoclaw-start non-root fallback", () => { } }); - it("executes explicit non-root commands before gateway startup setup", () => { + it("runs runtime preloads and scans before explicit non-root commands", () => { const src = fs.readFileSync(START_SCRIPT, "utf-8"); const script = [ "set -euo pipefail", @@ -456,20 +456,20 @@ describe("nemoclaw-start non-root fallback", () => { "lock_rc_files() { :; }", "apply_messaging_runtime_env_aliases() { :; }", 'configure_messaging_channels() { echo "SHOULD_NOT_CONFIGURE"; exit 70; }', - 'install_messaging_runtime_preloads() { echo "SHOULD_NOT_INSTALL"; exit 71; }', - 'verify_messaging_runtime_secret_scans() { echo "SHOULD_NOT_VERIFY"; exit 74; }', + 'install_messaging_runtime_preloads() { echo "ORDER:install"; }', + 'verify_messaging_runtime_secret_scans() { echo "ORDER:verify"; }', "seed_default_workspace_templates() { :; }", "_SANDBOX_HOME=/sandbox", "NEMOCLAW_CMD=(bash -c 'echo EXPLICIT_COMMAND; exit 23')", nonRootFallbackBlock(src), 'echo "SHOULD_NOT_REACH"', ].join("\n"); - const result = spawnSync("bash", ["-c", script], { encoding: "utf-8", timeout: 5000 }); expect(result.status).toBe(23); expect(result.stdout).toContain("EXPLICIT_COMMAND"); - expect(result.stdout).not.toContain("SHOULD_NOT"); + expect(result.stdout).toMatch(/ORDER:install[\s\S]*ORDER:verify[\s\S]*EXPLICIT_COMMAND/); + expect(result.stdout).not.toContain("SHOULD_NOT_CONFIGURE"); }); it("#3256: only requires early gateway token generation for gateway and OpenClaw commands", () => { diff --git a/test/onboard-policy-suggestions.test.ts b/test/onboard-policy-suggestions.test.ts index c6a17509ec..565c214d0d 100644 --- a/test/onboard-policy-suggestions.test.ts +++ b/test/onboard-policy-suggestions.test.ts @@ -113,6 +113,22 @@ describe("onboard policy preset suggestions", () => { } }); + it("auto-detects messaging policy presets from secondary channel credentials", () => { + const originalSlackBotToken = process.env.SLACK_BOT_TOKEN; + const originalSlackAppToken = process.env.SLACK_APP_TOKEN; + try { + delete process.env.SLACK_BOT_TOKEN; + process.env.SLACK_APP_TOKEN = "xapp-secondary"; + + expect(getSuggestedPolicyPresets()).toContain("slack"); + } finally { + if (originalSlackBotToken === undefined) delete process.env.SLACK_BOT_TOKEN; + else process.env.SLACK_BOT_TOKEN = originalSlackBotToken; + if (originalSlackAppToken === undefined) delete process.env.SLACK_APP_TOKEN; + else process.env.SLACK_APP_TOKEN = originalSlackAppToken; + } + }); + it("suggests local-inference preset for local providers only", () => { const ollamaPresets = getSuggestedPolicyPresets({ provider: "ollama-local" }); expect(ollamaPresets).toContain("local-inference"); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index 579c89d7dd..cdd3e89b12 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -865,7 +865,7 @@ EOF it("returns silently when no messaging plan is set", () => { const { stderr } = runWithLib("configure_messaging_channels", { - env: {}, + env: { NEMOCLAW_MESSAGING_PLAN_B64: "" }, }); expect(stderr).not.toContain("[channels]"); }); From f761d0b219d612057e519ffa017d139b1aa19e03 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 13 Jun 2026 21:26:50 +0700 Subject: [PATCH 04/23] refactor(messaging): simplify channel hook manifests --- Dockerfile | 3 +- ci/env-var-doc-allowlist.json | 4 + ci/test-file-size-budget.json | 2 +- scripts/nemoclaw-start.sh | 191 ++++++----- .../sandbox/policy-channel-conflict.test.ts | 8 +- src/lib/actions/sandbox/policy-channel.ts | 301 +----------------- ...hermes-secret-boundary-behavioural.test.ts | 4 +- src/lib/channel-runtime-status.ts | 28 +- src/lib/messaging/applier/hook-phases.test.ts | 25 +- src/lib/messaging/applier/hook-phases.ts | 7 - .../messaging/applier/host-state-applier.ts | 14 +- .../messaging/applier/setup-applier.test.ts | 10 +- src/lib/messaging/applier/setup-applier.ts | 25 +- .../messaging/channels/discord/hooks/index.ts | 18 ++ .../discord/hooks/openclaw-bridge-health.ts | 23 ++ .../messaging/channels/discord/manifest.ts | 76 ++--- src/lib/messaging/channels/manifests.test.ts | 164 +++++----- src/lib/messaging/channels/metadata.test.ts | 24 +- src/lib/messaging/channels/metadata.ts | 95 ++---- .../channels/openclaw-bridge-health.test.ts | 84 +++++ .../channels/openclaw-bridge-health.ts | 155 +++++++++ .../messaging/channels/slack/hooks/index.ts | 16 + .../slack/hooks/openclaw-bridge-health.ts | 23 ++ .../hooks/socket-mode-gateway-status.test.ts | 110 +++++++ .../slack/hooks/socket-mode-gateway-status.ts | 106 ++++++ src/lib/messaging/channels/slack/manifest.ts | 172 ++++------ .../slack/runtime}/slack-channel-guard.js | 0 .../hooks/gateway-conflict-status.test.ts | 65 ++++ .../telegram/hooks/gateway-conflict-status.ts | 107 +++++++ .../telegram/hooks/get-me-reachability.ts | 17 +- .../channels/telegram/hooks/index.ts | 4 +- .../telegram/hooks/openclaw-bridge-health.ts | 67 ++++ .../messaging/channels/telegram/manifest.ts | 109 ++----- .../telegram/runtime}/telegram-diagnostics.js | 0 src/lib/messaging/channels/wechat/manifest.ts | 92 ++---- .../wechat/runtime}/wechat-diagnostics.js | 0 .../messaging/channels/whatsapp/manifest.ts | 93 ++---- .../whatsapp/runtime}/whatsapp-qr-compact.js | 0 .../compiler/engines/build-step-engine.ts | 17 + .../compiler/engines/health-check-engine.ts | 8 +- .../compiler/engines/runtime-setup-engine.ts | 72 +++++ .../compiler/manifest-compiler.test.ts | 55 ++-- .../messaging/compiler/manifest-compiler.ts | 3 + .../messaging/compiler/workflow-planner.ts | 31 ++ src/lib/messaging/hooks/builtins.ts | 33 +- src/lib/messaging/hooks/hook-runner.test.ts | 90 +++++- src/lib/messaging/hooks/hook-runner.ts | 47 ++- src/lib/messaging/index.ts | 1 - src/lib/messaging/manifest/types.ts | 77 ++++- src/lib/messaging/plan-validation.ts | 11 + src/lib/messaging/status-outputs.test.ts | 46 --- src/lib/messaging/status-outputs.ts | 134 -------- src/lib/status-command-deps.test.ts | 2 +- src/lib/status-command-deps.ts | 290 ++++++++++------- test/cli/list-share-live-inference.test.ts | 18 +- test/cli/logs.test.ts | 2 +- .../live/whatsapp-qr-compact.test.ts | 11 +- test/local-slack-auth-test.sh | 5 +- test/nemoclaw-start-runtime-env-alias.test.ts | 44 ++- test/nemoclaw-start-slack-runtime.test.ts | 44 ++- test/nemoclaw-start-telegram-runtime.test.ts | 170 ++++++++++ test/nemoclaw-start.test.ts | 151 +++------ test/telegram-diagnostics.test.ts | 14 +- test/wechat-diagnostics.test.ts | 14 +- test/whatsapp-qr-compact.test.ts | 8 +- 65 files changed, 2079 insertions(+), 1561 deletions(-) create mode 100644 src/lib/messaging/channels/discord/hooks/index.ts create mode 100644 src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts create mode 100644 src/lib/messaging/channels/openclaw-bridge-health.test.ts create mode 100644 src/lib/messaging/channels/openclaw-bridge-health.ts create mode 100644 src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts create mode 100644 src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.test.ts create mode 100644 src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts rename {nemoclaw-blueprint/scripts => src/lib/messaging/channels/slack/runtime}/slack-channel-guard.js (100%) create mode 100644 src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.test.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts create mode 100644 src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts rename {nemoclaw-blueprint/scripts => src/lib/messaging/channels/telegram/runtime}/telegram-diagnostics.js (100%) rename {nemoclaw-blueprint/scripts => src/lib/messaging/channels/wechat/runtime}/wechat-diagnostics.js (100%) rename {nemoclaw-blueprint/scripts => src/lib/messaging/channels/whatsapp/runtime}/whatsapp-qr-compact.js (100%) create mode 100644 src/lib/messaging/compiler/engines/runtime-setup-engine.ts delete mode 100644 src/lib/messaging/status-outputs.test.ts delete mode 100644 src/lib/messaging/status-outputs.ts create mode 100644 test/nemoclaw-start-telegram-runtime.test.ts diff --git a/Dockerfile b/Dockerfile index aa9fdf4d26..f13afba878 100644 --- a/Dockerfile +++ b/Dockerfile @@ -528,8 +528,9 @@ COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_r COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # Copy NODE_OPTIONS preload modules to a Landlock-accessible path. OpenShell ≥0.0.36 # blocks /opt/nemoclaw-blueprint/ from non-root users, but the entrypoint -# needs to read these files to install runtime preloads under /tmp. +# needs to read these files to install Node runtime preloads under /tmp. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ +COPY src/lib/messaging/channels/*/runtime/*.js /usr/local/lib/nemoclaw/preloads/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.mts /scripts/generate-openclaw-config.mts COPY src/lib/messaging/ /src/lib/messaging/ diff --git a/ci/env-var-doc-allowlist.json b/ci/env-var-doc-allowlist.json index c04df7defc..3ec89d7a38 100644 --- a/ci/env-var-doc-allowlist.json +++ b/ci/env-var-doc-allowlist.json @@ -35,6 +35,10 @@ "name": "NEMOCLAW_TEST_NO_SLEEP", "reason": "Test sentinel that bypasses real-time sleep() calls in onboard inference probes. Set to '1' only by Vitest tests; never user-set." }, + { + "name": "NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS", + "reason": "Internal Vitest-only override that shortens the Telegram diagnostics startup-grace timer. Production uses the built-in default." + }, { "name": "NEMOCLAW_E2E_FAILURE_INJECTION", "reason": "Internal E2E-only sentinel that enables deterministic onboarding fault injection for resume/repair scripts. Never user-set in production." diff --git a/ci/test-file-size-budget.json b/ci/test-file-size-budget.json index a0561d9306..15dc4b3f5a 100644 --- a/ci/test-file-size-budget.json +++ b/ci/test-file-size-budget.json @@ -8,7 +8,7 @@ "test/channels-add-preset.test.ts": 1871, "test/generate-openclaw-config.test.ts": 1990, "test/install-preflight.test.ts": 4207, - "test/nemoclaw-start.test.ts": 5230, + "test/nemoclaw-start.test.ts": 5163, "test/onboard-messaging.test.ts": 2063, "test/onboard-selection.test.ts": 6891, "test/onboard.test.ts": 4774, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 63c27970d1..f99bedefdc 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1418,29 +1418,29 @@ PYPLACEHOLDERS [ "$_write_rc" -eq 0 ] || return "$_write_rc" } -# ── Messaging runtime preloads from manifest hooks ─────────────── -# Channel-owned runtime-preload hooks are serialized into -# NEMOCLAW_MESSAGING_PLAN_B64 at image build time. The entrypoint only consumes -# generic declarations: envAliases, preloads, and secretScans. -_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json" +# ── Messaging runtime setup from manifest metadata ─────────────── +# Channel-owned runtime setup is serialized into NEMOCLAW_MESSAGING_PLAN_B64 at +# image build time. The entrypoint only consumes generic declarations: +# envAliases, nodePreloads, and secretScans. +_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" _MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" -write_messaging_runtime_preload_plan() { - python3 - <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_PRELOAD_PLAN" +write_messaging_runtime_setup_plan() { + python3 - <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN" import base64 import json import os import re import sys -EMPTY = {"preloads": [], "envAliases": [], "secretScans": []} +EMPTY = {"nodePreloads": [], "envAliases": [], "secretScans": []} PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/" PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-" ENV_KEY_RE = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") def fail(message): - print(f"[channels] Invalid messaging runtime preload plan: {message}", file=sys.stderr) + print(f"[channels] Invalid messaging runtime setup plan: {message}", file=sys.stderr) raise SystemExit(1) @@ -1464,34 +1464,34 @@ def clean_message(value, field): return value -def clean_preload(entry, index): +def clean_node_preload(entry, index): if not isinstance(entry, dict): - fail(f"preloads[{index}] must be an object") - source = clean_string(entry.get("source"), f"preloads[{index}].source") - target = clean_string(entry.get("target"), f"preloads[{index}].target") + fail(f"nodePreloads[{index}] must be an object") + source = clean_string(entry.get("source"), f"nodePreloads[{index}].source") + target = clean_string(entry.get("target"), f"nodePreloads[{index}].target") if not source.startswith(PRELOAD_SOURCE_PREFIX) or not source.endswith(".js"): - fail(f"preloads[{index}].source must be a preload JavaScript file under {PRELOAD_SOURCE_PREFIX}") + fail(f"nodePreloads[{index}].source must be a preload JavaScript file under {PRELOAD_SOURCE_PREFIX}") if not target.startswith(PRELOAD_TARGET_PREFIX) or not target.endswith(".js"): - fail(f"preloads[{index}].target must be a JavaScript file under {PRELOAD_TARGET_PREFIX}*") - node_options = entry.get("nodeOptions", []) - if not isinstance(node_options, list): - fail(f"preloads[{index}].nodeOptions must be a list") - normalized_options = [] - for option in node_options: - if option not in ("boot", "connect"): - fail(f"preloads[{index}].nodeOptions contains unsupported value {option!r}") - if option not in normalized_options: - normalized_options.append(option) + fail(f"nodePreloads[{index}].target must be a JavaScript file under {PRELOAD_TARGET_PREFIX}*") + inject_into = entry.get("injectInto", []) + if not isinstance(inject_into, list): + fail(f"nodePreloads[{index}].injectInto must be a list") + normalized_scopes = [] + for scope in inject_into: + if scope not in ("boot", "connect"): + fail(f"nodePreloads[{index}].injectInto contains unsupported value {scope!r}") + if scope not in normalized_scopes: + normalized_scopes.append(scope) optional = entry.get("optional", False) if not isinstance(optional, bool): - fail(f"preloads[{index}].optional must be a boolean") + fail(f"nodePreloads[{index}].optional must be a boolean") return { "source": source, "target": target, - "nodeOptions": normalized_options, + "injectInto": normalized_scopes, "optional": optional, - "installMessage": clean_message(entry.get("installMessage"), f"preloads[{index}].installMessage"), - "installedMessage": clean_message(entry.get("installedMessage"), f"preloads[{index}].installedMessage"), + "installMessage": clean_message(entry.get("installMessage"), f"nodePreloads[{index}].installMessage"), + "installedMessage": clean_message(entry.get("installedMessage"), f"nodePreloads[{index}].installedMessage"), } @@ -1548,55 +1548,78 @@ except Exception as exc: if not isinstance(plan, dict): fail("decoded plan must be an object") -preloads = [] -env_aliases = [] -secret_scans = [] -seen_preloads = set() -seen_aliases = set() -seen_scans = set() - +disabled_channels = { + channel_id + for channel_id in plan.get("disabledChannels", []) + if isinstance(channel_id, str) +} +active_channel_ids = set() for channel in plan.get("channels", []): if not isinstance(channel, dict): continue - if channel.get("active") is not True or channel.get("disabled") is True: + channel_id = channel.get("channelId") + if not isinstance(channel_id, str): continue - for hook in channel.get("hooks", []): - if not isinstance(hook, dict) or hook.get("phase") != "runtime-preload": + if channel.get("active") is True and channel.get("disabled") is not True and channel_id not in disabled_channels: + active_channel_ids.add(channel_id) + +runtime_setup = plan.get("runtimeSetup", EMPTY) +if runtime_setup is None: + runtime_setup = EMPTY +if not isinstance(runtime_setup, dict): + fail("runtimeSetup must be an object") + + +def runtime_setup_entries(key): + entries = runtime_setup.get(key, []) + if not isinstance(entries, list): + fail(f"runtimeSetup.{key} must be a list") + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + fail(f"runtimeSetup.{key}[{index}] must be an object") + channel_id = entry.get("channelId") + if not isinstance(channel_id, str) or not channel_id: + fail(f"runtimeSetup.{key}[{index}].channelId must be a string") + if channel_id not in active_channel_ids: continue - for output in hook.get("outputs", []): - if not isinstance(output, dict) or output.get("kind") != "runtime-preload": - continue - value = output.get("value") - if not isinstance(value, dict): - fail(f"{channel.get('channelId', '')}.{hook.get('id', '')}.{output.get('id', '')} value must be an object") - for entry in value.get("preloads", []): - preload = clean_preload(entry, len(preloads)) - preload_key = (preload["source"], preload["target"]) - if preload_key not in seen_preloads: - seen_preloads.add(preload_key) - preloads.append(preload) - for entry in value.get("envAliases", []): - alias = clean_env_alias(entry, len(env_aliases)) - alias_key = (alias["envKey"], alias["match"], alias["value"]) - if alias_key not in seen_aliases: - seen_aliases.add(alias_key) - env_aliases.append(alias) - for entry in value.get("secretScans", []): - scan = clean_secret_scan(entry, len(secret_scans)) - scan_key = (scan["path"], scan["pattern"]) - if scan_key not in seen_scans: - seen_scans.add(scan_key) - secret_scans.append(scan) - -print(json.dumps({"preloads": preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) + yield entry + + +node_preloads = [] +env_aliases = [] +secret_scans = [] +seen_node_preloads = set() +seen_aliases = set() +seen_scans = set() + +for entry in runtime_setup_entries("nodePreloads"): + preload = clean_node_preload(entry, len(node_preloads)) + preload_key = (preload["source"], preload["target"]) + if preload_key not in seen_node_preloads: + seen_node_preloads.add(preload_key) + node_preloads.append(preload) +for entry in runtime_setup_entries("envAliases"): + alias = clean_env_alias(entry, len(env_aliases)) + alias_key = (alias["envKey"], alias["match"], alias["value"]) + if alias_key not in seen_aliases: + seen_aliases.add(alias_key) + env_aliases.append(alias) +for entry in runtime_setup_entries("secretScans"): + scan = clean_secret_scan(entry, len(secret_scans)) + scan_key = (scan["path"], scan["pattern"]) + if scan_key not in seen_scans: + seen_scans.add(scan_key) + secret_scans.append(scan) + +print(json.dumps({"nodePreloads": node_preloads, "envAliases": env_aliases, "secretScans": secret_scans}, sort_keys=True)) PYMESSAGINGRUNTIME } apply_messaging_runtime_env_aliases() { - [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 local _rows _rows="$( - python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGALIASES' + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGALIASES' import json import os import re @@ -1624,20 +1647,20 @@ PYMESSAGINGALIASES } install_messaging_runtime_preloads() { - [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 local _rows _rows="$( - python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGPRELOADS' + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGPRELOADS' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: plan = json.load(handle) -for preload in plan.get("preloads", []): +for preload in plan.get("nodePreloads", []): print("\t".join([ preload["source"], preload["target"], - ",".join(preload.get("nodeOptions", [])), + ",".join(preload.get("injectInto", [])), "1" if preload.get("optional") else "0", preload.get("installMessage", ""), preload.get("installedMessage", ""), @@ -1647,8 +1670,8 @@ PYMESSAGINGPRELOADS local _connect_preloads=() if [ -n "$_rows" ]; then - local _source _target _node_options _optional _install_message _installed_message - while IFS=$'\t' read -r _source _target _node_options _optional _install_message _installed_message; do + local _source _target _inject_into _optional _install_message _installed_message + while IFS=$'\t' read -r _source _target _inject_into _optional _install_message _installed_message; do if [ ! -f "$_source" ]; then [ "$_optional" = "1" ] && continue printf '[channels] Missing runtime preload source: %s\n' "$_source" >&2 @@ -1656,12 +1679,12 @@ PYMESSAGINGPRELOADS fi [ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2 emit_sandbox_sourced_file "$_target" <"$_source" - case ",$_node_options," in + case ",$_inject_into," in *,boot,*) export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_target" ;; esac - case ",$_node_options," in + case ",$_inject_into," in *,connect,*) _connect_preloads+=("$_target") ;; @@ -1690,15 +1713,15 @@ CONNECTPRELOADSEOF } messaging_runtime_preload_targets() { - printf '%s\n' "$_MESSAGING_RUNTIME_PRELOAD_PLAN" "$_MESSAGING_CONNECT_PRELOADS_FILE" - [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 - python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGTARGETS' + printf '%s\n' "$_MESSAGING_RUNTIME_SETUP_PLAN" "$_MESSAGING_CONNECT_PRELOADS_FILE" + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGTARGETS' import json import sys with open(sys.argv[1], encoding="utf-8") as handle: plan = json.load(handle) -for preload in plan.get("preloads", []): +for preload in plan.get("nodePreloads", []): target = preload.get("target") if target: print(target) @@ -1716,8 +1739,8 @@ validate_nemoclaw_tmp_permissions() { } verify_messaging_runtime_secret_scans() { - [ -f "$_MESSAGING_RUNTIME_PRELOAD_PLAN" ] || return 0 - python3 - "$_MESSAGING_RUNTIME_PRELOAD_PLAN" <<'PYMESSAGINGSECRETS' + [ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0 + python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGSECRETS' import json import re import sys @@ -2316,7 +2339,7 @@ fi # that could not catch follow-redirects + proxy-from-env bundled as ESM # in OpenClaw's dist/ (no require() calls to intercept). # -# Runtime preload modules are copied into /usr/local/lib/nemoclaw/preloads/ +# Node runtime preload modules are copied into /usr/local/lib/nemoclaw/preloads/ # at image build time, then copied to /tmp before NODE_OPTIONS=--require so # the sandbox user can read them under Landlock-constrained runtimes. # ── Global sandbox safety net ────────────────────────────────── @@ -3564,7 +3587,7 @@ if [ "$(id -u)" -ne 0 ]; then # actually runs with. write_openclaw_config_baseline export_gateway_token - write_messaging_runtime_preload_plan + write_messaging_runtime_setup_plan write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" || true @@ -3719,7 +3742,7 @@ prepare_gateway_token_for_current_command # actually runs with. write_openclaw_config_baseline export_gateway_token -write_messaging_runtime_preload_plan +write_messaging_runtime_setup_plan write_runtime_shell_env ensure_runtime_shell_env_shim lock_rc_files "$_SANDBOX_HOME" @@ -3730,7 +3753,7 @@ apply_messaging_runtime_env_aliases # Messaging channel config was announced before placeholder refresh so the # baseline captures the same provider placeholders the gateway will use. -# Install manifest-declared runtime preloads before starting OpenClaw. +# Install manifest-declared Node runtime preloads before starting OpenClaw. install_messaging_runtime_preloads verify_messaging_runtime_secret_scans diff --git a/src/lib/actions/sandbox/policy-channel-conflict.test.ts b/src/lib/actions/sandbox/policy-channel-conflict.test.ts index 363cbfc761..05f004dd1b 100644 --- a/src/lib/actions/sandbox/policy-channel-conflict.test.ts +++ b/src/lib/actions/sandbox/policy-channel-conflict.test.ts @@ -210,11 +210,11 @@ beforeEach(() => { // Downstream rebuild is not under test. vi.spyOn(rebuild, "rebuildSandbox").mockResolvedValue(undefined); - // After a successful interactive add, manifest health-check hooks can probe + // After a successful interactive add, channel health-check hooks can probe // the sandbox via executeSandboxExecCommand, which calls getOpenshellBinary() // -> process.exit(1) when the openshell binary is absent (e.g. the CI // unit-test runner; locally it is installed, so this only bites in CI). Stub - // the exec seam so the post-add verification never shells out and never trips + // the exec path so the post-add verification never shells out and never trips // the exit spy unless a test explicitly overrides it. vi.spyOn(processRecovery, "executeSandboxExecCommand").mockReturnValue(null); vi.spyOn(processRecovery, "executeSandboxCommand").mockReturnValue(null); @@ -809,7 +809,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { expect(upsertMock).toHaveBeenCalledTimes(1); }); - it("runs Telegram post-rebuild health through manifest hook output", async () => { + it("runs Telegram post-rebuild bridge verification through the channel hook", async () => { arrangeRegistry({ current: { name: "alpha" } as SandboxEntry }); getCredentialMock.mockImplementation((key: string) => key === "TELEGRAM_BOT_TOKEN" ? TELEGRAM_TOKEN : null, @@ -847,7 +847,7 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => { ).toBe(true); }); - it("runs Slack post-rebuild warning detection through manifest hook output", async () => { + it("runs Slack post-rebuild warning detection through the channel hook", async () => { arrangeRegistry({ current: { name: "alpha" } as SandboxEntry }); getCredentialMock.mockImplementation((key: string) => key === "SLACK_BOT_TOKEN" diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 32207a0656..053e5df6d8 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -16,9 +16,7 @@ import { createMessagingPreEnableHookInputs, getMessagingManifestAvailabilityContext, isMessagingHookConflictError, - type MessagingHookOutputValue, MessagingHostStateApplier, - type MessagingSerializableValue, MessagingSetupApplier, MessagingWorkflowPlanner, runMessagingHook, @@ -69,28 +67,6 @@ type ChannelMutationOptions = { force?: boolean; }; -type BridgeStartupHealthSpec = { - type: "openclaw-bridge-startup"; - configFile: string; - channelConfigPath: string; - enabledPath: string; - logFile: string; - maxLogLines: number; - logLinePattern: string; - warningPattern: string; - positivePattern: string; - allowlistWarning?: AllowlistHealthWarningSpec; -}; - -type AllowlistHealthWarningSpec = { - accountContainerPath: string; - preferredAccountKey?: string; - policyPath: string; - requiredPolicy: string; - allowListPath: string; - messages: readonly string[]; -}; - const messagingManifestRegistry = createBuiltInChannelManifestRegistry(); const useColor = !process.env.NO_COLOR && !!process.stdout.isTTY; @@ -698,9 +674,9 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis return true; } -// Run post-rebuild messaging health checks through the manifest hook phase. +// Run manifest-owned post-rebuild health hooks. // Failures remain best-effort warnings because the rebuild has already -// succeeded; the phase surfaces likely channel startup issues without making +// succeeded; this phase surfaces likely channel startup issues without making // channel ownership leak back into this action. async function runMessagingHealthChecksAfterRebuild( sandboxName: string, @@ -708,10 +684,15 @@ async function runMessagingHealthChecksAfterRebuild( ): Promise { if (MessagingSetupApplier.listHealthChecks(plan).length === 0) return; - const hookRegistry = createBuiltInMessagingHookRegistry(); - let result: Awaited>; + const hookRegistry = createBuiltInMessagingHookRegistry({ + openclawBridgeHealth: { + sandboxName, + executeSandboxCommand: (command, timeoutMs) => + executeSandboxExecCommand(sandboxName, command, timeoutMs), + }, + }); try { - result = await MessagingSetupApplier.applyHealthChecks(plan, { + await MessagingSetupApplier.applyHealthChecks(plan, { runHook: (request) => runMessagingHook( { @@ -732,269 +713,7 @@ async function runMessagingHealthChecksAfterRebuild( }); } catch (err) { console.log(` ${YW}⚠${R} Messaging health check failed: ${formatErrorMessage(err)}`); - return; - } - - for (const hookResult of result.hookResults) { - const request = result.hookRequests.find( - (entry) => entry.hookId === hookResult.hookId && entry.handler === hookResult.handlerId, - ); - if (!request) continue; - for (const [outputId, output] of Object.entries(hookResult.outputs)) { - if (output.kind !== "health-check") continue; - runDeclaredMessagingHealthCheck(sandboxName, request.channelId, outputId, output); - } - } -} - -function runDeclaredMessagingHealthCheck( - sandboxName: string, - channelName: string, - outputId: string, - output: MessagingHookOutputValue, -): void { - const spec = parseBridgeStartupHealthSpec(output.value); - if (!spec) { - console.log( - ` ${YW}⚠${R} Ignoring unsupported messaging health-check output '${outputId}' for '${channelName}'.`, - ); - return; - } - verifyOpenClawBridgeStartupFromSpec(sandboxName, channelName, spec); -} - -function verifyOpenClawBridgeStartupFromSpec( - sandboxName: string, - channelName: string, - spec: BridgeStartupHealthSpec, -): void { - const configProbe = executeSandboxExecCommand( - sandboxName, - `cat ${shellQuote(spec.configFile)} 2>/dev/null || true`, - 10000, - ); - if (!configProbe || configProbe.status !== 0 || !configProbe.stdout) { - console.log( - ` ${YW}⚠${R} Could not read ${spec.configFile} to verify '${channelName}' bridge startup.`, - ); - console.log( - ` Run '${CLI_NAME} ${sandboxName} status' to inspect the sandbox once it is fully running.`, - ); - return; - } - - let channelBlock: unknown = null; - let channelEnabled = false; - try { - const cfg = JSON.parse(String(configProbe.stdout)); - channelBlock = getObjectPath(cfg, spec.channelConfigPath); - channelEnabled = Boolean(getObjectPath(channelBlock, spec.enabledPath)); - } catch { - // Malformed config: continue to a clear disabled warning. - } - - if (!channelEnabled) { - console.log( - ` ${YW}⚠${R} '${channelName}' channel was not marked enabled in baked ${spec.configFile} after rebuild.`, - ); - console.log( - ` The bridge will not start. Re-run '${CLI_NAME} ${sandboxName} rebuild' or 'channels remove ${channelName}' and add again.`, - ); - return; - } - - const logLineRegex = compileHealthRegex(spec.logLinePattern, "log line", channelName); - const warningRegex = compileHealthRegex(spec.warningPattern, "warning", channelName, "i"); - const positiveRegex = compileHealthRegex(spec.positivePattern, "startup", channelName); - if (!logLineRegex || !warningRegex || !positiveRegex) return; - - const logProbe = executeSandboxExecCommand( - sandboxName, - `tail -n ${spec.maxLogLines} ${shellQuote(spec.logFile)} 2>/dev/null || true`, - 10000, - ); - const lines = String(logProbe?.stdout || "") - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && logLineRegex.test(line)); - if (lines.length === 0) { - console.log( - ` ${YW}⚠${R} '${channelName}' bridge did not log a startup breadcrumb in ${spec.logFile} yet.`, - ); - console.log( - ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${spec.logFile}' if the channel stays silent.`, - ); - return; - } - - const credentialWarnings = lines.filter((line) => warningRegex.test(line)); - if (credentialWarnings.length > 0) { - console.log(` ${YW}⚠${R} '${channelName}' bridge logged credential/startup warnings:`); - for (const line of credentialWarnings.slice(0, 3)) { - console.log(` ${line}`); - } - console.log( - ` Verify the OpenShell provider for ${channelName} holds a valid credential and re-run '${CLI_NAME} ${sandboxName} rebuild' if needed.`, - ); - return; - } - - if (lines.some((line) => positiveRegex.test(line))) { - console.log(` ${G}✓${R} '${channelName}' bridge startup detected in sandbox runtime log.`); - if (spec.allowlistWarning) { - printDeclaredAllowlistWarning(channelBlock, spec.allowlistWarning); - } - return; - } - - console.log( - ` ${YW}⚠${R} '${channelName}' bridge log lines found but no startup confirmation yet.`, - ); - console.log( - ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${spec.logFile}' if the channel stays silent.`, - ); -} - -function parseBridgeStartupHealthSpec( - value: MessagingSerializableValue, -): BridgeStartupHealthSpec | null { - if (!isObjectRecord(value) || value.type !== "openclaw-bridge-startup") return null; - const configFile = getStringField(value, "configFile"); - const channelConfigPath = getStringField(value, "channelConfigPath"); - const enabledPath = getStringField(value, "enabledPath") ?? "enabled"; - const logFile = getStringField(value, "logFile"); - const logLinePattern = getStringField(value, "logLinePattern"); - const warningPattern = getStringField(value, "warningPattern"); - const positivePattern = getStringField(value, "positivePattern"); - if ( - !configFile || - !channelConfigPath || - !enabledPath || - !logFile || - !logLinePattern || - !warningPattern || - !positivePattern - ) { - return null; - } - - return { - type: "openclaw-bridge-startup", - configFile, - channelConfigPath, - enabledPath, - logFile, - maxLogLines: normalizeMaxLogLines(value.maxLogLines), - logLinePattern, - warningPattern, - positivePattern, - allowlistWarning: parseAllowlistHealthWarning(value.allowlistWarning), - }; -} - -function parseAllowlistHealthWarning(value: unknown): AllowlistHealthWarningSpec | undefined { - if (!isObjectRecord(value)) return undefined; - const accountContainerPath = getStringField(value, "accountContainerPath"); - const policyPath = getStringField(value, "policyPath"); - const requiredPolicy = getStringField(value, "requiredPolicy"); - const allowListPath = getStringField(value, "allowListPath"); - const messages = Array.isArray(value.messages) - ? value.messages.filter((entry): entry is string => typeof entry === "string") - : []; - if ( - !accountContainerPath || - !policyPath || - !requiredPolicy || - !allowListPath || - messages.length === 0 - ) { - return undefined; - } - const preferredAccountKey = getStringField(value, "preferredAccountKey"); - return { - accountContainerPath, - preferredAccountKey, - policyPath, - requiredPolicy, - allowListPath, - messages, - }; -} - -function printDeclaredAllowlistWarning( - channelBlock: unknown, - spec: AllowlistHealthWarningSpec, -): boolean { - const account = selectDeclaredAccount(channelBlock, spec); - const allowList = getObjectPath(account, spec.allowListPath); - const allowedCount = Array.isArray(allowList) ? allowList.length : 0; - if (getObjectPath(account, spec.policyPath) !== spec.requiredPolicy || allowedCount > 0) { - return false; } - - const [firstLine, ...rest] = spec.messages; - if (!firstLine) return false; - console.log(` ${YW}⚠${R} ${firstLine}`); - for (const line of rest) { - console.log(` ${line}`); - } - return true; -} - -function selectDeclaredAccount( - channelBlock: unknown, - spec: AllowlistHealthWarningSpec, -): Record | null { - const accountContainer = getObjectPath(channelBlock, spec.accountContainerPath); - if (!isObjectRecord(accountContainer)) return null; - const preferredAccount = spec.preferredAccountKey - ? accountContainer[spec.preferredAccountKey] - : null; - if (isObjectRecord(preferredAccount)) { - return preferredAccount; - } - const firstKey = Object.keys(accountContainer)[0]; - const firstAccount = firstKey ? accountContainer[firstKey] : null; - return isObjectRecord(firstAccount) ? firstAccount : null; -} - -function compileHealthRegex( - pattern: string, - label: string, - channelName: string, - flags?: string, -): RegExp | null { - try { - return new RegExp(pattern, flags); - } catch (err) { - console.log( - ` ${YW}⚠${R} Invalid ${label} health pattern for '${channelName}': ${formatErrorMessage(err)}`, - ); - return null; - } -} - -function getObjectPath(value: unknown, dottedPath: string): unknown { - let current = value; - for (const segment of dottedPath.split(".").filter(Boolean)) { - if (!isObjectRecord(current)) return undefined; - current = current[segment]; - } - return current; -} - -function getStringField(value: Record, field: string): string | undefined { - const entry = value[field]; - return typeof entry === "string" && entry.trim().length > 0 ? entry : undefined; -} - -function normalizeMaxLogLines(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return 400; - return Math.min(Math.max(Math.trunc(value), 1), 2000); -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); } function formatErrorMessage(err: unknown): string { diff --git a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts index a8ef786903..0ab9d83fd1 100644 --- a/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts +++ b/src/lib/agent/runtime-hermes-secret-boundary-behavioural.test.ts @@ -113,7 +113,9 @@ describe("Hermes secret-boundary guard — guard snippet behaviour", () => { } } - it("env-file guard exits 1, kills hermes processes, and persists [SECURITY] to the recovery log when python validator fails", () => { + it("env-file guard exits 1, kills hermes processes, and persists [SECURITY] to the recovery log when python validator fails", { + timeout: 15_000, + }, () => { const result = runGuard({ guard: __testing.buildHermesEnvFileBoundaryGuard(), pythonExit: 1, diff --git a/src/lib/channel-runtime-status.ts b/src/lib/channel-runtime-status.ts index 262565a056..361b2b5515 100644 --- a/src/lib/channel-runtime-status.ts +++ b/src/lib/channel-runtime-status.ts @@ -38,19 +38,11 @@ */ import { - collectBuiltInMessagingStatusOutputs, - type OpenClawRuntimeChannelStatusOutput, -} from "./messaging/status-outputs"; + listOpenClawRuntimeChannelMetadata, + type OpenClawRuntimeChannelMetadata, +} from "./messaging/channels/metadata"; -const DEFAULT_RUNTIME_STATUS_OUTPUTS = collectBuiltInMessagingStatusOutputs({ - agent: "openclaw", -}).filter(isOpenClawRuntimeChannelStatusOutput); - -function isOpenClawRuntimeChannelStatusOutput( - output: ReturnType[number], -): output is OpenClawRuntimeChannelStatusOutput { - return output.type === "openclaw-runtime-channel"; -} +const DEFAULT_RUNTIME_VISIBILITY_METADATA = listOpenClawRuntimeChannelMetadata(); export type RuntimeChannelStatus = { /** @@ -118,7 +110,7 @@ export function extractEnabledChannelsFromOpenclawConfig(json: unknown): string[ if (!json || typeof json !== "object") return []; const channels = (json as Record).channels; if (!channels || typeof channels !== "object") return []; - const channelKeyToName = runtimeConfigKeyToChannelName(DEFAULT_RUNTIME_STATUS_OUTPUTS); + const channelKeyToName = runtimeConfigKeyToChannelName(DEFAULT_RUNTIME_VISIBILITY_METADATA); const visible = new Set(); for (const [key, value] of Object.entries(channels as Record)) { const canonical = channelKeyToName.get(key); @@ -182,7 +174,7 @@ const GATEWAY_BOOT_MARKER_REGEX = "\\[gateway\\].*(launched|respawning)"; */ export function buildGatewayLogScanScript(gatewayLogPath: string): string { const quotedPath = shellQuote(gatewayLogPath); - const patternAlternation = runtimeLogPatterns(DEFAULT_RUNTIME_STATUS_OUTPUTS) + const patternAlternation = runtimeLogPatterns(DEFAULT_RUNTIME_VISIBILITY_METADATA) .map(escapeExtendedRegexLiteral) .join("|"); // The awk program uses single-quoted strings inside the shell single- @@ -213,7 +205,7 @@ export function buildGatewayLogScanScript(gatewayLogPath: string): string { */ export function parseGatewayLogScanOutput(stdout: string): Set { const found = new Set(); - const patternToChannel = runtimeLogPatternToChannelName(DEFAULT_RUNTIME_STATUS_OUTPUTS); + const patternToChannel = runtimeLogPatternToChannelName(DEFAULT_RUNTIME_VISIBILITY_METADATA); for (const line of stdout.split(/\r?\n/)) { const trimmed = line.trim(); if (!trimmed.startsWith(LOG_FOUND_PREFIX)) continue; @@ -227,7 +219,7 @@ export function parseGatewayLogScanOutput(stdout: string): Set { const DEFAULT_GATEWAY_LOG_PATH = "/tmp/gateway.log"; function runtimeConfigKeyToChannelName( - outputs: readonly OpenClawRuntimeChannelStatusOutput[], + outputs: readonly OpenClawRuntimeChannelMetadata[], ): ReadonlyMap { const aliases = new Map(); for (const output of outputs) { @@ -238,14 +230,14 @@ function runtimeConfigKeyToChannelName( return aliases; } -function runtimeLogPatterns(outputs: readonly OpenClawRuntimeChannelStatusOutput[]): string[] { +function runtimeLogPatterns(outputs: readonly OpenClawRuntimeChannelMetadata[]): string[] { return [ ...new Set(outputs.flatMap((output) => output.logPatterns).filter((entry) => entry.length > 0)), ]; } function runtimeLogPatternToChannelName( - outputs: readonly OpenClawRuntimeChannelStatusOutput[], + outputs: readonly OpenClawRuntimeChannelMetadata[], ): ReadonlyMap { const aliases = new Map(); for (const output of outputs) { diff --git a/src/lib/messaging/applier/hook-phases.test.ts b/src/lib/messaging/applier/hook-phases.test.ts index 63abe8e32c..3475e3b53d 100644 --- a/src/lib/messaging/applier/hook-phases.test.ts +++ b/src/lib/messaging/applier/hook-phases.test.ts @@ -4,17 +4,11 @@ import { describe, expect, it } from "vitest"; import type { - ChannelHookPhase, MessagingChannelId, SandboxMessagingChannelPlan, SandboxMessagingPlan, } from "../manifest"; -import { - applyDiagnostics, - applyPreEnableChecks, - applyRuntimePreloads, - MessagingSetupApplier, -} from "./index"; +import { applyDiagnostics, applyPreEnableChecks, MessagingSetupApplier } from "./index"; import type { MessagingHookApplyRequest, MessagingHookApplyRunner } from "./types"; describe("messaging applier hook phases", () => { @@ -103,17 +97,6 @@ describe("messaging applier hook phases", () => { }); }); - it("keeps phase wrappers on the shared ChannelHookPhase type", async () => { - const phases: ChannelHookPhase[] = []; - await applyRuntimePreloads(makePlan(), { - runHook: (request) => { - phases.push(request.phase); - }, - }); - - expect(phases).toEqual(["runtime-preload"]); - }); - it("honors skip-channel failure policy and continues later hooks", async () => { const runHook: MessagingHookApplyRunner = (request) => { if (request.hookId === "telegram-pre-enable") { @@ -215,12 +198,6 @@ function makePlan( }, ] : []), - { - channelId: "telegram", - id: "telegram-runtime-preload", - phase: "runtime-preload", - handler: "telegram.runtimePreload", - }, ], }), makeChannel("slack", { diff --git a/src/lib/messaging/applier/hook-phases.ts b/src/lib/messaging/applier/hook-phases.ts index 962303c40d..7d0e787bed 100644 --- a/src/lib/messaging/applier/hook-phases.ts +++ b/src/lib/messaging/applier/hook-phases.ts @@ -100,13 +100,6 @@ export function applyPreEnableChecks( return applyMessagingHooksForPhase(plan, "pre-enable", options); } -export function applyRuntimePreloads( - plan: SandboxMessagingPlan, - options?: MessagingHookPhaseOptions, -): ReturnType { - return applyMessagingHooksForPhase(plan, "runtime-preload", options); -} - export function applyHealthChecks( plan: SandboxMessagingPlan, options?: MessagingHookPhaseOptions, diff --git a/src/lib/messaging/applier/host-state-applier.ts b/src/lib/messaging/applier/host-state-applier.ts index c3e5b8644a..ac434896a2 100644 --- a/src/lib/messaging/applier/host-state-applier.ts +++ b/src/lib/messaging/applier/host-state-applier.ts @@ -1,8 +1,8 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import type { SandboxMessagingPlan } from "../manifest"; import * as registry from "../../state/registry"; +import type { SandboxMessagingPlan, SandboxMessagingRuntimeSetupPlan } from "../manifest"; import { MessagingSetupApplier } from "./setup-applier"; import type { MessagingSetupEnvOptions } from "./types"; @@ -101,11 +101,23 @@ function mergeSandboxMessagingPlans( }, agentRender: mergeByChannelId(existing.agentRender, incoming.agentRender), buildSteps: mergeByChannelId(existing.buildSteps, incoming.buildSteps), + runtimeSetup: mergeRuntimeSetup(existing.runtimeSetup, incoming.runtimeSetup), stateUpdates: mergeByChannelId(existing.stateUpdates, incoming.stateUpdates), healthChecks: mergeByChannelId(existing.healthChecks, incoming.healthChecks), }); } +function mergeRuntimeSetup( + existing: SandboxMessagingRuntimeSetupPlan | undefined, + incoming: SandboxMessagingRuntimeSetupPlan | undefined, +): SandboxMessagingRuntimeSetupPlan { + return { + nodePreloads: mergeByChannelId(existing?.nodePreloads ?? [], incoming?.nodePreloads ?? []), + envAliases: mergeByChannelId(existing?.envAliases ?? [], incoming?.envAliases ?? []), + secretScans: mergeByChannelId(existing?.secretScans ?? [], incoming?.secretScans ?? []), + }; +} + function mergeByChannelId( existing: readonly T[], incoming: readonly T[], diff --git a/src/lib/messaging/applier/setup-applier.test.ts b/src/lib/messaging/applier/setup-applier.test.ts index d689a86d1e..1956b8c0e8 100644 --- a/src/lib/messaging/applier/setup-applier.test.ts +++ b/src/lib/messaging/applier/setup-applier.test.ts @@ -208,11 +208,11 @@ describe("MessagingSetupApplier", () => { phase: "pre-enable", }), ]); - expect(MessagingSetupApplier.listRuntimePreloads(slackPlan)).toEqual([ + expect(slackPlan.runtimeSetup?.nodePreloads).toEqual([ expect.objectContaining({ channelId: "slack", - hookId: "slack-runtime-preload", - phase: "runtime-preload", + module: "slack-channel-guard", + source: "/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js", }), ]); expect(MessagingSetupApplier.listHealthChecks(slackPlan)).toEqual([ @@ -220,6 +220,7 @@ describe("MessagingSetupApplier", () => { channelId: "slack", hookId: "slack-openclaw-bridge-health", phase: "health-check", + handler: "slack.openclawBridgeHealth", }), ]); }); @@ -455,11 +456,8 @@ describe("MessagingSetupApplier", () => { ), ).toEqual([ "slack:slack-socket-mode-gateway-conflict", - "slack:slack-runtime-preload", "slack:slack-openclaw-bridge-health", - "slack:slack-openclaw-runtime-status", "slack:slack-socket-mode-gateway-status", - "slack:slack-openclaw-package-install", "slack:slack-token-paste", "slack:slack-config-prompt", "slack:slack-credential-validation", diff --git a/src/lib/messaging/applier/setup-applier.ts b/src/lib/messaging/applier/setup-applier.ts index 87230b6d3d..d86b821b9d 100644 --- a/src/lib/messaging/applier/setup-applier.ts +++ b/src/lib/messaging/applier/setup-applier.ts @@ -12,7 +12,6 @@ import { applyHealthChecks as applyPlanHealthChecks, applyMessagingHooksForPhase as applyPlanHooksForPhase, applyPreEnableChecks as applyPlanPreEnableChecks, - applyRuntimePreloads as applyPlanRuntimePreloads, type MessagingHookPhaseOptions, } from "./hook-phases"; import { applyCredentialsAtOpenShell as applyCredentialsPlanAtOpenShell } from "./openshell-provider"; @@ -80,11 +79,6 @@ export class MessagingSetupApplier { return listPlanHookRequests(plan, "pre-enable"); } - static listRuntimePreloads(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] { - assertSandboxMessagingPlan(plan); - return listPlanHookRequests(plan, "runtime-preload"); - } - static listHealthChecks(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] { assertSandboxMessagingPlan(plan); return listPlanHookRequests(plan, "health-check"); @@ -107,14 +101,6 @@ export class MessagingSetupApplier { return applyPlanPreEnableChecks(plan, options); } - static applyRuntimePreloads( - plan: SandboxMessagingPlan, - options: MessagingHookPhaseOptions = {}, - ): ReturnType { - assertSandboxMessagingPlan(plan); - return applyPlanRuntimePreloads(plan, options); - } - static applyHealthChecks( plan: SandboxMessagingPlan, options: MessagingHookPhaseOptions = {}, @@ -168,6 +154,7 @@ function assertSandboxMessagingPlan(value: unknown): asserts value is SandboxMes !isObject(value.networkPolicy) || !Array.isArray(value.agentRender) || !Array.isArray(value.buildSteps) || + !isRuntimeSetup(value.runtimeSetup) || !Array.isArray(value.stateUpdates) || !Array.isArray(value.healthChecks) ) { @@ -179,6 +166,16 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isRuntimeSetup(value: unknown): boolean { + if (value === undefined) return true; + return ( + isObject(value) && + Array.isArray(value.nodePreloads) && + Array.isArray(value.envAliases) && + Array.isArray(value.secretScans) + ); +} + function assertJsonSerializable( value: unknown, path = "$", diff --git a/src/lib/messaging/channels/discord/hooks/index.ts b/src/lib/messaging/channels/discord/hooks/index.ts new file mode 100644 index 0000000000..ceef41d254 --- /dev/null +++ b/src/lib/messaging/channels/discord/hooks/index.ts @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookRegistration } from "../../../hooks/types"; +import type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; +import { createDiscordOpenClawBridgeHealthHookRegistration } from "./openclaw-bridge-health"; + +export * from "./openclaw-bridge-health"; + +export interface DiscordHookOptions { + readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; +} + +export function createDiscordHookRegistrations( + options: DiscordHookOptions = {}, +): readonly MessagingHookRegistration[] { + return [createDiscordOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth)] as const; +} diff --git a/src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts b/src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts new file mode 100644 index 0000000000..da4301b6a2 --- /dev/null +++ b/src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, +} from "../../openclaw-bridge-health"; + +export type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; + +export const DISCORD_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID = "discord.openclawBridgeHealth"; + +export function createDiscordOpenClawBridgeHealthHookRegistration( + options: OpenClawBridgeHealthHookOptions = {}, +) { + return createOpenClawBridgeHealthHookRegistration( + { + channelId: "discord", + handlerId: DISCORD_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID, + }, + options, + ); +} diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 8dbde897b8..5ed60b46d1 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -179,6 +179,24 @@ export const discordManifest = { }, }, ], + runtime: { + openclaw: { + visibility: { + configKeys: ["discord"], + logPatterns: ["discord"], + }, + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/discord@{{openclaw.version}}", + pin: true, + required: true, + }, + ], state: { persist: { discordGuilds: ["serverId", "requireMention", "userId"], @@ -202,64 +220,8 @@ export const discordManifest = { { id: "discord-openclaw-bridge-health", phase: "health-check", - handler: "common.staticOutputs", + handler: "discord.openclawBridgeHealth", agents: ["openclaw"], - outputs: [ - { - id: "openclawBridgeStartup", - kind: "health-check", - required: true, - value: { - type: "openclaw-bridge-startup", - configFile: "/sandbox/.openclaw/openclaw.json", - channelConfigPath: "channels.discord", - enabledPath: "enabled", - logFile: "/tmp/gateway.log", - maxLogLines: 400, - logLinePattern: "^\\[discord\\] |^\\[channels\\] \\[discord\\]", - warningPattern: - "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", - positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", - }, - }, - ], - onFailure: "abort", - }, - { - id: "discord-openclaw-runtime-status", - phase: "status", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawRuntimeChannel", - kind: "status", - required: true, - value: { - type: "openclaw-runtime-channel", - configKeys: ["discord"], - logPatterns: ["discord"], - }, - }, - ], - }, - { - id: "discord-openclaw-package-install", - phase: "agent-install", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawPluginPackage", - kind: "package-install", - required: true, - value: { - manager: "openclaw-plugin", - spec: "npm:@openclaw/discord@{{openclaw.version}}", - pin: true, - }, - }, - ], onFailure: "abort", }, { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 0a11476e5e..dea18687d0 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -8,7 +8,6 @@ import { describe, expect, it } from "vitest"; import { getChannelTokenKeys, KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels"; import { COMMON_CONFIG_PROMPT_HOOK_HANDLER_ID, - COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, COMMON_TOKEN_PASTE_HOOK_HANDLER_ID, } from "../hooks/common"; import type { @@ -28,9 +27,13 @@ import { } from "./index"; import { SLACK_SOCKET_MODE_GATEWAY_CONFLICT_HOOK_HANDLER_ID, + SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID, } from "./slack/hooks"; -import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks"; +import { + TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID, + TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID, +} from "./telegram/hooks"; function findInput(manifest: ChannelManifest, inputId: string): ChannelInputSpec { const input = manifest.inputs.find((entry) => entry.id === inputId); @@ -133,65 +136,58 @@ function expectSlackSocketModeGatewayConflictHook(): void { }); } -function expectRuntimePreloadHook( +function expectOpenClawBridgeHealthHook( manifest: ChannelManifest, hookId: string, - outputId: string, + handler: string, ): void { - expect(findHook(manifest, hookId)).toMatchObject({ - id: hookId, - phase: "runtime-preload", - handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, - agents: ["openclaw"], - outputs: [ - expect.objectContaining({ - id: outputId, - kind: "runtime-preload", - required: true, - }), - ], - onFailure: "abort", - }); -} - -function expectOpenClawBridgeHealthHook(manifest: ChannelManifest, hookId: string): void { - expect(findHook(manifest, hookId)).toMatchObject({ + const hook = findHook(manifest, hookId); + expect(hook).toMatchObject({ id: hookId, phase: "health-check", - handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + handler, agents: ["openclaw"], - outputs: [ - expect.objectContaining({ - id: "openclawBridgeStartup", - kind: "health-check", - required: true, - }), - ], onFailure: "abort", }); + expect(hook.outputs).toBeUndefined(); } -function expectStatusHook( +function expectConcreteStatusHook( manifest: ChannelManifest, hookId: string, + handler: string, outputId: string, - type: string, ): void { expect(findHook(manifest, hookId)).toMatchObject({ id: hookId, phase: "status", - handler: COMMON_STATIC_OUTPUTS_HOOK_HANDLER_ID, + handler, outputs: [ - expect.objectContaining({ + { id: outputId, kind: "status", - required: true, - value: expect.objectContaining({ type }), - }), + }, ], }); } +function expectOpenClawRuntimeVisibility( + manifest: ChannelManifest, + configKeys: readonly string[], + logPatterns: readonly string[], +): void { + expect(manifest.runtime?.openclaw?.visibility).toEqual({ + configKeys, + logPatterns, + }); +} + +function expectOpenClawNodePreload(manifest: ChannelManifest, module: string): void { + expect(manifest.runtime?.openclaw?.nodePreloads ?? []).toEqual( + expect.arrayContaining([expect.objectContaining({ module })]), + ); +} + describe("built-in channel manifests", () => { it("registers the phase-1 built-in manifests without consuming them in workflows", () => { const registry = createBuiltInChannelManifestRegistry(); @@ -223,14 +219,21 @@ describe("built-in channel manifests", () => { it("keeps phase-1 manifest and hook files free of production side-effect imports", () => { const manifestPaths = [ "src/lib/messaging/channels/telegram/manifest.ts", + "src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts", + "src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts", "src/lib/messaging/channels/discord/manifest.ts", + "src/lib/messaging/channels/discord/hooks/index.ts", + "src/lib/messaging/channels/discord/hooks/openclaw-bridge-health.ts", "src/lib/messaging/channels/wechat/manifest.ts", "src/lib/messaging/channels/wechat/hooks/health-check.ts", "src/lib/messaging/channels/wechat/hooks/ilink-login.ts", "src/lib/messaging/channels/wechat/hooks/index.ts", "src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts", + "src/lib/messaging/channels/openclaw-bridge-health.ts", "src/lib/messaging/channels/slack/manifest.ts", + "src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts", "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-conflict.ts", + "src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts", "src/lib/messaging/channels/slack/hooks/validate-credentials.ts", "src/lib/messaging/channels/whatsapp/manifest.ts", "src/lib/messaging/hooks/common/config-prompt.ts", @@ -337,25 +340,19 @@ describe("built-in channel manifests", () => { }); expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]); expectReachabilityHook(telegramManifest, ["botToken"]); - expectRuntimePreloadHook(telegramManifest, "telegram-runtime-preload", "telegramDiagnostics"); - expect(JSON.stringify(findHook(telegramManifest, "telegram-runtime-preload"))).toContain( - "telegram-diagnostics.js", - ); - expectOpenClawBridgeHealthHook(telegramManifest, "telegram-openclaw-bridge-health"); - expect(JSON.stringify(findHook(telegramManifest, "telegram-openclaw-bridge-health"))).toContain( - "Telegram direct-message allowlist is empty", - ); - expectStatusHook( + expectOpenClawNodePreload(telegramManifest, "telegram-diagnostics"); + expect(JSON.stringify(telegramManifest.runtime?.openclaw)).toContain("telegram-diagnostics"); + expectOpenClawBridgeHealthHook( telegramManifest, - "telegram-openclaw-runtime-status", - "openclawRuntimeChannel", - "openclaw-runtime-channel", + "telegram-openclaw-bridge-health", + "telegram.openclawBridgeHealth", ); - expectStatusHook( + expectOpenClawRuntimeVisibility(telegramManifest, ["telegram"], ["telegram"]); + expectConcreteStatusHook( telegramManifest, "telegram-gateway-conflict-status", - "gatewayConflictCounter", - "gateway-log-conflict-counter", + TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID, + "bridgeHealth", ); }); @@ -395,13 +392,12 @@ describe("built-in channel manifests", () => { expect(renderJson(discordManifest)).toContain("require_mention"); expectTokenPasteEnrollHook(discordManifest, ["botToken"]); expectConfigPromptEnrollHook(discordManifest, ["serverId", "requireMention", "userId"]); - expectOpenClawBridgeHealthHook(discordManifest, "discord-openclaw-bridge-health"); - expectStatusHook( + expectOpenClawBridgeHealthHook( discordManifest, - "discord-openclaw-runtime-status", - "openclawRuntimeChannel", - "openclaw-runtime-channel", + "discord-openclaw-bridge-health", + "discord.openclawBridgeHealth", ); + expectOpenClawRuntimeVisibility(discordManifest, ["discord"], ["discord"]); }); it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => { @@ -451,28 +447,23 @@ describe("built-in channel manifests", () => { expect(renderJson(slackManifest)).toContain('"accounts"'); expect(renderJson(slackManifest)).toContain("allowedIds.slack.channels"); expectSlackSocketModeGatewayConflictHook(); - expectRuntimePreloadHook(slackManifest, "slack-runtime-preload", "slackRuntimePreload"); - expect(JSON.stringify(findHook(slackManifest, "slack-runtime-preload"))).toContain( - "slack-channel-guard.js", - ); - expect(JSON.stringify(findHook(slackManifest, "slack-runtime-preload"))).toContain( - "SLACK_BOT_TOKEN", - ); + expectOpenClawNodePreload(slackManifest, "slack-channel-guard"); + expect(JSON.stringify(slackManifest.runtime?.openclaw)).toContain("slack-channel-guard"); + expect(JSON.stringify(slackManifest.runtime?.openclaw)).toContain("SLACK_BOT_TOKEN"); expectTokenPasteEnrollHook(slackManifest, ["botToken", "appToken"]); expectConfigPromptEnrollHook(slackManifest, ["allowedUsers", "allowedChannels"]); expectSlackCredentialValidationHook(["botToken", "appToken"]); - expectOpenClawBridgeHealthHook(slackManifest, "slack-openclaw-bridge-health"); - expectStatusHook( + expectOpenClawBridgeHealthHook( slackManifest, - "slack-openclaw-runtime-status", - "openclawRuntimeChannel", - "openclaw-runtime-channel", + "slack-openclaw-bridge-health", + "slack.openclawBridgeHealth", ); - expectStatusHook( + expectOpenClawRuntimeVisibility(slackManifest, ["slack"], ["slack"]); + expectConcreteStatusHook( slackManifest, "slack-socket-mode-gateway-status", - "singleGatewayOverlap", - "single-gateway-channel-overlap", + SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, + "gatewayOverlaps", ); expect(slackManifest.state).toEqual({ persist: { @@ -547,18 +538,13 @@ describe("built-in channel manifests", () => { expect(renderJson(wechatManifest)).toContain("WEIXIN_TOKEN"); expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ - "common.staticOutputs", "wechat.ilinkLogin", "common.configPrompt", "wechat.seedOpenClawAccount", - "common.staticOutputs", "wechat.healthCheck", - "common.staticOutputs", ]); - expectRuntimePreloadHook(wechatManifest, "wechat-runtime-preload", "wechatDiagnostics"); - expect(JSON.stringify(findHook(wechatManifest, "wechat-runtime-preload"))).toContain( - "wechat-diagnostics.js", - ); + expectOpenClawNodePreload(wechatManifest, "wechat-diagnostics"); + expect(JSON.stringify(wechatManifest.runtime?.openclaw)).toContain("wechat-diagnostics"); expectConfigPromptEnrollHook(wechatManifest, ["allowedIds"]); const seedHook = wechatManifest.hooks.find( (hook) => hook.id === "wechat-seed-openclaw-account", @@ -582,11 +568,10 @@ describe("built-in channel manifests", () => { inputs: ["wechatConfig.accountId"], onFailure: "abort", }); - expectStatusHook( + expectOpenClawRuntimeVisibility( wechatManifest, - "wechat-openclaw-runtime-status", - "openclawRuntimeChannel", - "openclaw-runtime-channel", + ["openclaw-weixin"], + ["wechat", "openclaw-weixin"], ); }); @@ -626,15 +611,8 @@ describe("built-in channel manifests", () => { expect(renderJson(whatsappManifest)).toContain("platforms.whatsapp"); expect(renderJson(whatsappManifest)).not.toContain("WHATSAPP_BOT_TOKEN"); expect(renderJson(whatsappManifest)).not.toContain("openshell:resolve:env:WHATSAPP"); - expectRuntimePreloadHook(whatsappManifest, "whatsapp-runtime-preload", "whatsappQrCompact"); - expect(JSON.stringify(findHook(whatsappManifest, "whatsapp-runtime-preload"))).toContain( - "whatsapp-qr-compact.js", - ); - expectStatusHook( - whatsappManifest, - "whatsapp-openclaw-runtime-status", - "openclawRuntimeChannel", - "openclaw-runtime-channel", - ); + expectOpenClawNodePreload(whatsappManifest, "whatsapp-qr-compact"); + expect(JSON.stringify(whatsappManifest.runtime?.openclaw)).toContain("whatsapp-qr-compact"); + expectOpenClawRuntimeVisibility(whatsappManifest, ["whatsapp"], ["whatsapp"]); }); }); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index a8adce873c..04bef2569e 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -153,31 +153,23 @@ describe("built-in messaging channel metadata", () => { ]); }); - it("expands universal package-install hooks to the manifest supported agents", () => { + it("lists package installs from manifest agent package metadata", () => { const manifests: ChannelManifest[] = [ { ...manifestWithPreset("alpha", "alpha"), - hooks: [ + agentPackages: [ { - id: "alpha-install", - phase: "agent-install", - handler: "common.staticOutputs", - outputs: [ - { - id: "alphaPackage", - kind: "package-install", - value: { manager: "npm", spec: "alpha@1.0.0" }, - }, - ], + id: "alphaPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/alpha@{{openclaw.version}}", }, ], }, ]; - expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual([ - "openclaw", - "hermes", - ]); + expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual(["openclaw"]); + expect(listMessagingPackageInstallSpecs({ manifests, agent: "hermes" })).toEqual([]); }); }); diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index a90fd4cc19..de03ba2ecf 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import type { - ChannelHookOutputSpec, + ChannelAgentPackageSpec, ChannelManifest, ChannelPolicyPresetReference, ChannelPolicyPresetSpec, MessagingAgentId, - MessagingSerializableObject, MessagingSerializableValue, } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins"; @@ -48,19 +47,16 @@ export interface MessagingPolicyPresetMetadata { export interface OpenClawRuntimeChannelMetadata { readonly channelId: string; - readonly hookId: string; - readonly outputId: string; readonly configKeys: readonly string[]; readonly logPatterns: readonly string[]; } export interface MessagingPackageInstallMetadata { readonly channelId: string; - readonly hookId: string; - readonly outputId: string; + readonly packageId: string; readonly agents: readonly MessagingAgentId[]; - readonly manager?: string; - readonly spec?: string; + readonly manager: string; + readonly spec: string; readonly pin?: boolean; } @@ -270,47 +266,34 @@ export function getMessagingPolicyPresetValidationWarnings( export function listOpenClawRuntimeChannelMetadata( options: MessagingManifestMetadataOptions = {}, ): OpenClawRuntimeChannelMetadata[] { - return selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => - manifest.hooks.flatMap((hook) => { - if (hook.phase !== "status" || !hookTargetsAgent(hook.agents, "openclaw")) return []; - return (hook.outputs ?? []).flatMap((output) => { - if (output.kind !== "status") return []; - const value = serializableObject(output.value); - if (value?.type !== "openclaw-runtime-channel") return []; - return [ - { - channelId: manifest.id, - hookId: hook.id, - outputId: output.id, - configKeys: stringArray(value.configKeys), - logPatterns: stringArray(value.logPatterns), - }, - ]; - }); - }), - ); + return selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => { + const visibility = manifest.runtime?.openclaw?.visibility; + if (!visibility) return []; + if (visibility.configKeys.length === 0 || visibility.logPatterns.length === 0) return []; + return [ + { + channelId: manifest.id, + configKeys: [...visibility.configKeys], + logPatterns: [...visibility.logPatterns], + }, + ]; + }); } export function listMessagingPackageInstallSpecs( options: MessagingManifestMetadataOptions = {}, ): MessagingPackageInstallMetadata[] { return selectManifests(options).flatMap((manifest) => - manifest.hooks.flatMap((hook) => { - if (hook.phase !== "agent-install") return []; - if (options.agent && !hookTargetsAgent(hook.agents, options.agent)) return []; - return (hook.outputs ?? []).flatMap((output) => { - if (output.kind !== "package-install") return []; - const value = serializableObject(output.value); - return [ - { - channelId: manifest.id, - hookId: hook.id, - outputId: output.id, - agents: hook.agents ?? manifest.supportedAgents, - ...packageInstallValue(value), - }, - ]; - }); + (manifest.agentPackages ?? []).flatMap((agentPackage) => { + if (options.agent && agentPackage.agent !== options.agent) return []; + return [ + { + channelId: manifest.id, + packageId: agentPackage.id, + agents: [agentPackage.agent], + ...packageInstallValue(agentPackage), + }, + ]; }), ); } @@ -332,36 +315,16 @@ function normalizePolicyPreset(preset: ChannelPolicyPresetReference): ChannelPol return typeof preset === "string" ? { name: preset } : preset; } -function hookTargetsAgent( - agents: readonly MessagingAgentId[] | undefined, - agent: MessagingAgentId, -): boolean { - return agents === undefined || agents.includes(agent); -} - -function serializableObject( - value: ChannelHookOutputSpec["value"], -): MessagingSerializableObject | null { - return isSerializableObject(value) ? value : null; -} - function packageInstallValue( - value: MessagingSerializableObject | null, + value: ChannelAgentPackageSpec, ): Pick { - if (!value) return {}; return { - ...(typeof value.manager === "string" ? { manager: value.manager } : {}), - ...(typeof value.spec === "string" ? { spec: value.spec } : {}), + manager: value.manager, + spec: value.spec, ...(typeof value.pin === "boolean" ? { pin: value.pin } : {}), }; } -function isSerializableObject( - value: MessagingSerializableValue | undefined, -): value is MessagingSerializableObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function stringArray(value: MessagingSerializableValue | undefined): string[] { return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") diff --git a/src/lib/messaging/channels/openclaw-bridge-health.test.ts b/src/lib/messaging/channels/openclaw-bridge-health.test.ts new file mode 100644 index 0000000000..b66369f984 --- /dev/null +++ b/src/lib/messaging/channels/openclaw-bridge-health.test.ts @@ -0,0 +1,84 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { MessagingHookRegistry, runMessagingHook } from "../hooks"; +import type { ChannelHookSpec } from "../manifest"; +import { createOpenClawBridgeHealthHookRegistration } from "./openclaw-bridge-health"; + +const SLACK_HEALTH_HOOK = { + id: "slack-openclaw-bridge-health", + phase: "health-check", + handler: "slack.openclawBridgeHealth", +} as const satisfies ChannelHookSpec; + +describe("OpenClaw bridge health hook", () => { + it("logs channel startup warnings through the injected sandbox runner", async () => { + const logs: string[] = []; + const commands: string[] = []; + const registry = new MessagingHookRegistry([ + createOpenClawBridgeHealthHookRegistration( + { + channelId: "slack", + handlerId: "slack.openclawBridgeHealth", + }, + { + sandboxName: "alpha", + log: (message) => logs.push(message), + executeSandboxCommand: (command) => { + commands.push(command); + if (command.includes("openclaw.json")) { + return { + status: 0, + stdout: JSON.stringify({ + channels: { + slack: { + enabled: true, + }, + }, + }), + }; + } + if (command.includes("gateway.log")) { + return { + status: 0, + stdout: "[channels] [slack] provider failed to start: invalid_auth", + }; + } + return null; + }, + }, + ), + ]); + + await expect( + runMessagingHook(SLACK_HEALTH_HOOK, registry, { channelId: "slack" }), + ).resolves.toMatchObject({ + hookId: "slack-openclaw-bridge-health", + handlerId: "slack.openclawBridgeHealth", + phase: "health-check", + outputs: {}, + }); + + expect(commands).toEqual([ + "cat /sandbox/.openclaw/openclaw.json 2>/dev/null || true", + "tail -n 400 /tmp/gateway.log 2>/dev/null || true", + ]); + expect(logs.join("\n")).toContain("'slack' bridge logged credential/startup warnings"); + expect(logs.join("\n")).toContain("invalid_auth"); + }); + + it("requires a sandbox command runner", async () => { + const registry = new MessagingHookRegistry([ + createOpenClawBridgeHealthHookRegistration({ + channelId: "slack", + handlerId: "slack.openclawBridgeHealth", + }), + ]); + + await expect( + runMessagingHook(SLACK_HEALTH_HOOK, registry, { channelId: "slack" }), + ).rejects.toThrow("OpenClaw bridge health check requires executeSandboxCommand"); + }); +}); diff --git a/src/lib/messaging/channels/openclaw-bridge-health.ts b/src/lib/messaging/channels/openclaw-bridge-health.ts new file mode 100644 index 0000000000..d97535fec3 --- /dev/null +++ b/src/lib/messaging/channels/openclaw-bridge-health.ts @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookHandler, MessagingHookRegistration } from "../hooks/types"; + +const OPENCLAW_CONFIG_FILE = "/sandbox/.openclaw/openclaw.json"; +const OPENCLAW_GATEWAY_LOG_FILE = "/tmp/gateway.log"; +const OPENCLAW_BRIDGE_WARNING_PATTERN = + /credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired/i; +const OPENCLAW_BRIDGE_POSITIVE_STARTUP_PATTERN = /\bstarting provider\b|\bprovider ready\b/; + +export interface OpenClawBridgeHealthCommandResult { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +} + +export type OpenClawBridgeHealthCommandRunner = ( + command: string, + timeoutMs: number, +) => OpenClawBridgeHealthCommandResult | null | undefined; + +export interface OpenClawBridgeHealthHookOptions { + readonly sandboxName?: string; + readonly executeSandboxCommand?: OpenClawBridgeHealthCommandRunner; + readonly log?: (message: string) => void; +} + +export interface OpenClawBridgeHealthStartupContext { + readonly channelBlock: unknown; + readonly log: (message: string) => void; +} + +export interface OpenClawBridgeHealthChannelSpec { + readonly channelId: string; + readonly handlerId: string; + readonly onStartupDetected?: (context: OpenClawBridgeHealthStartupContext) => void; +} + +export function createOpenClawBridgeHealthHookRegistration( + spec: OpenClawBridgeHealthChannelSpec, + options: OpenClawBridgeHealthHookOptions = {}, +): MessagingHookRegistration { + return { + id: spec.handlerId, + handler: createOpenClawBridgeHealthHook(spec, options), + }; +} + +export function createOpenClawBridgeHealthHook( + spec: OpenClawBridgeHealthChannelSpec, + options: OpenClawBridgeHealthHookOptions = {}, +): MessagingHookHandler { + return () => { + const execute = options.executeSandboxCommand; + if (!execute) { + throw new Error("OpenClaw bridge health check requires executeSandboxCommand."); + } + + const log = options.log ?? console.log; + const sandboxName = normalizeSandboxName(options.sandboxName); + const configProbe = execute(`cat ${OPENCLAW_CONFIG_FILE} 2>/dev/null || true`, 10000); + if (!configProbe || configProbe.status !== 0 || !configProbe.stdout) { + log( + ` ⚠ Could not read ${OPENCLAW_CONFIG_FILE} to verify '${spec.channelId}' bridge startup.`, + ); + log(` Run the status command for '${sandboxName}' once the sandbox is fully running.`); + return {}; + } + + let channelBlock: unknown = null; + let channelEnabled = false; + try { + const cfg = JSON.parse(String(configProbe.stdout)); + channelBlock = getObjectPath(cfg, `channels.${spec.channelId}`); + channelEnabled = Boolean(getObjectPath(channelBlock, "enabled")); + } catch { + // Malformed config: continue to a clear disabled warning. + } + + if (!channelEnabled) { + log( + ` ⚠ '${spec.channelId}' channel was not marked enabled in baked ${OPENCLAW_CONFIG_FILE} after rebuild.`, + ); + log( + " The bridge will not start. Re-run the sandbox rebuild or remove and add the channel again.", + ); + return {}; + } + + const logLineRegex = new RegExp( + `^\\[${escapeRegExp(spec.channelId)}\\] |^\\[channels\\] \\[${escapeRegExp(spec.channelId)}\\]`, + ); + const logProbe = execute(`tail -n 400 ${OPENCLAW_GATEWAY_LOG_FILE} 2>/dev/null || true`, 10000); + const lines = String(logProbe?.stdout || "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && logLineRegex.test(line)); + if (lines.length === 0) { + log( + ` ⚠ '${spec.channelId}' bridge did not log a startup breadcrumb in ${OPENCLAW_GATEWAY_LOG_FILE} yet.`, + ); + log( + ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${OPENCLAW_GATEWAY_LOG_FILE}' if the channel stays silent.`, + ); + return {}; + } + + const credentialWarnings = lines.filter((line) => OPENCLAW_BRIDGE_WARNING_PATTERN.test(line)); + if (credentialWarnings.length > 0) { + log(` ⚠ '${spec.channelId}' bridge logged credential/startup warnings:`); + for (const line of credentialWarnings.slice(0, 3)) { + log(` ${line}`); + } + log( + ` Verify the OpenShell provider for ${spec.channelId} holds a valid credential and re-run the sandbox rebuild if needed.`, + ); + return {}; + } + + if (lines.some((line) => OPENCLAW_BRIDGE_POSITIVE_STARTUP_PATTERN.test(line))) { + log(` ✓ '${spec.channelId}' bridge startup detected in sandbox runtime log.`); + spec.onStartupDetected?.({ channelBlock, log }); + return {}; + } + + log(` ⚠ '${spec.channelId}' bridge log lines found but no startup confirmation yet.`); + log( + ` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f ${OPENCLAW_GATEWAY_LOG_FILE}' if the channel stays silent.`, + ); + return {}; + }; +} + +function normalizeSandboxName(value: string | undefined): string { + const normalized = value?.trim(); + return normalized && normalized.length > 0 ? normalized : ""; +} + +function getObjectPath(value: unknown, dottedPath: string): unknown { + let current = value; + for (const segment of dottedPath.split(".").filter(Boolean)) { + if (!isObjectRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/slack/hooks/index.ts b/src/lib/messaging/channels/slack/hooks/index.ts index fcfa83cff2..aa055475ae 100644 --- a/src/lib/messaging/channels/slack/hooks/index.ts +++ b/src/lib/messaging/channels/slack/hooks/index.ts @@ -2,22 +2,34 @@ // SPDX-License-Identifier: Apache-2.0 import type { MessagingHookRegistration } from "../../../hooks/types"; +import { + createSlackOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, +} from "./openclaw-bridge-health"; import { createSlackSocketModeGatewayConflictHookRegistration, type SlackSocketModeGatewayConflictHookOptions, } from "./socket-mode-gateway-conflict"; +import { + createSlackSocketModeGatewayStatusHookRegistration, + type SlackSocketModeGatewayStatusHookOptions, +} from "./socket-mode-gateway-status"; import { createSlackValidateCredentialsHookRegistration, type SlackValidateCredentialsHookOptions, } from "./validate-credentials"; export * from "./credential-validation"; +export * from "./openclaw-bridge-health"; export * from "./socket-mode-gateway-conflict"; +export * from "./socket-mode-gateway-status"; export * from "./validate-credentials"; export interface SlackHookOptions { readonly socketModeGatewayConflict?: SlackSocketModeGatewayConflictHookOptions; + readonly socketModeGatewayStatus?: SlackSocketModeGatewayStatusHookOptions; readonly validateCredentials?: SlackValidateCredentialsHookOptions; + readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; } export function createSlackHookRegistrations( @@ -27,6 +39,10 @@ export function createSlackHookRegistrations( createSlackSocketModeGatewayConflictHookRegistration( withoutUndefinedValues(options.socketModeGatewayConflict), ), + createSlackSocketModeGatewayStatusHookRegistration( + withoutUndefinedValues(options.socketModeGatewayStatus), + ), + createSlackOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth), createSlackValidateCredentialsHookRegistration( withoutUndefinedValues(options.validateCredentials), ), diff --git a/src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts b/src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts new file mode 100644 index 0000000000..25349558dc --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/openclaw-bridge-health.ts @@ -0,0 +1,23 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, +} from "../../openclaw-bridge-health"; + +export type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; + +export const SLACK_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID = "slack.openclawBridgeHealth"; + +export function createSlackOpenClawBridgeHealthHookRegistration( + options: OpenClawBridgeHealthHookOptions = {}, +) { + return createOpenClawBridgeHealthHookRegistration( + { + channelId: "slack", + handlerId: SLACK_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID, + }, + options, + ); +} diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.test.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.test.ts new file mode 100644 index 0000000000..c6d4baee5e --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.test.ts @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { + makePlan, + planEntry, + slackBindings, + slackChannel, +} from "../../../../../../test/helpers/messaging-conflict-fixtures"; +import { runMessagingHookSync } from "../../../hooks"; +import { MessagingHookRegistry } from "../../../hooks/registry"; +import type { MessagingSerializableValue } from "../../../manifest"; +import { + createSlackSocketModeGatewayStatusHookRegistration, + SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, + SLACK_SOCKET_MODE_GATEWAY_STATUS_MESSAGE, +} from "./socket-mode-gateway-status"; + +const HOOK = { + id: "slack-socket-mode-gateway-status", + phase: "status", + handler: SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, + outputs: [{ id: "gatewayOverlaps", kind: "status" }], +} as const; + +describe("slack.socketModeGatewayStatus hook", () => { + it("reports Slack Socket Mode overlaps on the same gateway", () => { + const alice = { + ...planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: slackBindings("bot-a", "app-a", "alice"), + }), + ), + gatewayName: "nemoclaw", + }; + const bob = { + ...planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: slackBindings("bot-b", "app-b", "bob"), + }), + ), + gatewayName: "nemoclaw", + }; + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayStatusHookRegistration(), + ]); + + const result = runMessagingHookSync(HOOK, registry, { + channelId: "slack", + inputs: { registryEntries: serialize([alice, bob]) }, + }); + + expect(result.outputs.gatewayOverlaps).toEqual({ + kind: "status", + value: { + type: "messaging-overlaps", + overlaps: [ + { + channel: "slack", + gatewayName: "nemoclaw", + sandboxes: ["alice", "bob"], + reason: "socket-mode-gateway", + message: SLACK_SOCKET_MODE_GATEWAY_STATUS_MESSAGE, + }, + ], + }, + }); + }); + + it("emits no status output when Slack sandboxes use different gateways", () => { + const registry = new MessagingHookRegistry([ + createSlackSocketModeGatewayStatusHookRegistration({ + registryEntries: [ + { + ...planEntry( + "alice", + makePlan("alice", { + channels: [slackChannel()], + credentialBindings: slackBindings("bot-a", "app-a", "alice"), + }), + ), + gatewayName: "nemoclaw", + }, + { + ...planEntry( + "bob", + makePlan("bob", { + channels: [slackChannel()], + credentialBindings: slackBindings("bot-b", "app-b", "bob"), + }), + ), + gatewayName: "nemoclaw-9090", + }, + ], + }), + ]); + + expect(runMessagingHookSync(HOOK, registry, { channelId: "slack" }).outputs).toEqual({}); + }); +}); + +function serialize(value: unknown): MessagingSerializableValue { + return JSON.parse(JSON.stringify(value)) as MessagingSerializableValue; +} diff --git a/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts new file mode 100644 index 0000000000..d8a701f225 --- /dev/null +++ b/src/lib/messaging/channels/slack/hooks/socket-mode-gateway-status.ts @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + detectAllSlackSocketModeGatewayOverlaps, + type SlackGatewayOverlap, +} from "../../../applier/conflict-detection/slack-socket-mode"; +import type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types"; +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; +import type { MessagingSerializableValue } from "../../../manifest"; + +export const SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID = "slack.socketModeGatewayStatus"; + +export const SLACK_SOCKET_MODE_GATEWAY_STATUS_MESSAGE = + "'{first}' and '{second}' both have Slack Socket Mode enabled on the same gateway; only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing."; + +export interface SlackSocketModeGatewayStatusHookOptions { + readonly registryEntries?: + | readonly ConflictRegistryEntry[] + | (() => readonly ConflictRegistryEntry[]); + readonly detectOverlaps?: ( + entries: readonly ConflictRegistryEntry[], + ) => readonly SlackGatewayOverlap[]; +} + +export function createSlackSocketModeGatewayStatusHook( + options: SlackSocketModeGatewayStatusHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "slack") return {}; + const entries = resolveRegistryEntries(context.inputs?.registryEntries, options); + if (!entries || entries.length === 0) return {}; + + const detectOverlaps = options.detectOverlaps ?? detectAllSlackSocketModeGatewayOverlaps; + const overlaps = detectOverlaps(entries); + if (overlaps.length === 0) return {}; + + return { + outputs: { + gatewayOverlaps: { + kind: "status", + value: { + type: "messaging-overlaps", + overlaps: overlaps.map(({ gatewayName, sandboxes }) => ({ + channel: "slack", + gatewayName, + sandboxes, + reason: "socket-mode-gateway", + message: SLACK_SOCKET_MODE_GATEWAY_STATUS_MESSAGE, + })), + }, + }, + }, + }; + }; +} + +export function createSlackSocketModeGatewayStatusHookRegistration( + options: SlackSocketModeGatewayStatusHookOptions = {}, +): MessagingHookRegistration { + return { + id: SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID, + handler: createSlackSocketModeGatewayStatusHook(options), + }; +} + +function resolveRegistryEntries( + inputEntries: MessagingSerializableValue | undefined, + options: SlackSocketModeGatewayStatusHookOptions, +): readonly ConflictRegistryEntry[] | null { + const parsed = parseRegistryEntries(inputEntries); + if (parsed) return parsed; + const entries = + typeof options.registryEntries === "function" + ? options.registryEntries() + : options.registryEntries; + return entries ? [...entries] : null; +} + +function parseRegistryEntries( + value: MessagingSerializableValue | undefined, +): readonly ConflictRegistryEntry[] | null { + if (!Array.isArray(value)) return null; + return value.flatMap((entry) => { + if (!isObject(entry) || typeof entry.name !== "string" || entry.name.length === 0) { + return []; + } + return [ + { + name: entry.name, + gatewayName: normalizeNullableString(entry.gatewayName), + messaging: isObject(entry.messaging) + ? (entry.messaging as ConflictRegistryEntry["messaging"]) + : null, + }, + ]; + }); +} + +function normalizeNullableString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function isObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 6d7f6e8077..2403792f29 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -147,6 +147,58 @@ export const slackManifest = { }, }, ], + runtime: { + openclaw: { + visibility: { + configKeys: ["slack"], + logPatterns: ["slack"], + }, + envAliases: [ + { + envKey: "SLACK_BOT_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", + value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", + message: + "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + { + envKey: "SLACK_APP_TOKEN", + match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", + value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", + message: + "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", + }, + ], + nodePreloads: [ + { + module: "slack-channel-guard", + injectInto: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Slack channel guard (unhandled-rejection safety net)", + installedMessage: "[channels] Slack channel guard installed (NODE_OPTIONS updated)", + }, + ], + secretScans: [ + { + path: "/sandbox/.openclaw/openclaw.json", + pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", + message: "[SECURITY] Slack token leaked into {path} - refusing to serve", + exitCode: 78, + }, + ], + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/slack@{{openclaw.version}}", + pin: true, + required: true, + }, + ], state: { persist: { allowedIds: ["allowedUsers"], @@ -170,138 +222,24 @@ export const slackManifest = { handler: "slack.socketModeGatewayConflict", onFailure: "abort", }, - { - id: "slack-runtime-preload", - phase: "runtime-preload", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "slackRuntimePreload", - kind: "runtime-preload", - required: true, - value: { - envAliases: [ - { - envKey: "SLACK_BOT_TOKEN", - match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_BOT_TOKEN$", - value: "xoxb-OPENSHELL-RESOLVE-ENV-SLACK_BOT_TOKEN", - message: - "[channels] Normalized SLACK_BOT_TOKEN runtime placeholder to the Bolt-compatible alias", - }, - { - envKey: "SLACK_APP_TOKEN", - match: "^openshell:resolve:env:(v[0-9]+_)?SLACK_APP_TOKEN$", - value: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN", - message: - "[channels] Normalized SLACK_APP_TOKEN runtime placeholder to the Bolt-compatible alias", - }, - ], - preloads: [ - { - source: "/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js", - target: "/tmp/nemoclaw-slack-channel-guard.js", - nodeOptions: ["boot", "connect"], - optional: false, - installMessage: - "[channels] Installing Slack channel guard (unhandled-rejection safety net)", - installedMessage: "[channels] Slack channel guard installed (NODE_OPTIONS updated)", - }, - ], - secretScans: [ - { - path: "/sandbox/.openclaw/openclaw.json", - pattern: "(?:xoxb|xapp)-(?!OPENSHELL-RESOLVE-ENV-)", - message: "[SECURITY] Slack token leaked into {path} - refusing to serve", - exitCode: 78, - }, - ], - }, - }, - ], - onFailure: "abort", - }, { id: "slack-openclaw-bridge-health", phase: "health-check", - handler: "common.staticOutputs", + handler: "slack.openclawBridgeHealth", agents: ["openclaw"], - outputs: [ - { - id: "openclawBridgeStartup", - kind: "health-check", - required: true, - value: { - type: "openclaw-bridge-startup", - configFile: "/sandbox/.openclaw/openclaw.json", - channelConfigPath: "channels.slack", - enabledPath: "enabled", - logFile: "/tmp/gateway.log", - maxLogLines: 400, - logLinePattern: "^\\[slack\\] |^\\[channels\\] \\[slack\\]", - warningPattern: - "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", - positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", - }, - }, - ], onFailure: "abort", }, - { - id: "slack-openclaw-runtime-status", - phase: "status", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawRuntimeChannel", - kind: "status", - required: true, - value: { - type: "openclaw-runtime-channel", - configKeys: ["slack"], - logPatterns: ["slack"], - }, - }, - ], - }, { id: "slack-socket-mode-gateway-status", phase: "status", - handler: "common.staticOutputs", + handler: "slack.socketModeGatewayStatus", outputs: [ { - id: "singleGatewayOverlap", + id: "gatewayOverlaps", kind: "status", - required: true, - value: { - type: "single-gateway-channel-overlap", - reason: "socket-mode-gateway", - message: - "'{first}' and '{second}' both have Slack Socket Mode enabled on the same gateway; only one sandbox can receive Slack Socket Mode events unless the gateway supports multiplexing.", - }, }, ], }, - { - id: "slack-openclaw-package-install", - phase: "agent-install", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawPluginPackage", - kind: "package-install", - required: true, - value: { - manager: "openclaw-plugin", - spec: "npm:@openclaw/slack@{{openclaw.version}}", - pin: true, - }, - }, - ], - onFailure: "abort", - }, { id: "slack-token-paste", phase: "enroll", diff --git a/nemoclaw-blueprint/scripts/slack-channel-guard.js b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js similarity index 100% rename from nemoclaw-blueprint/scripts/slack-channel-guard.js rename to src/lib/messaging/channels/slack/runtime/slack-channel-guard.js diff --git a/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.test.ts b/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.test.ts new file mode 100644 index 0000000000..1a5333d47e --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.test.ts @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { runMessagingHookSync } from "../../../hooks"; +import { MessagingHookRegistry } from "../../../hooks/registry"; +import { + createTelegramGatewayConflictStatusHookRegistration, + TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID, +} from "./gateway-conflict-status"; + +const HOOK = { + id: "telegram-gateway-conflict-status", + phase: "status", + handler: TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID, + outputs: [{ id: "bridgeHealth", kind: "status" }], +} as const; + +describe("telegram.gatewayConflictStatus hook", () => { + it("counts Telegram getUpdates/409 conflict signatures from the gateway log", () => { + const executeSandboxCommand = vi.fn(() => ({ + status: 0, + stdout: "getUpdates conflict\n409 Conflict\n409: Conflict\nunrelated\n", + })); + const registry = new MessagingHookRegistry([ + createTelegramGatewayConflictStatusHookRegistration({ executeSandboxCommand }), + ]); + + const result = runMessagingHookSync(HOOK, registry, { + channelId: "telegram", + inputs: { currentSandbox: "alpha" }, + }); + + expect(result.outputs.bridgeHealth).toEqual({ + kind: "status", + value: { + type: "messaging-bridge-health", + channel: "telegram", + conflicts: 3, + logFile: "/tmp/gateway.log", + }, + }); + expect(executeSandboxCommand).toHaveBeenCalledWith( + "alpha", + "tail -n 200 /tmp/gateway.log 2>/dev/null || true", + 3000, + ); + }); + + it("emits no status output when no conflict signature is present", () => { + const registry = new MessagingHookRegistry([ + createTelegramGatewayConflictStatusHookRegistration({ + executeSandboxCommand: () => ({ status: 0, stdout: "provider ready\n" }), + }), + ]); + + expect( + runMessagingHookSync(HOOK, registry, { + channelId: "telegram", + inputs: { currentSandbox: "alpha" }, + }).outputs, + ).toEqual({}); + }); +}); diff --git a/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts b/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts new file mode 100644 index 0000000000..1e573b12c9 --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/gateway-conflict-status.ts @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { MessagingHookHandler, MessagingHookRegistration } from "../../../hooks/types"; + +export const TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID = "telegram.gatewayConflictStatus"; + +const GATEWAY_LOG_FILE = "/tmp/gateway.log"; +const DEFAULT_LOG_LINES = 200; +const DEFAULT_TIMEOUT_MS = 3000; +const TELEGRAM_CONFLICT_PATTERN = /getUpdates conflict|409\s*:?\s*Conflict/i; + +export interface TelegramGatewayConflictStatusCommandResult { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +} + +export type TelegramGatewayConflictStatusCommandRunner = ( + sandboxName: string, + command: string, + timeoutMs: number, +) => TelegramGatewayConflictStatusCommandResult | null | undefined; + +export interface TelegramGatewayConflictStatusHookOptions { + readonly sandboxName?: string | null | (() => string | null); + readonly executeSandboxCommand?: TelegramGatewayConflictStatusCommandRunner; + readonly maxLogLines?: number; + readonly timeoutMs?: number; +} + +export function createTelegramGatewayConflictStatusHook( + options: TelegramGatewayConflictStatusHookOptions = {}, +): MessagingHookHandler { + return (context) => { + if (context.channelId !== "telegram") return {}; + const sandboxName = resolveSandboxName(context.inputs?.currentSandbox, options.sandboxName); + const execute = options.executeSandboxCommand; + if (!sandboxName || !execute) return {}; + + const maxLogLines = normalizeLogLines(options.maxLogLines); + const timeoutMs = normalizeTimeoutMs(options.timeoutMs); + const command = `tail -n ${maxLogLines} ${GATEWAY_LOG_FILE} 2>/dev/null || true`; + const result = execute(sandboxName, command, timeoutMs); + if (!result) return {}; + + const conflicts = countTelegramConflictLines(String(result.stdout ?? "")); + if (conflicts === 0) return {}; + + return { + outputs: { + bridgeHealth: { + kind: "status", + value: { + type: "messaging-bridge-health", + channel: "telegram", + conflicts, + logFile: GATEWAY_LOG_FILE, + }, + }, + }, + }; + }; +} + +export function createTelegramGatewayConflictStatusHookRegistration( + options: TelegramGatewayConflictStatusHookOptions = {}, +): MessagingHookRegistration { + return { + id: TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID, + handler: createTelegramGatewayConflictStatusHook(options), + }; +} + +export function countTelegramConflictLines(logTail: string): number { + return logTail + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && TELEGRAM_CONFLICT_PATTERN.test(line)).length; +} + +function resolveSandboxName( + inputValue: unknown, + optionValue: string | null | (() => string | null) | undefined, +): string | null { + const input = normalizeString(inputValue); + if (input) return input; + const resolved = typeof optionValue === "function" ? optionValue() : optionValue; + return normalizeString(resolved); +} + +function normalizeString(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeLogLines(value: unknown): number { + return normalizePositiveInteger(value, DEFAULT_LOG_LINES, 2000); +} + +function normalizeTimeoutMs(value: unknown): number { + return normalizePositiveInteger(value, DEFAULT_TIMEOUT_MS, 30000); +} + +function normalizePositiveInteger(value: unknown, fallback: number, max: number): number { + if (typeof value !== "number" || !Number.isFinite(value)) return fallback; + return Math.min(Math.max(Math.trunc(value), 1), max); +} diff --git a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts index 43448f6569..17fb6d0c39 100644 --- a/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts +++ b/src/lib/messaging/channels/telegram/hooks/get-me-reachability.ts @@ -7,6 +7,14 @@ import { createTelegramAllowlistAliasesHookRegistration, type TelegramAllowlistAliasesHookOptions, } from "./allowlist-aliases"; +import { + createTelegramGatewayConflictStatusHookRegistration, + type TelegramGatewayConflictStatusHookOptions, +} from "./gateway-conflict-status"; +import { + createTelegramOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, +} from "./openclaw-bridge-health"; export const TELEGRAM_GET_ME_REACHABILITY_HOOK_ID = "telegram.getMeReachability"; const DEFAULT_TELEGRAM_REACHABILITY_TIMEOUT_MS = 10_000; @@ -35,6 +43,11 @@ export interface TelegramGetMeReachabilityHookOptions extends TelegramAllowlistA readonly log?: (message: string) => void; } +export interface TelegramHookOptions extends TelegramGetMeReachabilityHookOptions { + readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; + readonly gatewayConflictStatus?: TelegramGatewayConflictStatusHookOptions; +} + export function createTelegramGetMeReachabilityHook( options: TelegramGetMeReachabilityHookOptions = {}, ): MessagingHookHandler { @@ -84,10 +97,12 @@ export function createTelegramGetMeReachabilityHook( } export function createTelegramHookRegistrations( - options: TelegramGetMeReachabilityHookOptions = {}, + options: TelegramHookOptions = {}, ): readonly MessagingHookRegistration[] { return [ createTelegramAllowlistAliasesHookRegistration(options), + createTelegramOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth), + createTelegramGatewayConflictStatusHookRegistration(options.gatewayConflictStatus), { id: TELEGRAM_GET_ME_REACHABILITY_HOOK_ID, handler: createTelegramGetMeReachabilityHook(options), diff --git a/src/lib/messaging/channels/telegram/hooks/index.ts b/src/lib/messaging/channels/telegram/hooks/index.ts index 604e80d5ca..ff7be83ebe 100644 --- a/src/lib/messaging/channels/telegram/hooks/index.ts +++ b/src/lib/messaging/channels/telegram/hooks/index.ts @@ -1,5 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -export * from "./get-me-reachability"; export * from "./allowlist-aliases"; +export * from "./gateway-conflict-status"; +export * from "./get-me-reachability"; +export * from "./openclaw-bridge-health"; diff --git a/src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts b/src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts new file mode 100644 index 0000000000..057d58ffab --- /dev/null +++ b/src/lib/messaging/channels/telegram/hooks/openclaw-bridge-health.ts @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + createOpenClawBridgeHealthHookRegistration, + type OpenClawBridgeHealthHookOptions, + type OpenClawBridgeHealthStartupContext, +} from "../../openclaw-bridge-health"; + +export type { OpenClawBridgeHealthHookOptions } from "../../openclaw-bridge-health"; + +export const TELEGRAM_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID = "telegram.openclawBridgeHealth"; + +export function createTelegramOpenClawBridgeHealthHookRegistration( + options: OpenClawBridgeHealthHookOptions = {}, +) { + return createOpenClawBridgeHealthHookRegistration( + { + channelId: "telegram", + handlerId: TELEGRAM_OPENCLAW_BRIDGE_HEALTH_HOOK_HANDLER_ID, + onStartupDetected: printTelegramDirectMessageAllowlistWarning, + }, + options, + ); +} + +function printTelegramDirectMessageAllowlistWarning({ + channelBlock, + log, +}: OpenClawBridgeHealthStartupContext): void { + const accountContainer = getObjectPath(channelBlock, "accounts"); + if (!isObjectRecord(accountContainer)) return; + const account = isObjectRecord(accountContainer.default) + ? accountContainer.default + : getFirstObjectValue(accountContainer); + const allowFrom = getObjectPath(account, "allowFrom"); + const allowedCount = Array.isArray(allowFrom) ? allowFrom.length : 0; + if (getObjectPath(account, "dmPolicy") !== "allowlist" || allowedCount > 0) return; + + log(" ⚠ Telegram direct-message allowlist is empty in baked openclaw.json."); + log( + " Set TELEGRAM_ALLOWED_IDS before rebuild, or complete OpenClaw pairing before expecting DM replies.", + ); + log( + " Telegram Bot API sendMessage tests outbound delivery only; send from a Telegram client to test inbound agent replies.", + ); +} + +function getFirstObjectValue(value: Record): Record | null { + for (const entry of Object.values(value)) { + if (isObjectRecord(entry)) return entry; + } + return null; +} + +function getObjectPath(value: unknown, dottedPath: string): unknown { + let current = value; + for (const segment of dottedPath.split(".").filter(Boolean)) { + if (!isObjectRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 056fa47558..8b9a1a66cf 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -158,6 +158,24 @@ export const telegramManifest = { }, }, ], + runtime: { + openclaw: { + visibility: { + configKeys: ["telegram"], + logPatterns: ["telegram"], + }, + nodePreloads: [ + { + module: "telegram-diagnostics", + injectInto: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", + installedMessage: "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }, + }, state: { persist: { allowedIds: ["allowedIds"], @@ -221,106 +239,21 @@ export const telegramManifest = { inputs: ["botToken"], onFailure: "skip-channel", }, - { - id: "telegram-runtime-preload", - phase: "runtime-preload", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "telegramDiagnostics", - kind: "runtime-preload", - required: true, - value: { - preloads: [ - { - source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", - target: "/tmp/nemoclaw-telegram-diagnostics.js", - nodeOptions: ["boot", "connect"], - optional: false, - installMessage: - "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", - installedMessage: - "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", - }, - ], - }, - }, - ], - onFailure: "abort", - }, { id: "telegram-openclaw-bridge-health", phase: "health-check", - handler: "common.staticOutputs", + handler: "telegram.openclawBridgeHealth", agents: ["openclaw"], - outputs: [ - { - id: "openclawBridgeStartup", - kind: "health-check", - required: true, - value: { - type: "openclaw-bridge-startup", - configFile: "/sandbox/.openclaw/openclaw.json", - channelConfigPath: "channels.telegram", - enabledPath: "enabled", - logFile: "/tmp/gateway.log", - maxLogLines: 400, - logLinePattern: "^\\[telegram\\] |^\\[channels\\] \\[telegram\\]", - warningPattern: - "credential placeholder|Bot API rejected|startup probe (?:failed|returned)|provider failed to start|bridge did not start within|invalid_auth|token_revoked|token_expired", - positivePattern: "\\bstarting provider\\b|\\bprovider ready\\b", - allowlistWarning: { - accountContainerPath: "accounts", - preferredAccountKey: "default", - policyPath: "dmPolicy", - requiredPolicy: "allowlist", - allowListPath: "allowFrom", - messages: [ - "Telegram direct-message allowlist is empty in baked openclaw.json.", - "Set TELEGRAM_ALLOWED_IDS before rebuild, or complete OpenClaw pairing before expecting DM replies.", - "Telegram Bot API sendMessage tests outbound delivery only; send from a Telegram client to test inbound agent replies.", - ], - }, - }, - }, - ], onFailure: "abort", }, - { - id: "telegram-openclaw-runtime-status", - phase: "status", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawRuntimeChannel", - kind: "status", - required: true, - value: { - type: "openclaw-runtime-channel", - configKeys: ["telegram"], - logPatterns: ["telegram"], - }, - }, - ], - }, { id: "telegram-gateway-conflict-status", phase: "status", - handler: "common.staticOutputs", + handler: "telegram.gatewayConflictStatus", outputs: [ { - id: "gatewayConflictCounter", + id: "bridgeHealth", kind: "status", - required: true, - value: { - type: "gateway-log-conflict-counter", - logFile: "/tmp/gateway.log", - maxLogLines: 200, - pattern: "getUpdates conflict|409\\s*:?\\s*Conflict", - flags: "i", - }, }, ], }, diff --git a/nemoclaw-blueprint/scripts/telegram-diagnostics.js b/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js similarity index 100% rename from nemoclaw-blueprint/scripts/telegram-diagnostics.js rename to src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index c4ccd28af9..7299c64d9f 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -108,6 +108,34 @@ export const wechatManifest = { }, }, ], + runtime: { + openclaw: { + visibility: { + configKeys: ["openclaw-weixin"], + logPatterns: ["wechat", "openclaw-weixin"], + }, + nodePreloads: [ + { + module: "wechat-diagnostics", + injectInto: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing WeChat diagnostics (provider readiness + inference errors)", + installedMessage: "[channels] WeChat diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: WECHAT_PLUGIN_INSTALL_SPEC, + pin: true, + required: true, + }, + ], state: { persist: { wechatConfig: ["accountId", "baseUrl", "userId"], @@ -133,25 +161,6 @@ export const wechatManifest = { ], }, hooks: [ - { - id: "wechat-openclaw-package-install", - phase: "agent-install", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawPluginPackage", - kind: "package-install", - required: true, - value: { - manager: "openclaw-plugin", - spec: WECHAT_PLUGIN_INSTALL_SPEC, - pin: true, - }, - }, - ], - onFailure: "abort", - }, { id: "wechat-host-qr", phase: "enroll", @@ -224,33 +233,6 @@ export const wechatManifest = { ], onFailure: "abort", }, - { - id: "wechat-runtime-preload", - phase: "runtime-preload", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "wechatDiagnostics", - kind: "runtime-preload", - required: true, - value: { - preloads: [ - { - source: "/usr/local/lib/nemoclaw/preloads/wechat-diagnostics.js", - target: "/tmp/nemoclaw-wechat-diagnostics.js", - nodeOptions: ["boot", "connect"], - optional: false, - installMessage: - "[channels] Installing WeChat diagnostics (provider readiness + inference errors)", - installedMessage: "[channels] WeChat diagnostics installed (NODE_OPTIONS updated)", - }, - ], - }, - }, - ], - onFailure: "abort", - }, { id: "wechat-health-check", phase: "health-check", @@ -258,23 +240,5 @@ export const wechatManifest = { inputs: ["wechatConfig.accountId"], onFailure: "abort", }, - { - id: "wechat-openclaw-runtime-status", - phase: "status", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawRuntimeChannel", - kind: "status", - required: true, - value: { - type: "openclaw-runtime-channel", - configKeys: ["openclaw-weixin"], - logPatterns: ["wechat", "openclaw-weixin"], - }, - }, - ], - }, ], } as const satisfies ChannelManifest; diff --git a/nemoclaw-blueprint/scripts/wechat-diagnostics.js b/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js similarity index 100% rename from nemoclaw-blueprint/scripts/wechat-diagnostics.js rename to src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 3bd0b61b03..85d069dabf 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -85,6 +85,33 @@ export const whatsappManifest = { }, }, ], + runtime: { + openclaw: { + visibility: { + configKeys: ["whatsapp"], + logPatterns: ["whatsapp"], + }, + nodePreloads: [ + { + module: "whatsapp-qr-compact", + injectInto: ["connect"], + optional: true, + installMessage: + "[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)", + }, + ], + }, + }, + agentPackages: [ + { + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@openclaw/whatsapp@{{openclaw.version}}", + pin: true, + required: true, + }, + ], state: { persist: { allowedIds: ["allowedIds"], @@ -96,69 +123,5 @@ export const whatsappManifest = { }, ], }, - hooks: [ - { - id: "whatsapp-runtime-preload", - phase: "runtime-preload", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "whatsappQrCompact", - kind: "runtime-preload", - required: true, - value: { - preloads: [ - { - source: "/usr/local/lib/nemoclaw/preloads/whatsapp-qr-compact.js", - target: "/tmp/nemoclaw-whatsapp-qr-compact.js", - nodeOptions: ["connect"], - optional: true, - installMessage: - "[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)", - }, - ], - }, - }, - ], - onFailure: "abort", - }, - { - id: "whatsapp-openclaw-runtime-status", - phase: "status", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawRuntimeChannel", - kind: "status", - required: true, - value: { - type: "openclaw-runtime-channel", - configKeys: ["whatsapp"], - logPatterns: ["whatsapp"], - }, - }, - ], - }, - { - id: "whatsapp-openclaw-package-install", - phase: "agent-install", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "openclawPluginPackage", - kind: "package-install", - required: true, - value: { - manager: "openclaw-plugin", - spec: "npm:@openclaw/whatsapp@{{openclaw.version}}", - pin: true, - }, - }, - ], - onFailure: "abort", - }, - ], + hooks: [], } as const satisfies ChannelManifest; diff --git a/nemoclaw-blueprint/scripts/whatsapp-qr-compact.js b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.js similarity index 100% rename from nemoclaw-blueprint/scripts/whatsapp-qr-compact.js rename to src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.js diff --git a/src/lib/messaging/compiler/engines/build-step-engine.ts b/src/lib/messaging/compiler/engines/build-step-engine.ts index 2379b401a0..328a278e64 100644 --- a/src/lib/messaging/compiler/engines/build-step-engine.ts +++ b/src/lib/messaging/compiler/engines/build-step-engine.ts @@ -7,6 +7,7 @@ import type { ChannelHookOutputSpec, ChannelManifest, MessagingAgentId, + MessagingSerializableObject, MessagingSerializableValue, SandboxMessagingBuildStepPlan, SandboxMessagingChannelPlan, @@ -21,6 +22,22 @@ export async function planBuildSteps( hooks: MessagingHookRegistry, ): Promise { const steps: SandboxMessagingBuildStepPlan[] = []; + for (const agentPackage of manifest.agentPackages ?? []) { + if (agentPackage.agent !== agent) continue; + const value: MessagingSerializableObject = { + manager: agentPackage.manager, + spec: agentPackage.spec, + ...(typeof agentPackage.pin === "boolean" ? { pin: agentPackage.pin } : {}), + }; + steps.push({ + channelId: manifest.id, + kind: "package-install", + outputId: agentPackage.id, + required: agentPackage.required !== false, + ...(channel?.active ? { value } : {}), + }); + } + for (const hook of manifest.hooks) { if (hook.agents && !hook.agents.includes(agent)) continue; const buildOutputs = (hook.outputs ?? []).filter(isBuildStepOutput); diff --git a/src/lib/messaging/compiler/engines/health-check-engine.ts b/src/lib/messaging/compiler/engines/health-check-engine.ts index d19952efbf..680d7bc983 100644 --- a/src/lib/messaging/compiler/engines/health-check-engine.ts +++ b/src/lib/messaging/compiler/engines/health-check-engine.ts @@ -4,14 +4,16 @@ import type { ChannelManifest, SandboxMessagingHealthCheckPlan } from "../../manifest"; export function planHealthChecks(manifest: ChannelManifest): SandboxMessagingHealthCheckPlan[] { + const hookIds = manifest.hooks + .filter((hook) => hook.phase === "health-check") + .map((hook) => hook.id); + if (hookIds.length === 0) return []; return [ { channelId: manifest.id, phase: "health-check", requiredBefore: "lifecycle-success", - hookIds: manifest.hooks - .filter((hook) => hook.phase === "health-check") - .map((hook) => hook.id), + hookIds, }, ]; } diff --git a/src/lib/messaging/compiler/engines/runtime-setup-engine.ts b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts new file mode 100644 index 0000000000..90bdf7401e --- /dev/null +++ b/src/lib/messaging/compiler/engines/runtime-setup-engine.ts @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { + ChannelManifest, + ChannelRuntimeNodePreloadSpec, + MessagingAgentId, + SandboxMessagingChannelPlan, + SandboxMessagingRuntimeEnvAliasPlan, + SandboxMessagingRuntimeNodePreloadPlan, + SandboxMessagingRuntimeSecretScanPlan, + SandboxMessagingRuntimeSetupPlan, +} from "../../manifest"; + +const PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"; +const PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"; +const NODE_PRELOAD_MODULE_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +export function planRuntimeSetup( + manifests: readonly ChannelManifest[], + agent: MessagingAgentId, + channels: readonly SandboxMessagingChannelPlan[], +): SandboxMessagingRuntimeSetupPlan { + const activeChannelIds = new Set( + channels + .filter((channel) => channel.active && !channel.disabled) + .map((channel) => channel.channelId), + ); + const nodePreloads: SandboxMessagingRuntimeNodePreloadPlan[] = []; + const envAliases: SandboxMessagingRuntimeEnvAliasPlan[] = []; + const secretScans: SandboxMessagingRuntimeSecretScanPlan[] = []; + + for (const manifest of manifests) { + if (!activeChannelIds.has(manifest.id)) continue; + const runtime = manifest.runtime?.[agent]; + if (!runtime) continue; + nodePreloads.push( + ...(runtime.nodePreloads ?? []).map((entry) => resolveNodePreload(manifest, entry)), + ); + envAliases.push( + ...(runtime.envAliases ?? []).map((entry) => ({ + channelId: manifest.id, + ...entry, + })), + ); + secretScans.push( + ...(runtime.secretScans ?? []).map((entry) => ({ + channelId: manifest.id, + ...entry, + })), + ); + } + + return { nodePreloads, envAliases, secretScans }; +} + +function resolveNodePreload( + manifest: ChannelManifest, + entry: ChannelRuntimeNodePreloadSpec, +): SandboxMessagingRuntimeNodePreloadPlan { + if (!NODE_PRELOAD_MODULE_PATTERN.test(entry.module)) { + throw new Error( + `Channel manifest '${manifest.id}' declares invalid runtime node preload module '${entry.module}'.`, + ); + } + return { + channelId: manifest.id, + ...entry, + source: `${PRELOAD_SOURCE_PREFIX}${entry.module}.js`, + target: `${PRELOAD_TARGET_PREFIX}${entry.module}.js`, + }; +} diff --git a/src/lib/messaging/compiler/manifest-compiler.test.ts b/src/lib/messaging/compiler/manifest-compiler.test.ts index 34935d5fed..b3e72304a8 100644 --- a/src/lib/messaging/compiler/manifest-compiler.test.ts +++ b/src/lib/messaging/compiler/manifest-compiler.test.ts @@ -205,16 +205,12 @@ describe("ManifestCompiler", () => { { channelId: "discord", kind: "package-install", - hookId: "discord-openclaw-package-install", - handler: "common.staticOutputs", outputId: "openclawPluginPackage", required: true, }, { channelId: "wechat", kind: "package-install", - hookId: "wechat-openclaw-package-install", - handler: "common.staticOutputs", outputId: "openclawPluginPackage", required: true, }, @@ -245,16 +241,12 @@ describe("ManifestCompiler", () => { { channelId: "slack", kind: "package-install", - hookId: "slack-openclaw-package-install", - handler: "common.staticOutputs", outputId: "openclawPluginPackage", required: true, }, { channelId: "whatsapp", kind: "package-install", - hookId: "whatsapp-openclaw-package-install", - handler: "common.staticOutputs", outputId: "openclawPluginPackage", required: true, }, @@ -287,21 +279,31 @@ describe("ManifestCompiler", () => { statePath: "wechatConfig.accountId", env: "WECHAT_ACCOUNT_ID", }); - expect(plan.healthChecks).toHaveLength(ALL_CHANNELS.length); - expect(plan.healthChecks.every((check) => check.requiredBefore === "lifecycle-success")).toBe( - true, - ); - expect(plan.healthChecks.find((check) => check.channelId === "telegram")?.hookIds).toEqual([ - "telegram-openclaw-bridge-health", - ]); - expect(plan.healthChecks.find((check) => check.channelId === "discord")?.hookIds).toEqual([ - "discord-openclaw-bridge-health", - ]); - expect(plan.healthChecks.find((check) => check.channelId === "wechat")?.hookIds).toEqual([ - "wechat-health-check", - ]); - expect(plan.healthChecks.find((check) => check.channelId === "slack")?.hookIds).toEqual([ - "slack-openclaw-bridge-health", + expect(plan.healthChecks).toEqual([ + { + channelId: "telegram", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: ["telegram-openclaw-bridge-health"], + }, + { + channelId: "discord", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: ["discord-openclaw-bridge-health"], + }, + { + channelId: "wechat", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: ["wechat-health-check"], + }, + { + channelId: "slack", + phase: "health-check", + requiredBefore: "lifecycle-success", + hookIds: ["slack-openclaw-bridge-health"], + }, ]); expect( plan.agentRender.find( @@ -549,11 +551,10 @@ describe("ManifestCompiler", () => { "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", - "telegram-runtime-preload", "telegram-openclaw-bridge-health", - "telegram-openclaw-runtime-status", "telegram-gateway-conflict-status", ]); + expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); expect(plan.credentialBindings.map((binding) => binding.channelId)).toEqual(["telegram"]); expect(plan.networkPolicy.entries.map((entry) => entry.channelId)).toEqual(["telegram"]); expect(plan.agentRender.map((render) => render.channelId)).toEqual(["telegram", "telegram"]); @@ -746,6 +747,7 @@ describe("ManifestCompiler", () => { "networkPolicy", "agentRender", "buildSteps", + "runtimeSetup", "stateUpdates", "healthChecks", ] satisfies Array); @@ -785,11 +787,10 @@ describe("ManifestCompiler", () => { "telegram-allowlist-aliases", "telegram-config-prompt", "telegram-get-me-reachability", - "telegram-runtime-preload", "telegram-openclaw-bridge-health", - "telegram-openclaw-runtime-status", "telegram-gateway-conflict-status", ]); + expect(plan.runtimeSetup).toEqual({ nodePreloads: [], envAliases: [], secretScans: [] }); }); it("compiles a non-built-in channel manifest through the same generic path", async () => { diff --git a/src/lib/messaging/compiler/manifest-compiler.ts b/src/lib/messaging/compiler/manifest-compiler.ts index 303b69f16b..78d6735584 100644 --- a/src/lib/messaging/compiler/manifest-compiler.ts +++ b/src/lib/messaging/compiler/manifest-compiler.ts @@ -31,6 +31,7 @@ import { planBuildSteps } from "./engines/build-step-engine"; import { planCredentialBindings } from "./engines/credential-binding-engine"; import { planHealthChecks } from "./engines/health-check-engine"; import { planNetworkPolicy } from "./engines/policy-resolver"; +import { planRuntimeSetup } from "./engines/runtime-setup-engine"; import { planStateUpdates } from "./engines/state-update-engine"; import type { RenderTemplateReferenceResolver } from "./engines/template"; import type { ManifestCompilerContext } from "./types"; @@ -91,6 +92,7 @@ export class ManifestCompiler { ), ) ).flat(); + const runtimeSetup = planRuntimeSetup(manifests, context.agent, channels); const stateUpdates = manifests.flatMap((manifest) => planStateUpdates(manifest)); const healthChecks = manifests.flatMap((manifest) => planHealthChecks(manifest)); @@ -105,6 +107,7 @@ export class ManifestCompiler { networkPolicy, agentRender, buildSteps, + runtimeSetup, stateUpdates, healthChecks, }; diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index 0c188de1ab..57e031ed46 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -9,6 +9,7 @@ import type { MessagingCompilerWorkflow, SandboxMessagingChannelPlan, SandboxMessagingPlan, + SandboxMessagingRuntimeSetupPlan, } from "../manifest"; import type { RenderTemplateReferenceResolver } from "./engines/template"; import { ManifestCompiler } from "./manifest-compiler"; @@ -315,6 +316,7 @@ function mergeSandboxMessagingPlans( }, agentRender: mergePlanEntriesByChannel(existing.agentRender, incoming.agentRender), buildSteps: mergePlanEntriesByChannel(existing.buildSteps, incoming.buildSteps), + runtimeSetup: mergeRuntimeSetup(existing.runtimeSetup, incoming.runtimeSetup), stateUpdates: mergePlanEntriesByChannel(existing.stateUpdates, incoming.stateUpdates), healthChecks: mergePlanEntriesByChannel(existing.healthChecks, incoming.healthChecks), }); @@ -436,11 +438,40 @@ function removePlanChannel( }, agentRender: plan.agentRender.filter(keepEntry), buildSteps: plan.buildSteps.filter(keepEntry), + runtimeSetup: filterRuntimeSetup(plan.runtimeSetup, keepEntry), stateUpdates: plan.stateUpdates.filter(keepEntry), healthChecks: plan.healthChecks.filter(keepEntry), }); } +function mergeRuntimeSetup( + existing: SandboxMessagingRuntimeSetupPlan | undefined, + incoming: SandboxMessagingRuntimeSetupPlan | undefined, +): SandboxMessagingRuntimeSetupPlan { + return { + nodePreloads: mergePlanEntriesByChannel( + existing?.nodePreloads ?? [], + incoming?.nodePreloads ?? [], + ), + envAliases: mergePlanEntriesByChannel(existing?.envAliases ?? [], incoming?.envAliases ?? []), + secretScans: mergePlanEntriesByChannel( + existing?.secretScans ?? [], + incoming?.secretScans ?? [], + ), + }; +} + +function filterRuntimeSetup( + setup: SandboxMessagingRuntimeSetupPlan | undefined, + keepEntry: (entry: T) => boolean, +): SandboxMessagingRuntimeSetupPlan { + return { + nodePreloads: (setup?.nodePreloads ?? []).filter(keepEntry), + envAliases: (setup?.envAliases ?? []).filter(keepEntry), + secretScans: (setup?.secretScans ?? []).filter(keepEntry), + }; +} + function isChannelPlanStartable(channel: SandboxMessagingChannelPlan): boolean { if (!channel.configured) return false; return channel.inputs.every((input) => { diff --git a/src/lib/messaging/hooks/builtins.ts b/src/lib/messaging/hooks/builtins.ts index 7b1bf110c4..0e8ad9e11b 100644 --- a/src/lib/messaging/hooks/builtins.ts +++ b/src/lib/messaging/hooks/builtins.ts @@ -1,20 +1,24 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 +import { createDiscordHookRegistrations, type DiscordHookOptions } from "../channels/discord/hooks"; +import type { OpenClawBridgeHealthHookOptions } from "../channels/openclaw-bridge-health"; import { createSlackHookRegistrations, type SlackHookOptions } from "../channels/slack/hooks"; import { createTelegramHookRegistrations, - type TelegramGetMeReachabilityHookOptions, + type TelegramHookOptions, } from "../channels/telegram/hooks"; import { createWechatHookRegistrations, type WechatHookOptions } from "../channels/wechat/hooks"; -import { createCommonHookRegistrations, type CommonHookOptions } from "./common"; +import { type CommonHookOptions, createCommonHookRegistrations } from "./common"; import { MessagingHookRegistry } from "./registry"; import type { MessagingHookRegistration } from "./types"; export interface BuiltInMessagingHookOptions { readonly common?: CommonHookOptions; + readonly discord?: DiscordHookOptions; + readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions; readonly slack?: SlackHookOptions; - readonly telegram?: TelegramGetMeReachabilityHookOptions; + readonly telegram?: TelegramHookOptions; readonly wechat?: WechatHookOptions; } @@ -23,8 +27,15 @@ export function createBuiltInMessagingHookRegistrations( ): readonly MessagingHookRegistration[] { return [ ...createCommonHookRegistrations(options.common), - ...createSlackHookRegistrations(options.slack), - ...createTelegramHookRegistrations(options.telegram), + ...createDiscordHookRegistrations( + withOpenClawBridgeHealthOptions(options.discord, options.openclawBridgeHealth), + ), + ...createSlackHookRegistrations( + withOpenClawBridgeHealthOptions(options.slack, options.openclawBridgeHealth), + ), + ...createTelegramHookRegistrations( + withOpenClawBridgeHealthOptions(options.telegram, options.openclawBridgeHealth), + ), ...createWechatHookRegistrations(options.wechat), ]; } @@ -36,3 +47,15 @@ export function createBuiltInMessagingHookRegistry( } export const BUILT_IN_MESSAGING_HOOK_REGISTRY = createBuiltInMessagingHookRegistry(); + +function withOpenClawBridgeHealthOptions< + T extends { readonly openclawBridgeHealth?: OpenClawBridgeHealthHookOptions }, +>(options: T | undefined, openclawBridgeHealth: OpenClawBridgeHealthHookOptions | undefined): T { + return { + ...options, + openclawBridgeHealth: { + ...openclawBridgeHealth, + ...options?.openclawBridgeHealth, + }, + } as T; +} diff --git a/src/lib/messaging/hooks/hook-runner.test.ts b/src/lib/messaging/hooks/hook-runner.test.ts index 184a7fceec..835599aecc 100644 --- a/src/lib/messaging/hooks/hook-runner.test.ts +++ b/src/lib/messaging/hooks/hook-runner.test.ts @@ -8,6 +8,7 @@ import { createBuiltInMessagingHookRegistry, MessagingHookRegistry, runMessagingHook, + runMessagingHookSync, } from "./index"; const HOST_QR_HOOK = { @@ -36,9 +37,14 @@ describe("MessagingHookRegistry", () => { "common.staticOutputs", "common.tokenPaste", "common.configPrompt", + "discord.openclawBridgeHealth", "slack.socketModeGatewayConflict", + "slack.socketModeGatewayStatus", + "slack.openclawBridgeHealth", "slack.validateCredentials", "telegram.allowlistAliases", + "telegram.openclawBridgeHealth", + "telegram.gatewayConflictStatus", "telegram.getMeReachability", "wechat.ilinkLogin", "wechat.seedOpenClawAccount", @@ -46,37 +52,91 @@ describe("MessagingHookRegistry", () => { ]); }); - it("returns declared static outputs for manifest-owned build and render hooks", async () => { + it("runs synchronous status hooks with the same output validation", () => { + const registry = new MessagingHookRegistry([ + { + id: "status.demo", + handler: () => ({ + outputs: { + bridgeHealth: { + kind: "status", + value: { + type: "messaging-bridge-health", + channel: "demo", + conflicts: 1, + }, + }, + }, + }), + }, + ]); + const hook = { + id: "demo-status", + phase: "status", + handler: "status.demo", + outputs: [{ id: "bridgeHealth", kind: "status" }], + } as const satisfies ChannelHookSpec; + + expect(runMessagingHookSync(hook, registry, { channelId: "demo" })).toEqual({ + hookId: "demo-status", + handlerId: "status.demo", + phase: "status", + outputs: { + bridgeHealth: { + kind: "status", + value: { + type: "messaging-bridge-health", + channel: "demo", + conflicts: 1, + }, + }, + }, + }); + }); + + it("returns declared static outputs for manifest-owned render hooks", async () => { const registry = createBuiltInMessagingHookRegistry(); const hook = { - id: "discord-openclaw-package-install", - phase: "agent-install", + id: "discord-openclaw-render", + phase: "render", handler: "common.staticOutputs", outputs: [ { - id: "openclawPluginPackage", - kind: "package-install", + id: "render", + kind: "agent-render", required: true, value: { - manager: "openclaw-plugin", - spec: "npm:@openclaw/discord@{{openclaw.version}}", - pin: true, + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.discord", + value: { + enabled: true, + }, + }, }, }, ], } as const satisfies ChannelHookSpec; await expect(runMessagingHook(hook, registry, { channelId: "discord" })).resolves.toEqual({ - hookId: "discord-openclaw-package-install", + hookId: "discord-openclaw-render", handlerId: "common.staticOutputs", - phase: "agent-install", + phase: "render", outputs: { - openclawPluginPackage: { - kind: "package-install", + render: { + kind: "agent-render", value: { - manager: "openclaw-plugin", - spec: "npm:@openclaw/discord@{{openclaw.version}}", - pin: true, + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { + path: "channels.discord", + value: { + enabled: true, + }, + }, }, }, }, diff --git a/src/lib/messaging/hooks/hook-runner.ts b/src/lib/messaging/hooks/hook-runner.ts index 2d0be8bd6c..4988e4dbe6 100644 --- a/src/lib/messaging/hooks/hook-runner.ts +++ b/src/lib/messaging/hooks/hook-runner.ts @@ -22,14 +22,29 @@ export async function runMessagingHook( context: MessagingHookRunContext, ): Promise { const handler = registry.require(hook.handler); - const result = await handler({ - channelId: context.channelId, + const result = await handler(buildHandlerContext(hook, context)); + const outputs = result.outputs ?? EMPTY_OUTPUTS; + + assertHookOutputsMatchDeclaration(hook, outputs); + + return { hookId: hook.id, + handlerId: hook.handler, phase: hook.phase, - ...(typeof context.isInteractive === "boolean" ? { isInteractive: context.isInteractive } : {}), - inputs: context.inputs, - outputDeclarations: hook.outputs, - }); + outputs, + }; +} + +export function runMessagingHookSync( + hook: ChannelHookSpec, + registry: MessagingHookRegistry, + context: MessagingHookRunContext, +): MessagingHookRunResult { + const handler = registry.require(hook.handler); + const result = handler(buildHandlerContext(hook, context)); + if (isPromiseLike(result)) { + throw new Error(`Messaging hook '${hook.id}' returned a Promise in a synchronous phase.`); + } const outputs = result.outputs ?? EMPTY_OUTPUTS; assertHookOutputsMatchDeclaration(hook, outputs); @@ -42,6 +57,26 @@ export async function runMessagingHook( }; } +function buildHandlerContext(hook: ChannelHookSpec, context: MessagingHookRunContext) { + return { + channelId: context.channelId, + hookId: hook.id, + phase: hook.phase, + ...(typeof context.isInteractive === "boolean" ? { isInteractive: context.isInteractive } : {}), + inputs: context.inputs, + outputDeclarations: hook.outputs, + }; +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + typeof value === "object" && + value !== null && + "then" in value && + typeof (value as { then?: unknown }).then === "function" + ); +} + function assertHookOutputsMatchDeclaration( hook: ChannelHookSpec, outputs: MessagingHookOutputMap, diff --git a/src/lib/messaging/index.ts b/src/lib/messaging/index.ts index 84b5ed45d5..282283256d 100644 --- a/src/lib/messaging/index.ts +++ b/src/lib/messaging/index.ts @@ -7,5 +7,4 @@ export * from "./compiler"; export * from "./diagnostics"; export * from "./hooks"; export * from "./manifest"; -export * from "./status-outputs"; export * from "./utils"; diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 744c00f072..780b536056 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -42,6 +42,8 @@ export interface ChannelManifest { /** Policy presets needed when this channel is active. */ readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; + readonly runtime?: Partial>; + readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly state: ChannelStateSpec; readonly hooks: readonly ChannelHookSpec[]; } @@ -141,6 +143,57 @@ export interface ChannelRenderFragmentSpec { readonly value: MessagingSerializableValue; } +/** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ +export interface ChannelRuntimeSpec { + readonly visibility?: ChannelRuntimeVisibilitySpec; + readonly nodePreloads?: readonly ChannelRuntimeNodePreloadSpec[]; + readonly envAliases?: readonly ChannelRuntimeEnvAliasSpec[]; + readonly secretScans?: readonly ChannelRuntimeSecretScanSpec[]; +} + +/** Agent-runtime metadata for common channel visibility diagnostics. */ +export interface ChannelRuntimeVisibilitySpec { + readonly configKeys: readonly string[]; + readonly logPatterns: readonly string[]; +} + +export type ChannelRuntimeNodePreloadScope = "boot" | "connect"; + +/** Node preload module to inject inside the OpenClaw runtime process. */ +export interface ChannelRuntimeNodePreloadSpec { + readonly module: string; + readonly injectInto?: readonly ChannelRuntimeNodePreloadScope[]; + readonly optional?: boolean; + readonly installMessage?: string; + readonly installedMessage?: string; +} + +export interface ChannelRuntimeEnvAliasSpec { + readonly envKey: string; + readonly match: string; + readonly value: string; + readonly message?: string; +} + +export interface ChannelRuntimeSecretScanSpec { + readonly path: string; + readonly pattern: string; + readonly message?: string; + readonly exitCode?: number; +} + +export type ChannelAgentPackageManager = "openclaw-plugin"; + +/** Agent package/plugin install the sandbox image build should apply. */ +export interface ChannelAgentPackageSpec { + readonly id: string; + readonly agent: MessagingAgentId; + readonly manager: ChannelAgentPackageManager; + readonly spec: MessagingTemplateString; + readonly pin?: boolean; + readonly required?: boolean; +} + /** State persistence and rebuild-hydration rules owned by the channel. */ export interface ChannelStateSpec { readonly persist?: Readonly>; @@ -162,7 +215,6 @@ export type ChannelHookPhase = | "render" | "apply" | "post-agent-install" - | "runtime-preload" | "health-check" | "diagnostic" | "status"; @@ -191,7 +243,6 @@ export interface ChannelHookOutputSpec { | "build-file" | "package-install" | "agent-render" - | "runtime-preload" | "health-check" | "status"; readonly required?: boolean; @@ -210,6 +261,8 @@ export interface SandboxMessagingPlan { readonly networkPolicy: SandboxMessagingNetworkPolicyPlan; readonly agentRender: readonly SandboxMessagingAgentRenderPlan[]; readonly buildSteps: readonly SandboxMessagingBuildStepPlan[]; + /** New plans include runtime setup; optional keeps older serialized plans readable. */ + readonly runtimeSetup?: SandboxMessagingRuntimeSetupPlan; readonly stateUpdates: readonly SandboxMessagingStateUpdatePlan[]; readonly healthChecks: readonly SandboxMessagingHealthCheckPlan[]; } @@ -349,6 +402,26 @@ export interface SandboxMessagingPackageInstallStepPlan { readonly value?: MessagingSerializableValue; } +export interface SandboxMessagingRuntimeSetupPlan { + readonly nodePreloads: readonly SandboxMessagingRuntimeNodePreloadPlan[]; + readonly envAliases: readonly SandboxMessagingRuntimeEnvAliasPlan[]; + readonly secretScans: readonly SandboxMessagingRuntimeSecretScanPlan[]; +} + +export interface SandboxMessagingRuntimeNodePreloadPlan extends ChannelRuntimeNodePreloadSpec { + readonly channelId: MessagingChannelId; + readonly source: string; + readonly target: string; +} + +export interface SandboxMessagingRuntimeEnvAliasPlan extends ChannelRuntimeEnvAliasSpec { + readonly channelId: MessagingChannelId; +} + +export interface SandboxMessagingRuntimeSecretScanPlan extends ChannelRuntimeSecretScanSpec { + readonly channelId: MessagingChannelId; +} + /** Hook reference carried into a compiled plan. */ export interface SandboxMessagingHookReferencePlan extends ChannelHookSpec { readonly channelId: MessagingChannelId; diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index db3d2a1604..7a21ffc859 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -31,6 +31,7 @@ export function parseSandboxMessagingPlan( !isObject(value.networkPolicy) || !Array.isArray(value.agentRender) || !Array.isArray(value.buildSteps) || + !isRuntimeSetup(value.runtimeSetup) || !Array.isArray(value.stateUpdates) || !Array.isArray(value.healthChecks) ) { @@ -145,3 +146,13 @@ function stringifyPlanStateValue(value: MessagingSerializableValue | undefined): function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } + +function isRuntimeSetup(value: unknown): boolean { + if (value === undefined) return true; + return ( + isObject(value) && + Array.isArray(value.nodePreloads) && + Array.isArray(value.envAliases) && + Array.isArray(value.secretScans) + ); +} diff --git a/src/lib/messaging/status-outputs.test.ts b/src/lib/messaging/status-outputs.test.ts deleted file mode 100644 index 040d403e6a..0000000000 --- a/src/lib/messaging/status-outputs.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { describe, expect, it } from "vitest"; - -import type { ChannelManifest } from "./manifest"; -import { collectMessagingStatusOutputs } from "./status-outputs"; - -describe("messaging status outputs", () => { - it("does not collect status hooks from manifests outside the requested agent", () => { - const manifests: ChannelManifest[] = [ - { - schemaVersion: 1, - id: "hermes-only", - displayName: "Hermes Only", - supportedAgents: ["hermes"], - auth: { mode: "none" }, - inputs: [], - credentials: [], - render: [], - state: {}, - hooks: [ - { - id: "hermes-status", - phase: "status", - handler: "common.staticOutputs", - outputs: [ - { - id: "gatewayOverlap", - kind: "status", - value: { - type: "single-gateway-channel-overlap", - reason: "hermes-only", - message: "Hermes-only overlap", - }, - }, - ], - }, - ], - }, - ]; - - expect(collectMessagingStatusOutputs(manifests, { agent: "openclaw" })).toEqual([]); - expect(collectMessagingStatusOutputs(manifests, { agent: "hermes" })).toHaveLength(1); - }); -}); diff --git a/src/lib/messaging/status-outputs.ts b/src/lib/messaging/status-outputs.ts deleted file mode 100644 index ecd3dd593e..0000000000 --- a/src/lib/messaging/status-outputs.ts +++ /dev/null @@ -1,134 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { createBuiltInChannelManifestRegistry } from "./channels"; -import type { ChannelManifest, MessagingAgentId, MessagingSerializableValue } from "./manifest"; - -export type MessagingStatusOutput = - | OpenClawRuntimeChannelStatusOutput - | GatewayLogConflictCounterStatusOutput - | SingleGatewayChannelOverlapStatusOutput; - -export interface MessagingStatusOutputBase { - readonly channelId: string; - readonly hookId: string; - readonly outputId: string; -} - -export interface OpenClawRuntimeChannelStatusOutput extends MessagingStatusOutputBase { - readonly type: "openclaw-runtime-channel"; - readonly configKeys: readonly string[]; - readonly logPatterns: readonly string[]; -} - -export interface GatewayLogConflictCounterStatusOutput extends MessagingStatusOutputBase { - readonly type: "gateway-log-conflict-counter"; - readonly logFile: string; - readonly maxLogLines: number; - readonly pattern: string; - readonly flags: string; -} - -export interface SingleGatewayChannelOverlapStatusOutput extends MessagingStatusOutputBase { - readonly type: "single-gateway-channel-overlap"; - readonly reason: string; - readonly message: string; -} - -export function collectBuiltInMessagingStatusOutputs( - options: { readonly agent?: MessagingAgentId } = {}, -): MessagingStatusOutput[] { - const registry = createBuiltInChannelManifestRegistry(); - return collectMessagingStatusOutputs( - options.agent ? registry.listAvailable({ agent: options.agent }) : registry.list(), - options, - ); -} - -export function collectMessagingStatusOutputs( - manifests: readonly ChannelManifest[], - options: { - readonly agent?: MessagingAgentId; - } = {}, -): MessagingStatusOutput[] { - const outputs: MessagingStatusOutput[] = []; - for (const manifest of manifests) { - if (options.agent && !manifest.supportedAgents.includes(options.agent)) continue; - for (const hook of manifest.hooks) { - if (hook.phase !== "status") continue; - if (options.agent && hook.agents && !hook.agents.includes(options.agent)) continue; - for (const output of hook.outputs ?? []) { - if (output.kind !== "status" || output.value === undefined) continue; - const parsed = parseMessagingStatusOutput(manifest.id, hook.id, output.id, output.value); - if (parsed) outputs.push(parsed); - } - } - } - return outputs; -} - -function parseMessagingStatusOutput( - channelId: string, - hookId: string, - outputId: string, - value: MessagingSerializableValue, -): MessagingStatusOutput | null { - if (!isObjectRecord(value) || typeof value.type !== "string") return null; - const base = { channelId, hookId, outputId }; - if (value.type === "openclaw-runtime-channel") { - const configKeys = stringArray(value.configKeys); - const logPatterns = stringArray(value.logPatterns); - if (configKeys.length === 0 || logPatterns.length === 0) return null; - return { - ...base, - type: "openclaw-runtime-channel", - configKeys, - logPatterns, - }; - } - if (value.type === "gateway-log-conflict-counter") { - const logFile = stringField(value, "logFile"); - const pattern = stringField(value, "pattern"); - if (!logFile || !pattern) return null; - return { - ...base, - type: "gateway-log-conflict-counter", - logFile, - maxLogLines: maxLogLines(value.maxLogLines), - pattern, - flags: stringField(value, "flags") ?? "i", - }; - } - if (value.type === "single-gateway-channel-overlap") { - const reason = stringField(value, "reason"); - const message = stringField(value, "message"); - if (!reason || !message) return null; - return { - ...base, - type: "single-gateway-channel-overlap", - reason, - message, - }; - } - return null; -} - -function stringArray(value: unknown): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string" && entry.length > 0) - : []; -} - -function stringField(value: Record, key: string): string | undefined { - const field = value[key]; - return typeof field === "string" && field.length > 0 ? field : undefined; -} - -function maxLogLines(value: unknown): number { - if (typeof value !== "number" || !Number.isFinite(value)) return 200; - return Math.min(Math.max(Math.trunc(value), 1), 2000); -} - -function isObjectRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/src/lib/status-command-deps.test.ts b/src/lib/status-command-deps.test.ts index 3c31575df1..8ce4f9be33 100644 --- a/src/lib/status-command-deps.test.ts +++ b/src/lib/status-command-deps.test.ts @@ -57,7 +57,7 @@ exit 0 { channel: "telegram", conflicts: 3 }, ]); expect(fs.readFileSync(callsFile, "utf-8")).toContain( - "sandbox exec -n alpha -- sh -c tail -n 200 '/tmp/gateway.log'", + "sandbox exec -n alpha -- sh -c tail -n 200 /tmp/gateway.log", ); expect(fs.readFileSync(callsFile, "utf-8")).not.toContain("grep -cE"); }); diff --git a/src/lib/status-command-deps.ts b/src/lib/status-command-deps.ts index 2ff261f1bf..050f5eb221 100644 --- a/src/lib/status-command-deps.ts +++ b/src/lib/status-command-deps.ts @@ -15,15 +15,13 @@ import type { ShowStatusCommandDeps, } from "./inventory"; import { findAllOverlaps } from "./messaging/applier"; -import type { MessagingAgentId } from "./messaging/manifest"; -import { getActiveChannelIdsFromPlan } from "./messaging/plan-validation"; -import { - collectBuiltInMessagingStatusOutputs, - type MessagingStatusOutput, - type GatewayLogConflictCounterStatusOutput, - type SingleGatewayChannelOverlapStatusOutput, -} from "./messaging/status-outputs"; -import { BASE_GATEWAY_NAME } from "./onboard/gateway-binding"; +import { createBuiltInChannelManifestRegistry } from "./messaging/channels"; +import { createBuiltInMessagingHookRegistry, runMessagingHookSync } from "./messaging/hooks"; +import type { + ChannelHookSpec, + MessagingAgentId, + MessagingSerializableValue, +} from "./messaging/manifest"; import * as registry from "./state/registry"; import { createSystemDeps, parseSshProcesses } from "./state/sandbox-session"; import { getServiceStatuses, showStatus as showServiceStatus } from "./tunnel/services"; @@ -51,29 +49,23 @@ function checkMessagingBridgeHealth( agent: string | null | undefined = "openclaw", ): MessagingBridgeHealth[] { const channelSet = new Set(Array.isArray(channels) ? channels : []); - const specs = getStatusOutputsForAgent(agent) - .filter(isGatewayLogConflictCounterStatusOutput) - .filter((spec) => channelSet.has(spec.channelId)); - if (specs.length === 0) return []; const openshell = resolveOpenshell(); if (!openshell) return []; - const results: MessagingBridgeHealth[] = []; - for (const spec of specs) { - const logTail = readSandboxFileTail( - rootDir, - openshell, - sandboxName, - spec.logFile, - spec.maxLogLines, - ); - if (logTail === null) continue; - const conflicts = countRegexMatchesByLine(logTail, spec.pattern, spec.flags); - if (conflicts > 0) { - results.push({ channel: spec.channelId, conflicts }); - } - } - return results; + return runMessagingStatusHooks({ + agent: normalizeMessagingAgentId(agent), + channels: channelSet, + currentSandbox: sandboxName, + registryEntries: safeListRegistryEntries(), + hookRegistry: createBuiltInMessagingHookRegistry({ + telegram: { + gatewayConflictStatus: { + executeSandboxCommand: (name, command, timeoutMs) => + executeSandboxCommand(rootDir, openshell, name, command, timeoutMs), + }, + }, + }), + }).flatMap(readBridgeHealthOutputs); } function findMessagingOverlaps() { @@ -82,136 +74,202 @@ function findMessagingOverlaps() { try { // Report both conflict axes independently and without deduping. They are // distinct, both-true facts: a shared messaging credential conflicts on any - // gateway, while manifest-declared gateway exclusivity can conflict even - // with distinct credentials. A pair that hits both genuinely has two - // problems, so surfacing both avoids masking the credential warning behind - // the gateway one. + // gateway, while channel-owned status hooks can report non-credential + // runtime exclusivity such as Slack Socket Mode on one gateway. const { sandboxes } = registry.listSandboxes(); const credentialOverlaps = findAllOverlaps({ listSandboxes: () => ({ sandboxes }), }); - const singleGatewayOverlaps = listSingleGatewayOverlapSpecsForEntries(sandboxes).flatMap( - ({ spec, agents }) => detectSingleGatewayChannelOverlaps(sandboxes, spec, agents), - ); - return [...credentialOverlaps, ...singleGatewayOverlaps]; + const statusOverlaps = runMessagingStatusHooks({ + agents: uniqueAgentsForEntries(sandboxes), + registryEntries: sandboxes, + }).flatMap(readOverlapOutputs); + return [...credentialOverlaps, ...statusOverlaps]; } catch { return []; } } -function isGatewayLogConflictCounterStatusOutput( - output: MessagingStatusOutput, -): output is GatewayLogConflictCounterStatusOutput { - return output.type === "gateway-log-conflict-counter"; +function normalizeMessagingAgentId(agent: string | null | undefined): MessagingAgentId { + return agent === "hermes" ? "hermes" : "openclaw"; } -function isSingleGatewayChannelOverlapStatusOutput( - output: MessagingStatusOutput, -): output is SingleGatewayChannelOverlapStatusOutput { - return output.type === "single-gateway-channel-overlap"; +interface MessagingStatusHookRunOptions { + readonly agent?: MessagingAgentId; + readonly agents?: ReadonlySet; + readonly channels?: ReadonlySet; + readonly currentSandbox?: string; + readonly registryEntries?: readonly registry.SandboxEntry[]; + readonly hookRegistry?: ReturnType; } -function getStatusOutputsForAgent(agent: string | null | undefined): MessagingStatusOutput[] { - return collectBuiltInMessagingStatusOutputs({ agent: normalizeMessagingAgentId(agent) }); +type MessagingStatusHookRunResult = { + readonly channelId: string; + readonly hookId: string; + readonly outputs: ReturnType["outputs"]; +}; + +function runMessagingStatusHooks( + options: MessagingStatusHookRunOptions, +): MessagingStatusHookRunResult[] { + const hookRegistry = options.hookRegistry ?? createBuiltInMessagingHookRegistry(); + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const agents: ReadonlySet = options.agent + ? new Set([options.agent]) + : (options.agents ?? new Set(["openclaw"])); + const hookResults: MessagingStatusHookRunResult[] = []; + const seen = new Set(); + + for (const agent of agents) { + for (const manifest of manifestRegistry.listAvailable({ agent })) { + if (options.channels && !options.channels.has(manifest.id)) continue; + for (const hook of manifest.hooks) { + if (!shouldRunStatusHook(hook, agent)) continue; + const key = `${manifest.id}\0${hook.id}\0${hook.handler}`; + if (seen.has(key)) continue; + seen.add(key); + try { + const result = runMessagingHookSync(hook, hookRegistry, { + channelId: manifest.id, + inputs: createMessagingStatusHookInputs(options), + }); + hookResults.push({ + channelId: manifest.id, + hookId: hook.id, + outputs: result.outputs, + }); + } catch { + // Status hooks are advisory; a broken hook must not hide the rest of + // `nemoclaw status`. + } + } + } + } + return hookResults; } -function normalizeMessagingAgentId(agent: string | null | undefined): MessagingAgentId { - return agent === "hermes" ? "hermes" : "openclaw"; +function shouldRunStatusHook(hook: ChannelHookSpec, agent: MessagingAgentId): boolean { + return hook.phase === "status" && (!hook.agents || hook.agents.includes(agent)); } -function readSandboxFileTail( +function executeSandboxCommand( rootDir: string, openshell: string, sandboxName: string, - path: string, - maxLines: number, -): string | null { - const script = `tail -n ${maxLines} ${shellQuote(path)} 2>/dev/null || true`; + command: string, + timeoutMs: number, +): { + readonly status?: number | null; + readonly stdout?: unknown; + readonly stderr?: unknown; +} | null { try { const result = spawnSync( openshell, - ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", script], - { cwd: rootDir, encoding: "utf-8", timeout: 3000, stdio: ["ignore", "pipe", "pipe"] }, + ["sandbox", "exec", "-n", sandboxName, "--", "sh", "-c", command], + { cwd: rootDir, encoding: "utf-8", timeout: timeoutMs, stdio: ["ignore", "pipe", "pipe"] }, ); - return typeof result.stdout === "string" ? result.stdout : ""; + return { + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + }; } catch { return null; } } -function countRegexMatchesByLine(logTail: string, pattern: string, flags: string): number { - let regex: RegExp; +function createMessagingStatusHookInputs( + options: MessagingStatusHookRunOptions, +): Record { + const inputs: Record = {}; + if (options.currentSandbox) inputs.currentSandbox = options.currentSandbox; + if (options.registryEntries) { + inputs.registryEntries = options.registryEntries.map(serializeRegistryEntry); + } + return inputs; +} + +function serializeRegistryEntry(entry: registry.SandboxEntry): MessagingSerializableValue { + return { + name: entry.name, + gatewayName: entry.gatewayName ?? null, + messaging: entry.messaging?.plan + ? { + plan: entry.messaging.plan as unknown as MessagingSerializableValue, + } + : null, + }; +} + +function safeListRegistryEntries(): readonly registry.SandboxEntry[] { try { - regex = new RegExp(pattern, flags.replaceAll("g", "")); + return registry.listSandboxes().sandboxes; } catch { - return 0; + return []; } - return logTail - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0 && regex.test(line)).length; } -function detectSingleGatewayChannelOverlaps( +function uniqueAgentsForEntries( entries: readonly registry.SandboxEntry[], - spec: SingleGatewayChannelOverlapStatusOutput, - agents: ReadonlySet, -): MessagingOverlap[] { - const byGateway = new Map(); +): ReadonlySet { + const agents = new Set(); for (const entry of entries) { - if (!agents.has(normalizeMessagingAgentId(entry.agent))) continue; - if (!entry.messaging?.plan) continue; - if (!getActiveChannelIdsFromPlan(entry.messaging.plan).includes(spec.channelId)) continue; - const gatewayName = entry.gatewayName ?? BASE_GATEWAY_NAME; - const names = byGateway.get(gatewayName) ?? []; - names.push(entry.name); - byGateway.set(gatewayName, names); + agents.add(normalizeMessagingAgentId(entry.agent)); } + if (agents.size === 0) agents.add("openclaw"); + return agents; +} - const overlaps: MessagingOverlap[] = []; - for (const names of byGateway.values()) { - if (names.length < 2) continue; - for (let i = 0; i < names.length; i += 1) { - for (let j = i + 1; j < names.length; j += 1) { - overlaps.push({ - channel: spec.channelId, - sandboxes: [names[i], names[j]], - reason: spec.reason, - message: spec.message, - }); - } - } - } - return overlaps; +function readBridgeHealthOutputs(result: MessagingStatusHookRunResult): MessagingBridgeHealth[] { + return Object.values(result.outputs).flatMap((output) => { + if (output.kind !== "status" || !isObjectRecord(output.value)) return []; + if (output.value.type !== "messaging-bridge-health") return []; + const channel = stringField(output.value.channel) ?? result.channelId; + const conflicts = numberField(output.value.conflicts); + return conflicts > 0 ? [{ channel, conflicts }] : []; + }); } -function listSingleGatewayOverlapSpecsForEntries(entries: readonly registry.SandboxEntry[]): Array<{ - readonly spec: SingleGatewayChannelOverlapStatusOutput; - readonly agents: ReadonlySet; -}> { - const byKey = new Map< - string, - { spec: SingleGatewayChannelOverlapStatusOutput; agents: Set } - >(); - for (const entry of entries) { - const agent = normalizeMessagingAgentId(entry.agent); - for (const spec of getStatusOutputsForAgent(agent).filter( - isSingleGatewayChannelOverlapStatusOutput, - )) { - const key = `${spec.channelId}\0${spec.reason}\0${spec.message}`; - const existing = byKey.get(key); - if (existing) { - existing.agents.add(agent); - } else { - byKey.set(key, { spec, agents: new Set([agent]) }); - } +function readOverlapOutputs(result: MessagingStatusHookRunResult): MessagingOverlap[] { + return Object.values(result.outputs).flatMap((output) => { + if (output.kind !== "status" || !isObjectRecord(output.value)) return []; + if (output.value.type !== "messaging-overlaps" || !Array.isArray(output.value.overlaps)) { + return []; } - } - return [...byKey.values()]; + return output.value.overlaps.flatMap((entry) => { + if (!isObjectRecord(entry) || !isStringPair(entry.sandboxes)) return []; + return [ + { + channel: stringField(entry.channel) ?? result.channelId, + sandboxes: entry.sandboxes, + ...(typeof entry.reason === "string" ? { reason: entry.reason } : {}), + ...(typeof entry.message === "string" ? { message: entry.message } : {}), + }, + ]; + }); + }); +} + +function stringField(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function numberField(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +function isStringPair(value: unknown): value is [string, string] { + return ( + Array.isArray(value) && + value.length === 2 && + typeof value[0] === "string" && + typeof value[1] === "string" + ); } -function shellQuote(value: string): string { - return `'${value.replace(/'/g, "'\\''")}'`; +function isObjectRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); } function readGatewayLog(rootDir: string, sandboxName: string): string | null { diff --git a/test/cli/list-share-live-inference.test.ts b/test/cli/list-share-live-inference.test.ts index db40e4251c..f7d5ec361d 100644 --- a/test/cli/list-share-live-inference.test.ts +++ b/test/cli/list-share-live-inference.test.ts @@ -525,15 +525,19 @@ describe("list shows live gateway inference", () => { } }); - it("share is recognized as a valid sandbox action (not 'Unknown action')", () => { - const env = createShareTestEnv("nemoclaw-cli-share-action-"); + it( + "share is recognized as a valid sandbox action (not 'Unknown action')", + testTimeoutOptions(15_000), + () => { + const env = createShareTestEnv("nemoclaw-cli-share-action-"); - const r = runWithEnv("alpha share mount", env); + const r = runWithEnv("alpha share mount", env); - // Will fail because sshfs/sandbox isn't running, but should NOT say "Unknown action" - expect(r.code).not.toBe(0); - expect(r.out).not.toContain("Unknown action"); - }); + // Will fail because sshfs/sandbox isn't running, but should NOT say "Unknown action" + expect(r.code).not.toBe(0); + expect(r.out).not.toContain("Unknown action"); + }, + ); it("unknown share subcommands fail before action dispatch", () => { const env = createShareTestEnv("nemoclaw-cli-share-unknown-"); diff --git a/test/cli/logs.test.ts b/test/cli/logs.test.ts index 0d25029319..29c15d3bca 100644 --- a/test/cli/logs.test.ts +++ b/test/cli/logs.test.ts @@ -384,7 +384,7 @@ describe("CLI dispatch", () => { const log = fs.readFileSync(markerFile, "utf8"); expect(r.code).toBe(0); expect(log).toContain( - "sandbox exec -n alpha -- sh -c tail -n 200 '/tmp/gateway.log' 2>/dev/null || true", + "sandbox exec -n alpha -- sh -c tail -n 200 /tmp/gateway.log 2>/dev/null || true", ); expect(log).not.toContain("grep -cE"); expect(log).not.toContain("sandbox exec alpha sh -c"); diff --git a/test/e2e-scenario/live/whatsapp-qr-compact.test.ts b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts index 0944560237..ad22eb0c4f 100644 --- a/test/e2e-scenario/live/whatsapp-qr-compact.test.ts +++ b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts @@ -19,7 +19,16 @@ import { testTimeoutOptions } from "../../helpers/timeouts"; const REPO_ROOT = path.resolve(import.meta.dirname, "../../.."); const DOCKERFILE_BASE = path.join(REPO_ROOT, "Dockerfile.base"); -const PRELOAD = path.join(REPO_ROOT, "nemoclaw-blueprint", "scripts", "whatsapp-qr-compact.js"); +const PRELOAD = path.join( + REPO_ROOT, + "src", + "lib", + "messaging", + "channels", + "whatsapp", + "runtime", + "whatsapp-qr-compact.js", +); const INSTALL_TIMEOUT_MS = 180_000; const PROBE_TIMEOUT_MS = 30_000; const COMPACT_MAX_ROWS = Number.parseInt(process.env.WHATSAPP_QR_COMPACT_MAX_ROWS ?? "40", 10); diff --git a/test/local-slack-auth-test.sh b/test/local-slack-auth-test.sh index 628f4923c9..b76195578b 100755 --- a/test/local-slack-auth-test.sh +++ b/test/local-slack-auth-test.sh @@ -2,8 +2,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Local end-to-end test for the Slack channel guard (install_slack_channel_guard) -# from nemoclaw-start.sh. +# Local end-to-end test for the Slack channel guard runtime preload. # # Extracts the guard's JS preload from the shell script, then runs Node.js # scenarios that simulate Slack-style unhandled rejections and uncaught @@ -17,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GUARD_SOURCE="$SCRIPT_DIR/../nemoclaw-blueprint/scripts/slack-channel-guard.js" +GUARD_SOURCE="$SCRIPT_DIR/../src/lib/messaging/channels/slack/runtime/slack-channel-guard.js" PASS=0 FAIL=0 diff --git a/test/nemoclaw-start-runtime-env-alias.test.ts b/test/nemoclaw-start-runtime-env-alias.test.ts index 0729b384ba..eb960438f8 100644 --- a/test/nemoclaw-start-runtime-env-alias.test.ts +++ b/test/nemoclaw-start-runtime-env-alias.test.ts @@ -9,20 +9,24 @@ import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -function messagingRuntimePreloadSection(src: string, planPath: string): string { - const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); +function messagingRuntimeSetupSection(src: string, planPath: string): string { + const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src .slice(start, end) .replace( - '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', - `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(planPath)}`, + '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, ); } -function encodeRuntimePreloadPlan(channelId: string, value: Record): string { +function encodeRuntimeSetupPlan(channelId: string, value: Record): string { + const withChannelId = (entries: unknown) => + Array.isArray(entries) + ? entries.map((entry) => ({ channelId, ...(entry as Record) })) + : []; return Buffer.from( JSON.stringify({ schemaVersion: 1, @@ -39,24 +43,7 @@ function encodeRuntimePreloadPlan(channelId: string, value: Record { "set -euo pipefail", 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, - messagingRuntimePreloadSection(src, planPath), - "write_messaging_runtime_preload_plan", + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue))}`, + messagingRuntimeSetupSection(src, planPath), + "write_messaging_runtime_setup_plan", "apply_messaging_runtime_env_aliases", 'printf "SLACK_BOT_TOKEN=%s\\n" "$SLACK_BOT_TOKEN"', ].join("\n"), diff --git a/test/nemoclaw-start-slack-runtime.test.ts b/test/nemoclaw-start-slack-runtime.test.ts index ddd2617a4f..702f587014 100644 --- a/test/nemoclaw-start-slack-runtime.test.ts +++ b/test/nemoclaw-start-slack-runtime.test.ts @@ -10,20 +10,24 @@ import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); -function messagingRuntimePreloadSection(src: string, planPath: string): string { - const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); +function messagingRuntimeSetupSection(src: string, planPath: string): string { + const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); return src .slice(start, end) .replace( - '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', - `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(planPath)}`, + '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(planPath)}`, ); } -function encodeRuntimePreloadPlan(channelId: string, value: Record): string { +function encodeRuntimeSetupPlan(channelId: string, value: Record): string { + const withChannelId = (entries: unknown) => + Array.isArray(entries) + ? entries.map((entry) => ({ channelId, ...(entry as Record) })) + : []; return Buffer.from( JSON.stringify({ schemaVersion: 1, @@ -40,24 +44,7 @@ function encodeRuntimePreloadPlan(channelId: string, value: Record { "set -euo pipefail", 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, - messagingRuntimePreloadSection(src, planPath), - "write_messaging_runtime_preload_plan", + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue))}`, + messagingRuntimeSetupSection(src, planPath), + "write_messaging_runtime_setup_plan", "apply_messaging_runtime_env_aliases", 'printf "BOT=%s\\n" "${SLACK_BOT_TOKEN-__UNSET__}"', 'printf "APP=%s\\n" "${SLACK_APP_TOKEN-__UNSET__}"', diff --git a/test/nemoclaw-start-telegram-runtime.test.ts b/test/nemoclaw-start-telegram-runtime.test.ts new file mode 100644 index 0000000000..311b552e78 --- /dev/null +++ b/test/nemoclaw-start-telegram-runtime.test.ts @@ -0,0 +1,170 @@ +// @ts-nocheck +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); +const TELEGRAM_RUNTIME_PRELOAD = path.join( + import.meta.dirname, + "..", + "src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js", +); + +function messagingRuntimeSetupSection( + src: string, + options: { + planPath: string; + connectPreloadsPath: string; + sourcePrefix: string; + targetPrefix: string; + }, +): string { + const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); + const end = src.indexOf("_read_gateway_token()", start); + expect(start).toBeGreaterThan(-1); + expect(end).toBeGreaterThan(start); + return src + .slice(start, end) + .replace( + '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(options.planPath)}`, + ) + .replace( + '_MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list"', + `_MESSAGING_CONNECT_PRELOADS_FILE=${JSON.stringify(options.connectPreloadsPath)}`, + ) + .replaceAll("/tmp/nemoclaw-messaging-connect-preloads.list", options.connectPreloadsPath) + .replace( + 'PRELOAD_SOURCE_PREFIX = "/usr/local/lib/nemoclaw/preloads/"', + `PRELOAD_SOURCE_PREFIX = ${JSON.stringify(options.sourcePrefix)}`, + ) + .replace( + 'PRELOAD_TARGET_PREFIX = "/tmp/nemoclaw-"', + `PRELOAD_TARGET_PREFIX = ${JSON.stringify(options.targetPrefix)}`, + ); +} + +function encodeRuntimeSetupPlan( + channelId: string, + runtimeSetup: Record, + options: { active?: boolean } = {}, +): string { + const active = options.active ?? true; + const withChannelId = (entries: unknown) => + Array.isArray(entries) + ? entries.map((entry) => ({ channelId, ...(entry as Record) })) + : []; + return Buffer.from( + JSON.stringify({ + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId, + displayName: channelId, + authMode: "token-paste", + active, + selected: true, + configured: true, + disabled: !active, + inputs: [], + hooks: [], + }, + ], + disabledChannels: active ? [] : [channelId], + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { + nodePreloads: withChannelId(runtimeSetup.nodePreloads), + envAliases: withChannelId(runtimeSetup.envAliases), + secretScans: withChannelId(runtimeSetup.secretScans), + }, + stateUpdates: [], + healthChecks: [], + }), + ).toString("base64"); +} + +describe("Telegram runtime preload installation", () => { + const src = fs.readFileSync(START_SCRIPT, "utf-8"); + + it("installs Telegram diagnostics only when Telegram is configured", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); + const sourcePrefix = path.join(tmpDir, "preloads") + path.sep; + const sourcePath = path.join(sourcePrefix, "telegram-diagnostics.js"); + const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(sourcePrefix, { recursive: true }); + fs.copyFileSync(TELEGRAM_RUNTIME_PRELOAD, sourcePath); + const runtimeSetup = { + nodePreloads: [ + { + source: sourcePath, + target: preloadPath, + injectInto: ["boot", "connect"], + optional: false, + installMessage: + "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", + installedMessage: "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", + }, + ], + }; + const run = (active: boolean) => { + fs.rmSync(preloadPath, { force: true }); + fs.rmSync(planPath, { force: true }); + fs.rmSync(connectPreloadsPath, { force: true }); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + "NODE_OPTIONS='--require /already-loaded.js'", + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("telegram", runtimeSetup, { active }))}`, + messagingRuntimeSetupSection(src, { + planPath, + connectPreloadsPath, + sourcePrefix, + targetPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_setup_plan", + "install_messaging_runtime_preloads", + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + { mode: 0o700 }, + ); + return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + }; + + try { + const noTelegram = run(false); + expect(noTelegram.status).toBe(0); + expect(fs.existsSync(preloadPath)).toBe(false); + expect(noTelegram.stdout).toContain("NODE_OPTIONS=--require /already-loaded.js"); + expect(noTelegram.stdout).not.toContain(preloadPath); + + const withTelegram = run(true); + expect(withTelegram.status).toBe(0); + expect(fs.existsSync(preloadPath)).toBe(true); + expect((fs.statSync(preloadPath).mode & 0o777).toString(8)).toBe("444"); + expect(withTelegram.stdout).toContain("--require /already-loaded.js"); + expect(withTelegram.stdout).toContain(`--require ${preloadPath}`); + expect(withTelegram.stderr).toContain("Telegram diagnostics installed"); + expect(fs.readFileSync(connectPreloadsPath, "utf-8")).toContain(preloadPath); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index 0f42fdd2ff..4dab3c4797 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest"; const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-start.sh"); const APPROVAL_POLICY_DIR = path.join(import.meta.dirname, "..", "scripts", "lib"); const PRELOAD_SCRIPTS = path.join(import.meta.dirname, "..", "nemoclaw-blueprint", "scripts"); +const CHANNEL_RUNTIME_SCRIPTS = path.join(import.meta.dirname, "..", "src/lib/messaging/channels"); const JSON5_MODULE = path.join(import.meta.dirname, "..", "nemoclaw", "node_modules", "json5"); function runtimeShellEnvBlock(src: string): string { @@ -21,7 +22,7 @@ function runtimeShellEnvBlock(src: string): string { return src.slice(start, end); } -function messagingRuntimePreloadSection( +function messagingRuntimeSetupSection( src: string, options: { planPath?: string; @@ -31,15 +32,15 @@ function messagingRuntimePreloadSection( secretScanPrefix?: string; } = {}, ): string { - const start = src.indexOf("# ── Messaging runtime preloads from manifest hooks"); + const start = src.indexOf("# ── Messaging runtime setup from manifest metadata"); const end = src.indexOf("_read_gateway_token()", start); expect(start).toBeGreaterThan(-1); expect(end).toBeGreaterThan(start); let section = src.slice(start, end); if (options.planPath) { section = section.replace( - '_MESSAGING_RUNTIME_PRELOAD_PLAN="/tmp/nemoclaw-messaging-runtime-preloads.json"', - `_MESSAGING_RUNTIME_PRELOAD_PLAN=${JSON.stringify(options.planPath)}`, + '_MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json"', + `_MESSAGING_RUNTIME_SETUP_PLAN=${JSON.stringify(options.planPath)}`, ); } if (options.connectPreloadsPath) { @@ -71,12 +72,16 @@ function messagingRuntimePreloadSection( return section; } -function encodeRuntimePreloadPlan( +function encodeRuntimeSetupPlan( channelId: string, value: Record, options: { active?: boolean } = {}, ): string { const active = options.active ?? true; + const withChannelId = (entries: unknown) => + Array.isArray(entries) + ? entries.map((entry) => ({ channelId, ...(entry as Record) })) + : []; return Buffer.from( JSON.stringify({ schemaVersion: 1, @@ -93,24 +98,7 @@ function encodeRuntimePreloadPlan( configured: true, disabled: !active, inputs: [], - hooks: [ - { - channelId, - id: `${channelId}-runtime-preload`, - phase: "runtime-preload", - handler: "common.staticOutputs", - agents: ["openclaw"], - outputs: [ - { - id: "runtimePreload", - kind: "runtime-preload", - required: true, - value, - }, - ], - onFailure: "abort", - }, - ], + hooks: [], }, ], disabledChannels: active ? [] : [channelId], @@ -118,6 +106,11 @@ function encodeRuntimePreloadPlan( networkPolicy: { presets: [], entries: [] }, agentRender: [], buildSteps: [], + runtimeSetup: { + nodePreloads: withChannelId(value.nodePreloads), + envAliases: withChannelId(value.envAliases), + secretScans: withChannelId(value.secretScans), + }, stateUpdates: [], healthChecks: [], }), @@ -138,12 +131,20 @@ function startScriptHeredoc(src: string, marker: string): string { const preloadByMarker: Record = { CIAO_GUARD_EOF: "ciao-network-guard.js", SAFETY_NET_EOF: "sandbox-safety-net.js", - SLACK_GUARD_EOF: "slack-channel-guard.js", - TELEGRAM_DIAGNOSTICS_EOF: "telegram-diagnostics.js", }; const preload = preloadByMarker[marker]; - expect(preload).toBeTruthy(); - return fs.readFileSync(path.join(PRELOAD_SCRIPTS, preload), "utf-8"); + if (preload) return fs.readFileSync(path.join(PRELOAD_SCRIPTS, preload), "utf-8"); + const channelPreload = + marker === "SLACK_GUARD_EOF" + ? ["slack", "slack-channel-guard.js"] + : marker === "TELEGRAM_DIAGNOSTICS_EOF" + ? ["telegram", "telegram-diagnostics.js"] + : undefined; + expect(channelPreload).toBeTruthy(); + return fs.readFileSync( + path.join(CHANNEL_RUNTIME_SCRIPTS, channelPreload[0], "runtime", channelPreload[1]), + "utf-8", + ); } function trustedApprovalPolicyFile(): string { @@ -450,7 +451,7 @@ describe("nemoclaw-start non-root fallback", () => { 'ensure_gateway_token_if_missing() { echo "SHOULD_NOT_ENSURE"; exit 76; }', "write_openclaw_config_baseline() { :; }", "export_gateway_token() { :; }", - "write_messaging_runtime_preload_plan() { :; }", + "write_messaging_runtime_setup_plan() { :; }", "write_runtime_shell_env() { :; }", "ensure_runtime_shell_env_shim() { :; }", "lock_rc_files() { :; }", @@ -1472,13 +1473,16 @@ ${body}`, const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); const scriptPath = path.join(tmpDir, "run.sh"); fs.mkdirSync(sourcePrefix, { recursive: true }); - fs.copyFileSync(path.join(PRELOAD_SCRIPTS, "slack-channel-guard.js"), guardSource); + fs.copyFileSync( + path.join(CHANNEL_RUNTIME_SCRIPTS, "slack", "runtime", "slack-channel-guard.js"), + guardSource, + ); const runtimeValue = { - preloads: [ + nodePreloads: [ { source: guardSource, target: guardPath, - nodeOptions: ["boot", "connect"], + injectInto: ["boot", "connect"], optional: false, installMessage: "[channels] Installing Slack channel guard (unhandled-rejection safety net)", @@ -1498,14 +1502,14 @@ ${body}`, 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', "NODE_OPTIONS='--require /already-loaded.js'", - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue, { active }))}`, - messagingRuntimePreloadSection(src, { + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue, { active }))}`, + messagingRuntimeSetupSection(src, { planPath, connectPreloadsPath, sourcePrefix, targetPrefix: tmpDir + path.sep, }), - "write_messaging_runtime_preload_plan", + "write_messaging_runtime_setup_plan", "install_messaging_runtime_preloads", 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', ].join("\n"), @@ -3061,12 +3065,12 @@ describe("Slack secrets-on-disk tripwire (#2085)", () => { "set -euo pipefail", 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("slack", runtimeValue))}`, - messagingRuntimePreloadSection(src, { + `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimeSetupPlan("slack", runtimeValue))}`, + messagingRuntimeSetupSection(src, { planPath, secretScanPrefix: tmpDir + path.sep, }), - "write_messaging_runtime_preload_plan", + "write_messaging_runtime_setup_plan", "verify_messaging_runtime_secret_scans", ].join("\n"), { mode: 0o700 }, @@ -3758,7 +3762,7 @@ describe("Telegram diagnostics (#2766)", () => { "ensure_gateway_token_if_missing() { :; }", "write_openclaw_config_baseline() { :; }", "export_gateway_token() { :; }", - "write_messaging_runtime_preload_plan() { :; }", + "write_messaging_runtime_setup_plan() { :; }", "write_runtime_shell_env() { :; }", "ensure_runtime_shell_env_shim() { :; }", "lock_rc_files() { :; }", @@ -3817,77 +3821,6 @@ describe("Telegram diagnostics (#2766)", () => { }; } - it("installs a Telegram diagnostics preload only when Telegram is configured", () => { - const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-install-")); - const sourcePrefix = path.join(tmpDir, "preloads") + path.sep; - const sourcePath = path.join(sourcePrefix, "telegram-diagnostics.js"); - const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); - const planPath = path.join(tmpDir, "runtime-plan.json"); - const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); - const scriptPath = path.join(tmpDir, "run.sh"); - fs.mkdirSync(sourcePrefix, { recursive: true }); - fs.copyFileSync(path.join(PRELOAD_SCRIPTS, "telegram-diagnostics.js"), sourcePath); - const runtimeValue = { - preloads: [ - { - source: sourcePath, - target: preloadPath, - nodeOptions: ["boot", "connect"], - optional: false, - installMessage: - "[channels] Installing Telegram diagnostics (provider readiness + inference errors)", - installedMessage: "[channels] Telegram diagnostics installed (NODE_OPTIONS updated)", - }, - ], - }; - const run = (active: boolean) => { - fs.rmSync(preloadPath, { force: true }); - fs.rmSync(planPath, { force: true }); - fs.rmSync(connectPreloadsPath, { force: true }); - fs.writeFileSync( - scriptPath, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', - 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', - "NODE_OPTIONS='--require /already-loaded.js'", - `export NEMOCLAW_MESSAGING_PLAN_B64=${JSON.stringify(encodeRuntimePreloadPlan("telegram", runtimeValue, { active }))}`, - messagingRuntimePreloadSection(src, { - planPath, - connectPreloadsPath, - sourcePrefix, - targetPrefix: tmpDir + path.sep, - }), - "write_messaging_runtime_preload_plan", - "install_messaging_runtime_preloads", - 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', - ].join("\n"), - { mode: 0o700 }, - ); - return spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); - }; - - try { - const noTelegram = run(false); - expect(noTelegram.status).toBe(0); - expect(fs.existsSync(preloadPath)).toBe(false); - expect(noTelegram.stdout).toContain("NODE_OPTIONS=--require /already-loaded.js"); - expect(noTelegram.stdout).not.toContain(preloadPath); - - const withTelegram = run(true); - expect(withTelegram.status).toBe(0); - expect(fs.existsSync(preloadPath)).toBe(true); - expect((fs.statSync(preloadPath).mode & 0o777).toString(8)).toBe("444"); - expect(withTelegram.stdout).toContain("--require /already-loaded.js"); - expect(withTelegram.stdout).toContain(`--require ${preloadPath}`); - expect(withTelegram.stderr).toContain("Telegram diagnostics installed"); - expect(fs.readFileSync(connectPreloadsPath, "utf-8")).toContain(preloadPath); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - it("emits provider readiness for successful Telegram Bot API startup probes", () => { const run = spawnSync( process.execPath, diff --git a/test/telegram-diagnostics.test.ts b/test/telegram-diagnostics.test.ts index 7090a1e85b..0457a248c6 100644 --- a/test/telegram-diagnostics.test.ts +++ b/test/telegram-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Unit tests for nemoclaw-blueprint/scripts/telegram-diagnostics.js. +// Unit tests for src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js. // // The diagnostics preload mutates global state on require (process.stderr, // http.request / https.request); each scenario runs in its own child Node @@ -12,17 +12,21 @@ // touches the Bot API, the preload must surface a single actionable line // instead of leaving the channel observably silent. -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; const DIAGNOSTICS_PATH = path.join( import.meta.dirname, "..", - "nemoclaw-blueprint", - "scripts", + "src", + "lib", + "messaging", + "channels", + "telegram", + "runtime", "telegram-diagnostics.js", ); diff --git a/test/wechat-diagnostics.test.ts b/test/wechat-diagnostics.test.ts index 630d454196..eafe9b2246 100644 --- a/test/wechat-diagnostics.test.ts +++ b/test/wechat-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Unit tests for nemoclaw-blueprint/scripts/wechat-diagnostics.js. +// Unit tests for src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js. // // The script is a self-contained IIFE that mutates process.stderr.write, // http.request, http.get, https.request, and https.get globally on require — @@ -11,17 +11,21 @@ // it (HTTP request, stderr write, etc.), and emits structured JSON we can // assert on. -import { describe, it, expect } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; const DIAGNOSTICS_PATH = path.join( import.meta.dirname, "..", - "nemoclaw-blueprint", - "scripts", + "src", + "lib", + "messaging", + "channels", + "wechat", + "runtime", "wechat-diagnostics.js", ); diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index ac5aed9f25..ba5664f98f 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -12,8 +12,12 @@ const REPO_ROOT = path.join(import.meta.dirname, ".."); const START_SCRIPT = path.join(REPO_ROOT, "scripts", "nemoclaw-start.sh"); const PRELOAD_SOURCE = path.join( REPO_ROOT, - "nemoclaw-blueprint", - "scripts", + "src", + "lib", + "messaging", + "channels", + "whatsapp", + "runtime", "whatsapp-qr-compact.js", ); From 0de062b1031fad649a502930dcb9e22796862f2b Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 13 Jun 2026 22:24:56 +0700 Subject: [PATCH 05/23] refactor(messaging): colocate wechat host qr login --- .../policies/presets/wechat.yaml | 2 +- src/lib/host-qr-handlers.ts | 77 ------------------- src/lib/messaging/channels/manifests.test.ts | 2 +- .../wechat/hooks/host-qr-login-runtime.ts | 29 ++----- .../messaging/channels}/wechat/login.test.ts | 4 +- .../messaging/channels}/wechat/login.ts | 0 .../messaging/channels}/wechat/qr.test.ts | 2 +- .../messaging/channels}/wechat/qr.ts | 2 +- .../onboard/messaging-channel-setup.test.ts | 22 +++--- src/lib/sandbox/channels.test.ts | 2 +- test/policies.test.ts | 2 +- 11 files changed, 23 insertions(+), 121 deletions(-) delete mode 100644 src/lib/host-qr-handlers.ts rename src/{ext => lib/messaging/channels}/wechat/login.test.ts (98%) rename src/{ext => lib/messaging/channels}/wechat/login.ts (100%) rename src/{ext => lib/messaging/channels}/wechat/qr.test.ts (99%) rename src/{ext => lib/messaging/channels}/wechat/qr.ts (99%) diff --git a/nemoclaw-blueprint/policies/presets/wechat.yaml b/nemoclaw-blueprint/policies/presets/wechat.yaml index 6b53fbda15..60225234bd 100644 --- a/nemoclaw-blueprint/policies/presets/wechat.yaml +++ b/nemoclaw-blueprint/policies/presets/wechat.yaml @@ -14,7 +14,7 @@ # IDC host either bridge can hit must be listed explicitly here. # # Known hosts (extend when an operator observes a new IDC redirect): -# - ilinkai.weixin.qq.com bootstrap; hard-coded in src/ext/wechat/qr.ts +# - ilinkai.weixin.qq.com bootstrap; hard-coded in src/lib/messaging/channels/wechat/qr.ts # and Hermes' WEIXIN_BASE_URL default per # hermes-agent docs/messaging/weixin # - ilinkai.wechat.com per-account baseUrl returned after QR confirm diff --git a/src/lib/host-qr-handlers.ts b/src/lib/host-qr-handlers.ts deleted file mode 100644 index 6b39cb5058..0000000000 --- a/src/lib/host-qr-handlers.ts +++ /dev/null @@ -1,77 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 -// -// Pluggable host-side QR login handlers. -// -// Channels marked `loginMethod: "host-qr"` in KNOWN_CHANNELS dispatch through -// this registry instead of the paste prompt. Each handler runs the -// provider-specific QR handshake on the host (so the operator can scan with -// a phone), captures the bot token + non-secret account metadata, and -// returns a normalized result that the onboard flow can apply uniformly. -// -// To register a new host-qr channel: -// 1. Add `loginMethod: "host-qr"` to its ChannelDef in sandbox-channels.ts. -// 2. Add an entry to HOST_QR_LOGIN_HANDLERS below — keep the QR/network -// code under src/ext// and only the adapter here. - -export type HostQrLoginKind = "ok" | "timeout" | "expired" | "aborted" | "error"; - -export interface HostQrLoginResult { - kind: HostQrLoginKind; - /** Free-text reason; populated for kind="error". */ - message?: string; - /** Bot token to save under the channel's envKey. Required for kind="ok". */ - token?: string; - /** Non-secret per-account metadata to stash on process.env so the - * Dockerfile-patch path can serialize it into the channel's build args - * (e.g. NEMOCLAW_WECHAT_CONFIG_B64). Keys are env-var names. */ - extraEnv?: Record; - /** User id to seed into the channel's userIdEnvKey when one isn't set - * (DM-allowlist convenience). */ - defaultUserId?: string; - /** One-line summary appended to the success log, - * e.g. `✓ wechat token saved (account 12345)`. */ - summary?: string; -} - -export type HostQrLoginHandler = () => Promise; - -export const HOST_QR_LOGIN_HANDLERS: Record = { - wechat: async () => { - // Wrap the lazy require + the runWechatHostQrLogin call in a single - // try/catch so any unexpected throw (missing module after bundling, a - // qrcode-terminal native-IO error, an iLink protocol edge case that - // escapes the discriminated result) turns into a structured "error" - // result the onboard dispatcher already knows how to render — instead - // of bubbling an unhandled rejection up through the registry. - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const { runWechatHostQrLogin } = require("../ext/wechat/login") as { - runWechatHostQrLogin: typeof import("../ext/wechat/login").runWechatHostQrLogin; - }; - const result = await runWechatHostQrLogin(); - if (result.kind !== "ok") { - return result.kind === "error" - ? { kind: "error", message: result.message } - : { kind: result.kind }; - } - const { token, accountId, baseUrl, userId } = result.credentials; - return { - kind: "ok", - token, - extraEnv: { - WECHAT_ACCOUNT_ID: accountId, - WECHAT_BASE_URL: baseUrl, - WECHAT_USER_ID: userId, - }, - defaultUserId: userId, - summary: `account ${accountId}`, - }; - } catch (err) { - return { - kind: "error", - message: err instanceof Error ? err.message : String(err), - }; - } - }, -}; diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index dea18687d0..6d0d4ed399 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -244,7 +244,7 @@ describe("built-in channel manifests", () => { "state/registry", "adapters/openshell", "host-qr-handlers", - "ext/wechat", + "../ext/", "node:fs", "node:child_process", ]; diff --git a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts index cc65da1563..af27994a8c 100644 --- a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts +++ b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { saveCredential } from "../../../../credentials/store"; -import { HOST_QR_LOGIN_HANDLERS, type HostQrLoginResult } from "../../../../host-qr-handlers"; +import { runWechatHostQrLogin, type WechatLoginResult as WechatHostQrLoginResult } from "../login"; import { wechatManifest } from "../manifest"; import type { WechatIlinkLoginHookOptions, WechatLoginResult } from "./ilink-login"; @@ -16,39 +16,22 @@ export function createDefaultWechatHostQrLoginOptions(): WechatIlinkLoginHookOpt function createWechatHostQrLoginRunner(): () => Promise { return async () => { logEnrollmentHelp(); - const handler = HOST_QR_LOGIN_HANDLERS.wechat; - if (!handler) return { kind: "error", message: "no host-qr handler registered" }; - let result: HostQrLoginResult; + let result: WechatHostQrLoginResult; try { - result = await handler(); + result = await runWechatHostQrLogin(); } catch (error) { result = { kind: "error", message: error instanceof Error ? error.message : String(error) }; } if (result.kind !== "ok") { - return result.kind === "error" - ? { kind: "error", message: result.message } - : { kind: result.kind }; - } - if (!result.token) { - return { kind: "error", message: "host-qr handler returned no token" }; - } - - const accountId = result.extraEnv?.WECHAT_ACCOUNT_ID; - if (!accountId) { - return { kind: "error", message: "host-qr handler returned no WeChat account id" }; + return result; } return { kind: "ok", - summary: result.summary, - credentials: { - token: result.token, - accountId, - baseUrl: result.extraEnv?.WECHAT_BASE_URL, - userId: result.extraEnv?.WECHAT_USER_ID ?? result.defaultUserId, - }, + summary: `account ${result.credentials.accountId}`, + credentials: result.credentials, }; }; } diff --git a/src/ext/wechat/login.test.ts b/src/lib/messaging/channels/wechat/login.test.ts similarity index 98% rename from src/ext/wechat/login.test.ts rename to src/lib/messaging/channels/wechat/login.test.ts index eacff753e4..731a5f1e25 100644 --- a/src/ext/wechat/login.test.ts +++ b/src/lib/messaging/channels/wechat/login.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from "vitest"; -import { runWechatHostQrLogin } from "../../../dist/ext/wechat/login"; -import type { FetchLike } from "../../../dist/ext/wechat/qr"; +import { runWechatHostQrLogin } from "./login"; +import type { FetchLike } from "./qr"; type StatusBody = { status: string; diff --git a/src/ext/wechat/login.ts b/src/lib/messaging/channels/wechat/login.ts similarity index 100% rename from src/ext/wechat/login.ts rename to src/lib/messaging/channels/wechat/login.ts diff --git a/src/ext/wechat/qr.test.ts b/src/lib/messaging/channels/wechat/qr.test.ts similarity index 99% rename from src/ext/wechat/qr.test.ts rename to src/lib/messaging/channels/wechat/qr.test.ts index a34e6b149e..716cd6b17e 100644 --- a/src/ext/wechat/qr.test.ts +++ b/src/lib/messaging/channels/wechat/qr.test.ts @@ -11,7 +11,7 @@ import { WECHAT_ILINK_BOOTSTRAP_BASE_URL, WECHAT_ILINK_DEFAULT_BOT_TYPE, type FetchLike, -} from "../../../dist/ext/wechat/qr"; +} from "./qr"; type Capture = { url: string; init?: { method?: string; headers?: Record } }; diff --git a/src/ext/wechat/qr.ts b/src/lib/messaging/channels/wechat/qr.ts similarity index 99% rename from src/ext/wechat/qr.ts rename to src/lib/messaging/channels/wechat/qr.ts index ec30383e0e..24dfaca8c8 100644 --- a/src/ext/wechat/qr.ts +++ b/src/lib/messaging/channels/wechat/qr.ts @@ -37,7 +37,7 @@ export const WECHAT_ILINK_APP_ID = "bot"; * Pinned in lockstep with the @tencent-weixin/openclaw-weixin version * installed in the sandbox image, so the iLink gateway sees the same * client version from both the host login and the in-sandbox plugin. - * Bump together with WECHAT_PLUGIN_SPEC in the messaging WeChat hook. */ + * Bump together with WECHAT_PLUGIN_INSTALL_SPEC in the messaging WeChat hook. */ export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.3"); /** Client-side ceiling for a single status long-poll. 35s keeps us within diff --git a/src/lib/onboard/messaging-channel-setup.test.ts b/src/lib/onboard/messaging-channel-setup.test.ts index eeb7a9b803..f0ca47699f 100644 --- a/src/lib/onboard/messaging-channel-setup.test.ts +++ b/src/lib/onboard/messaging-channel-setup.test.ts @@ -4,9 +4,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getCredential, prompt, saveCredential } from "../credentials/store"; -import { HOST_QR_LOGIN_HANDLERS } from "../host-qr-handlers"; import { createBuiltInChannelManifestRegistry, MessagingSetupApplier } from "../messaging"; import { MESSAGING_SETUP_APPLIER_ENV_KEY } from "../messaging/applier/types"; +import { runWechatHostQrLogin } from "../messaging/channels/wechat/login"; import { setupMessagingChannels, setupSelectedMessagingChannels } from "./messaging-channel-setup"; import { validateSlackCredentials } from "../messaging/channels/slack/hooks/credential-validation"; @@ -19,10 +19,8 @@ vi.mock("../credentials/store", () => ({ saveCredential: vi.fn(), })); -vi.mock("../host-qr-handlers", () => ({ - HOST_QR_LOGIN_HANDLERS: { - wechat: vi.fn(), - }, +vi.mock("../messaging/channels/wechat/login", () => ({ + runWechatHostQrLogin: vi.fn(), })); vi.mock("../messaging/channels/slack/hooks/credential-validation", () => ({ @@ -257,16 +255,14 @@ describe("setupSelectedMessagingChannels", () => { }); it("runs WeChat host-QR enrollment through the manifest hook", async () => { - vi.mocked(HOST_QR_LOGIN_HANDLERS.wechat).mockResolvedValue({ + vi.mocked(runWechatHostQrLogin).mockResolvedValue({ kind: "ok", - token: "wechat-token", - extraEnv: { - WECHAT_ACCOUNT_ID: "wechat-account", - WECHAT_BASE_URL: "https://ilinkai.wechat.com", - WECHAT_USER_ID: "wechat-user", + credentials: { + token: "wechat-token", + accountId: "wechat-account", + baseUrl: "https://ilinkai.wechat.com", + userId: "wechat-user", }, - defaultUserId: "wechat-user", - summary: "account wechat-account", }); const logs: string[] = []; vi.spyOn(console, "log").mockImplementation((message = "") => { diff --git a/src/lib/sandbox/channels.test.ts b/src/lib/sandbox/channels.test.ts index 4d8ad87b64..27b3b98b03 100644 --- a/src/lib/sandbox/channels.test.ts +++ b/src/lib/sandbox/channels.test.ts @@ -30,7 +30,7 @@ describe("sandbox-channels KNOWN_CHANNELS", () => { it("classifies channels by login method", () => { // Token-paste is the default and stays implicit (undefined). WeChat // captures a static token via a host-side QR handshake - // (src/ext/wechat/login.ts). WhatsApp pairs entirely inside the sandbox + // (src/lib/messaging/channels/wechat/login.ts). WhatsApp pairs entirely inside the sandbox // because the bot library owns the live Signal-style session — a // host-side capture would yield a stale blob the moment the bot mutates // its on-disk state. Onboarding branches on this flag, so flipping any diff --git a/test/policies.test.ts b/test/policies.test.ts index 94cdc0f2ff..a1b23e17be 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -421,7 +421,7 @@ describe("policies", () => { // OpenShell's SSRF engine doesn't expand `*.` wildcards at // runtime, so the preset lists each known iLink IDC host explicitly. // Both hosts are load-bearing today — `ilinkai.weixin.qq.com` is the - // bootstrap (hard-coded in src/ext/wechat/qr.ts), `ilinkai.wechat.com` + // bootstrap (hard-coded in src/lib/messaging/channels/wechat/qr.ts), `ilinkai.wechat.com` // is the per-account baseUrl returned after QR confirm. Additional // IDC hosts may need to be added when operators observe new // `DENIED ... -> :443` lines in OCSF logs. From 59cf5329323a04efb12acb3bd91a595084d95eea Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 13 Jun 2026 22:41:02 +0700 Subject: [PATCH 06/23] fix(messaging): address metadata review comments --- src/lib/messaging/channels/metadata.test.ts | 7 ++----- src/lib/messaging/channels/metadata.ts | 7 ------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 04bef2569e..98a5319cb8 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; -import type { ChannelManifest } from "../manifest"; +import type { ChannelManifest, ChannelPolicyPresetReference } from "../manifest"; import { getMessagingChannelForCredentialEnvKey, getMessagingConfigEnvAliases, @@ -173,10 +173,7 @@ describe("built-in messaging channel metadata", () => { }); }); -function manifestWithPreset( - id: string, - preset: NonNullable[number], -): ChannelManifest { +function manifestWithPreset(id: string, preset: ChannelPolicyPresetReference): ChannelManifest { return { schemaVersion: 1, id, diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index de03ba2ecf..41b9494d8b 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -7,7 +7,6 @@ import type { ChannelPolicyPresetReference, ChannelPolicyPresetSpec, MessagingAgentId, - MessagingSerializableValue, } from "../manifest"; import { BUILT_IN_CHANNEL_MANIFESTS } from "./built-ins"; @@ -325,12 +324,6 @@ function packageInstallValue( }; } -function stringArray(value: MessagingSerializableValue | undefined): string[] { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : []; -} - function uniqueStrings(values: readonly string[]): string[] { return [...new Set(values)]; } From 5c71c1fb2709949713d6e7aaf23301c8d1111e0b Mon Sep 17 00:00:00 2001 From: San Dang Date: Sat, 13 Jun 2026 23:47:10 +0700 Subject: [PATCH 07/23] refactor(messaging): compact persisted plan state --- src/lib/messaging/persistence.ts | 625 +++++++++++++++++++++- src/lib/messaging/plan-validation.test.ts | 78 ++- src/lib/messaging/plan-validation.ts | 54 +- src/lib/state/onboard-session.test.ts | 42 +- test/registry.test.ts | 60 ++- 5 files changed, 795 insertions(+), 64 deletions(-) diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 34831cf585..de7a256289 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -5,6 +5,12 @@ import { createBuiltInChannelManifestRegistry, createBuiltInRenderTemplateResolver, } from "./channels"; +import { buildWechatSeedOpenClawAccountOutputs } from "./channels/wechat/hooks/seed-openclaw-account"; +import { planCredentialBindings } from "./compiler/engines/credential-binding-engine"; +import { planHealthChecks } from "./compiler/engines/health-check-engine"; +import { planNetworkPolicy } from "./compiler/engines/policy-resolver"; +import { planRuntimeSetup } from "./compiler/engines/runtime-setup-engine"; +import { planStateUpdates } from "./compiler/engines/state-update-engine"; import { collectTemplateReferencesInLines, collectTemplateReferencesInValue, @@ -14,38 +20,122 @@ import { resolveRenderTemplatesInLines, resolveRenderTemplatesInValue, } from "./compiler/engines/template"; +import type { ManifestCompilerContext } from "./compiler/types"; import type { ChannelHookSpec, + ChannelInputSpec, ChannelManifest, MessagingAgentId, MessagingChannelId, + MessagingSerializableValue, SandboxMessagingAgentRenderPlan, + SandboxMessagingBuildStepPlan, SandboxMessagingChannelPlan, + SandboxMessagingCredentialBindingPlan, SandboxMessagingEnvLinesRenderPlan, SandboxMessagingHookReferencePlan, + SandboxMessagingInputReference, SandboxMessagingJsonRenderPlan, SandboxMessagingPlan, + SandboxMessagingRuntimeSetupPlan, } from "./manifest"; +import type { MessagingHookInputMap, MessagingHookOutputMap } from "./hooks"; -export type PersistedSandboxMessagingChannelPlan = Omit & { - readonly hooks?: readonly SandboxMessagingHookReferencePlan[]; -}; +export type PersistedSandboxMessagingInputReference = Pick< + SandboxMessagingInputReference, + "inputId" | "value" | "credentialAvailable" +>; + +export type PersistedSandboxMessagingChannelPlan = Pick< + SandboxMessagingChannelPlan, + "channelId" | "configured" | "disabled" +> & { + readonly inputs?: readonly PersistedSandboxMessagingInputReference[]; +} & Partial< + Pick + > & { + readonly hooks?: readonly SandboxMessagingHookReferencePlan[]; + }; + +export type PersistedSandboxMessagingCredentialBindingPlan = Pick< + SandboxMessagingCredentialBindingPlan, + "channelId" | "providerEnvKey" | "credentialAvailable" | "credentialHash" +> & + Partial< + Pick< + SandboxMessagingCredentialBindingPlan, + "credentialId" | "sourceInput" | "providerName" | "placeholder" + > + >; export type PersistedSandboxMessagingPlan = Omit< SandboxMessagingPlan, - "channels" | "agentRender" + | "channels" + | "credentialBindings" + | "networkPolicy" + | "agentRender" + | "buildSteps" + | "runtimeSetup" + | "stateUpdates" + | "healthChecks" > & { readonly channels: readonly PersistedSandboxMessagingChannelPlan[]; + readonly credentialBindings?: readonly PersistedSandboxMessagingCredentialBindingPlan[]; + readonly networkPolicy?: SandboxMessagingPlan["networkPolicy"]; readonly agentRender?: readonly SandboxMessagingAgentRenderPlan[]; + readonly buildSteps?: readonly SandboxMessagingBuildStepPlan[]; + readonly runtimeSetup?: SandboxMessagingRuntimeSetupPlan; + readonly stateUpdates?: SandboxMessagingPlan["stateUpdates"]; + readonly healthChecks?: SandboxMessagingPlan["healthChecks"]; }; export function compactSandboxMessagingPlanForPersistence( plan: SandboxMessagingPlan, ): PersistedSandboxMessagingPlan { - const { agentRender: _agentRender, channels, ...rest } = clonePlan(plan); + const { + channels, + credentialBindings, + networkPolicy: _networkPolicy, + agentRender: _agentRender, + buildSteps: _buildSteps, + runtimeSetup: _runtimeSetup, + stateUpdates: _stateUpdates, + healthChecks: _healthChecks, + ...rest + } = clonePlan(plan); return { ...rest, - channels: channels.map(({ hooks: _hooks, ...channel }) => channel), + channels: channels.map((channel) => ({ + channelId: channel.channelId, + configured: channel.configured, + disabled: channel.disabled, + inputs: channel.inputs + .flatMap((input) => { + const compact: PersistedSandboxMessagingInputReference = { + inputId: input.inputId, + ...(input.value !== undefined ? { value: input.value } : {}), + ...(input.credentialAvailable !== undefined + ? { credentialAvailable: input.credentialAvailable } + : {}), + }; + return compact.value !== undefined || compact.credentialAvailable !== undefined + ? [compact] + : []; + }) + .sort((left, right) => left.inputId.localeCompare(right.inputId)), + })), + credentialBindings: credentialBindings + .map((binding) => ({ + channelId: binding.channelId, + providerEnvKey: binding.providerEnvKey, + credentialAvailable: binding.credentialAvailable, + ...(binding.credentialHash ? { credentialHash: binding.credentialHash } : {}), + })) + .sort((left, right) => + `${left.channelId}:${left.providerEnvKey}`.localeCompare( + `${right.channelId}:${right.providerEnvKey}`, + ), + ), }; } @@ -53,27 +143,520 @@ export function hydrateDerivedSandboxMessagingPlanFields( plan: SandboxMessagingPlan, ): SandboxMessagingPlan { const manifestRegistry = createBuiltInChannelManifestRegistry(); - const channels = plan.channels.map((channel) => { - if (channel.hooks.length > 0) return channel; - return { - ...channel, - hooks: channelHooksFromManifest( - plan.agent, - channel.channelId, - manifestRegistry.get(channel.channelId), - ), - }; - }); + const channels = plan.channels.map((channel) => + hydrateChannelFromManifest(plan, channel, manifestRegistry.get(channel.channelId)), + ); const hydratedPlan = { ...plan, channels }; + const manifests = channels.flatMap((channel) => { + const manifest = manifestRegistry.get(channel.channelId); + return manifest ? [manifest] : []; + }); + const planWithCredentials = hydratedPlan; return { - ...hydratedPlan, + ...planWithCredentials, + networkPolicy: + plan.networkPolicy.entries.length > 0 + ? plan.networkPolicy + : planNetworkPolicy(manifests, compilerContext(planWithCredentials)), agentRender: - hydratedPlan.agentRender.length > 0 - ? hydratedPlan.agentRender - : agentRenderFromManifests(hydratedPlan, manifestRegistry), + plan.agentRender.length > 0 + ? plan.agentRender + : agentRenderFromManifests(planWithCredentials, manifestRegistry), + buildSteps: + plan.buildSteps.length > 0 + ? plan.buildSteps + : buildStepsFromManifests(planWithCredentials, manifests), + runtimeSetup: runtimeSetupHasEntries(plan.runtimeSetup) + ? plan.runtimeSetup + : planRuntimeSetup(manifests, plan.agent, channels), + stateUpdates: + plan.stateUpdates.length > 0 ? plan.stateUpdates : manifests.flatMap(planStateUpdates), + healthChecks: + plan.healthChecks.length > 0 ? plan.healthChecks : manifests.flatMap(planHealthChecks), + }; +} + +export function normalizePersistedSandboxMessagingPlanShape( + plan: MaybeCompactMessagingPlan, +): SandboxMessagingPlan { + const manifestRegistry = createBuiltInChannelManifestRegistry(); + const disabledChannels = plan.disabledChannels.filter( + (channelId) => typeof channelId === "string", + ); + const disabledSet = new Set(disabledChannels); + const channels = plan.channels.map((channel) => + normalizePersistedChannel(channel, disabledSet, manifestRegistry.get(channel.channelId)), + ); + const normalizedPlan: SandboxMessagingPlan = { + ...plan, + channels, + disabledChannels, + credentialBindings: normalizePersistedCredentialBindings(plan, channels, manifestRegistry), + networkPolicy: + plan.networkPolicy && Array.isArray(plan.networkPolicy.entries) + ? plan.networkPolicy + : { presets: [], entries: [] }, + agentRender: Array.isArray(plan.agentRender) ? [...plan.agentRender] : [], + buildSteps: Array.isArray(plan.buildSteps) ? [...plan.buildSteps] : [], + ...(plan.runtimeSetup !== undefined + ? { runtimeSetup: normalizeRuntimeSetup(plan.runtimeSetup) } + : {}), + stateUpdates: Array.isArray(plan.stateUpdates) ? [...plan.stateUpdates] : [], + healthChecks: Array.isArray(plan.healthChecks) ? [...plan.healthChecks] : [], + }; + + return normalizedPlan; +} + +export type MaybeCompactMessagingChannelPlan = Partial & { + readonly channelId: string; + readonly inputs?: readonly Partial[]; +}; + +export type MaybeCompactMessagingPlan = Omit< + Partial, + "channels" | "credentialBindings" +> & + Pick & { + readonly channels: readonly MaybeCompactMessagingChannelPlan[]; + readonly disabledChannels: readonly string[]; + readonly credentialBindings?: readonly Partial[]; + }; + +function normalizePersistedChannel( + channel: MaybeCompactMessagingChannelPlan, + disabledSet: ReadonlySet, + manifest: ChannelManifest | undefined, +): SandboxMessagingChannelPlan { + const disabled = channel.disabled ?? disabledSet.has(channel.channelId); + const configured = channel.configured ?? true; + const hasFullShape = hasFullChannelShape(channel); + const inputs = hasFullShape + ? normalizeFullInputs(channel.channelId, channel.inputs ?? []) + : normalizePersistedInputs(channel, manifest); + const active = + channel.active ?? (configured && !disabled && requiredInputsAvailable(manifest, inputs)); + + return { + channelId: channel.channelId, + displayName: channel.displayName ?? manifest?.displayName ?? channel.channelId, + authMode: channel.authMode ?? manifest?.auth.mode ?? "none", + active, + selected: channel.selected ?? configured, + configured, + disabled, + inputs, + hooks: Array.isArray(channel.hooks) ? [...channel.hooks] : [], + }; +} + +function normalizePersistedInputs( + channel: MaybeCompactMessagingChannelPlan, + manifest: ChannelManifest | undefined, +): SandboxMessagingInputReference[] { + const persistedById = new Map( + (channel.inputs ?? []) + .filter((input) => typeof input.inputId === "string") + .map((input) => [input.inputId as string, input] as const), + ); + const fromManifest = (manifest?.inputs ?? []).map((input) => + inputReferenceFromManifest(channel.channelId, input, persistedById.get(input.id)), + ); + const manifestInputIds = new Set((manifest?.inputs ?? []).map((input) => input.id)); + const unknownInputs = [...persistedById.values()].flatMap((input) => { + if (!input.inputId || manifestInputIds.has(input.inputId)) return []; + return [normalizeUnknownInput(channel.channelId, input)]; + }); + return [...fromManifest, ...unknownInputs]; +} + +function normalizeFullInputs( + channelId: string, + inputs: readonly Partial[], +): SandboxMessagingInputReference[] { + return inputs + .filter((input) => typeof input.inputId === "string") + .map((input) => ({ + channelId: typeof input.channelId === "string" ? input.channelId : channelId, + inputId: input.inputId as string, + kind: input.kind === "secret" || input.kind === "config" ? input.kind : "config", + required: typeof input.required === "boolean" ? input.required : false, + ...(typeof input.sourceEnv === "string" ? { sourceEnv: input.sourceEnv } : {}), + ...(typeof input.statePath === "string" ? { statePath: input.statePath } : {}), + ...(input.credentialAvailable !== undefined + ? { credentialAvailable: input.credentialAvailable } + : {}), + ...(input.value !== undefined ? { value: input.value } : {}), + })); +} + +function inputReferenceFromManifest( + channelId: string, + input: ChannelInputSpec, + persisted: Partial | undefined, +): SandboxMessagingInputReference { + return { + channelId, + inputId: input.id, + kind: input.kind, + required: input.required, + ...(input.envKey ? { sourceEnv: input.envKey } : {}), + ...(input.kind === "config" && input.statePath ? { statePath: input.statePath } : {}), + ...(persisted?.credentialAvailable !== undefined + ? { credentialAvailable: persisted.credentialAvailable } + : {}), + ...(persisted?.value !== undefined ? { value: persisted.value } : {}), + }; +} + +function normalizeUnknownInput( + channelId: string, + input: Partial, +): SandboxMessagingInputReference { + const kind = input.kind === "secret" || input.kind === "config" ? input.kind : "config"; + return { + channelId, + inputId: input.inputId as string, + kind, + required: input.required === true, + ...(typeof input.sourceEnv === "string" ? { sourceEnv: input.sourceEnv } : {}), + ...(typeof input.statePath === "string" ? { statePath: input.statePath } : {}), + ...(input.credentialAvailable !== undefined + ? { credentialAvailable: input.credentialAvailable } + : {}), + ...(input.value !== undefined ? { value: input.value } : {}), + }; +} + +function requiredInputsAvailable( + manifest: ChannelManifest | undefined, + inputs: readonly SandboxMessagingInputReference[], +): boolean { + if (!manifest) return true; + return manifest.inputs.every((manifestInput) => { + if (!manifestInput.required) return true; + const input = inputs.find((entry) => entry.inputId === manifestInput.id); + if (!input) return false; + if (input.kind === "secret") return input.credentialAvailable === true; + if (input.value === undefined) return false; + return typeof input.value === "string" ? input.value.trim().length > 0 : true; + }); +} + +function normalizePersistedCredentialBindings( + plan: MaybeCompactMessagingPlan, + channels: readonly SandboxMessagingChannelPlan[], + manifestRegistry: ReturnType, +): SandboxMessagingCredentialBindingPlan[] { + const persisted = plan.credentialBindings ?? []; + if ( + Array.isArray(plan.credentialBindings) && + plan.channels.every(hasFullChannelShape) && + persisted.every(hasFullCredentialBindingShape) + ) { + return persisted.map((binding) => ({ + channelId: binding.channelId as string, + credentialId: binding.credentialId as string, + sourceInput: binding.sourceInput as string, + providerName: binding.providerName as string, + providerEnvKey: binding.providerEnvKey as string, + placeholder: binding.placeholder as string, + credentialAvailable: binding.credentialAvailable === true, + ...(typeof binding.credentialHash === "string" + ? { credentialHash: binding.credentialHash } + : {}), + })); + } + + const manifests = channels.flatMap((channel) => { + const manifest = manifestRegistry.get(channel.channelId); + return manifest ? [manifest] : []; + }); + const planForBindings: SandboxMessagingPlan = { + ...plan, + channels, + credentialBindings: [], + networkPolicy: { presets: [], entries: [] }, + agentRender: [], + buildSteps: [], + runtimeSetup: { nodePreloads: [], envAliases: [], secretScans: [] }, + stateUpdates: [], + healthChecks: [], + }; + const generated = credentialBindingsFromManifests( + planForBindings, + manifests, + new Map(channels.map((channel) => [channel.channelId, channel.inputs] as const)), + ); + return generated.map((binding) => overlayPersistedCredentialBinding(binding, persisted)); +} + +function hydrateChannelFromManifest( + plan: SandboxMessagingPlan, + channel: SandboxMessagingChannelPlan, + manifest: ChannelManifest | undefined, +): SandboxMessagingChannelPlan { + const disabled = channel.disabled || plan.disabledChannels.includes(channel.channelId); + const inputs = hasFullChannelShape(channel) + ? normalizeFullInputs(channel.channelId, channel.inputs) + : normalizePersistedInputs(channel, manifest); + const configured = channel.configured; + return { + ...channel, + displayName: channel.displayName ?? manifest?.displayName ?? channel.channelId, + authMode: channel.authMode ?? manifest?.auth.mode ?? "none", + configured, + disabled, + active: channel.active, + inputs, + hooks: + channel.hooks.length > 0 + ? channel.hooks + : channelHooksFromManifest(plan.agent, channel.channelId, manifest), + }; +} + +function credentialBindingsFromManifests( + plan: SandboxMessagingPlan, + manifests: readonly ChannelManifest[], + inputRegistry: ReadonlyMap, +): SandboxMessagingCredentialBindingPlan[] { + const context = compilerContext(plan); + return manifests.flatMap((manifest) => + planCredentialBindings(manifest, context, inputRegistry.get(manifest.id) ?? []).map((binding) => + overlayPersistedCredentialBinding(binding, plan.credentialBindings), + ), + ); +} + +function overlayPersistedCredentialBinding( + binding: SandboxMessagingCredentialBindingPlan, + persisted: readonly Partial[], +): SandboxMessagingCredentialBindingPlan { + const match = persisted.find((candidate) => credentialBindingMatches(binding, candidate)); + if (!match) return binding; + return { + ...binding, + credentialAvailable: + typeof match.credentialAvailable === "boolean" + ? match.credentialAvailable + : binding.credentialAvailable, + ...(typeof match.credentialHash === "string" && match.credentialHash.length > 0 + ? { credentialHash: match.credentialHash } + : binding.credentialHash + ? { credentialHash: binding.credentialHash } + : {}), + }; +} + +function credentialBindingMatches( + binding: SandboxMessagingCredentialBindingPlan, + candidate: Partial, +): boolean { + if (candidate.channelId && candidate.channelId !== binding.channelId) return false; + if (candidate.providerEnvKey && candidate.providerEnvKey === binding.providerEnvKey) return true; + if (candidate.credentialId && candidate.credentialId === binding.credentialId) return true; + if (candidate.sourceInput && candidate.sourceInput === binding.sourceInput) return true; + return false; +} + +function buildStepsFromManifests( + plan: SandboxMessagingPlan, + manifests: readonly ChannelManifest[], +): SandboxMessagingBuildStepPlan[] { + const channelById = new Map(plan.channels.map((channel) => [channel.channelId, channel])); + return manifests.flatMap((manifest) => { + const channel = channelById.get(manifest.id); + const active = channel?.active === true && channel.disabled !== true; + return [ + ...packageInstallBuildSteps(plan.agent, manifest, active), + ...hookBuildSteps(plan, manifest, channel, active), + ]; + }); +} + +function packageInstallBuildSteps( + agent: MessagingAgentId, + manifest: ChannelManifest, + active: boolean, +): SandboxMessagingBuildStepPlan[] { + return (manifest.agentPackages ?? []) + .filter((agentPackage) => agentPackage.agent === agent) + .map((agentPackage) => ({ + channelId: manifest.id, + kind: "package-install" as const, + outputId: agentPackage.id, + required: agentPackage.required !== false, + ...(active + ? { + value: { + manager: agentPackage.manager, + spec: agentPackage.spec, + ...(typeof agentPackage.pin === "boolean" ? { pin: agentPackage.pin } : {}), + }, + } + : {}), + })); +} + +function hookBuildSteps( + plan: SandboxMessagingPlan, + manifest: ChannelManifest, + channel: SandboxMessagingChannelPlan | undefined, + active: boolean, +): SandboxMessagingBuildStepPlan[] { + return manifest.hooks + .filter((hook) => isHookForAgent(hook, plan.agent)) + .flatMap((hook) => { + const outputs = (hook.outputs ?? []).filter((output) => + ["build-arg", "build-file", "package-install"].includes(output.kind), + ); + if (outputs.length === 0) return []; + const hookOutputs = + active && channel ? buildKnownHookOutputs(plan, manifest, hook, channel) : {}; + return outputs.map((output) => ({ + channelId: manifest.id, + kind: output.kind as "build-arg" | "build-file" | "package-install", + hookId: hook.id, + handler: hook.handler, + outputId: output.id, + required: output.required === true, + ...(hookOutputs[output.id]?.value !== undefined + ? { value: hookOutputs[output.id]?.value } + : output.value !== undefined && active + ? { value: output.value } + : {}), + })); + }); +} + +function buildKnownHookOutputs( + plan: SandboxMessagingPlan, + _manifest: ChannelManifest, + hook: ChannelHookSpec, + channel: SandboxMessagingChannelPlan, +): MessagingHookOutputMap { + if (hook.handler === "wechat.seedOpenClawAccount") { + try { + return buildWechatSeedOpenClawAccountOutputs( + selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), + ); + } catch { + return {}; + } + } + return {}; +} + +function hasFullChannelShape( + channel: MaybeCompactMessagingChannelPlan, +): channel is MaybeCompactMessagingChannelPlan & SandboxMessagingChannelPlan { + return ( + typeof channel.displayName === "string" && + typeof channel.authMode === "string" && + typeof channel.active === "boolean" && + typeof channel.selected === "boolean" && + typeof channel.configured === "boolean" && + typeof channel.disabled === "boolean" && + Array.isArray(channel.inputs) + ); +} + +function hasFullCredentialBindingShape( + binding: Partial, +): binding is SandboxMessagingCredentialBindingPlan { + return ( + typeof binding.channelId === "string" && + typeof binding.credentialId === "string" && + typeof binding.sourceInput === "string" && + typeof binding.providerName === "string" && + typeof binding.providerEnvKey === "string" && + typeof binding.placeholder === "string" && + typeof binding.credentialAvailable === "boolean" + ); +} + +function buildHookInputMap( + channel: SandboxMessagingChannelPlan, + credentialBindings: readonly SandboxMessagingCredentialBindingPlan[], +): MessagingHookInputMap { + const inputs: Record = {}; + for (const input of channel.inputs) { + if (input.value === undefined) continue; + inputs[input.inputId] = input.value; + if (input.statePath) inputs[input.statePath] = input.value; + } + for (const credential of credentialBindings) { + if (credential.channelId !== channel.channelId) continue; + inputs[`credential.${credential.credentialId}.placeholder`] = credential.placeholder; + } + return inputs; +} + +function selectHookInputs( + inputs: MessagingHookInputMap, + inputKeys: readonly string[] | undefined, +): MessagingHookInputMap { + if (!inputKeys || inputKeys.length === 0) return inputs; + return Object.fromEntries( + inputKeys + .filter((inputKey) => Object.hasOwn(inputs, inputKey)) + .map((inputKey) => [inputKey, inputs[inputKey]]), + ); +} + +function runtimeSetupHasEntries(setup: SandboxMessagingRuntimeSetupPlan | undefined): boolean { + return Boolean( + setup && + (setup.nodePreloads.length > 0 || + setup.envAliases.length > 0 || + setup.secretScans.length > 0), + ); +} + +function normalizeRuntimeSetup( + setup: SandboxMessagingRuntimeSetupPlan | undefined, +): SandboxMessagingRuntimeSetupPlan { + return { + nodePreloads: Array.isArray(setup?.nodePreloads) ? [...setup.nodePreloads] : [], + envAliases: Array.isArray(setup?.envAliases) ? [...setup.envAliases] : [], + secretScans: Array.isArray(setup?.secretScans) ? [...setup.secretScans] : [], }; } +function compilerContext(plan: SandboxMessagingPlan): ManifestCompilerContext { + return { + sandboxName: plan.sandboxName, + agent: plan.agent, + workflow: plan.workflow, + isInteractive: false, + configuredChannels: plan.channels.map((channel) => channel.channelId), + disabledChannels: plan.disabledChannels, + credentialAvailability: credentialAvailabilityFromPlan(plan), + }; +} + +function credentialAvailabilityFromPlan(plan: SandboxMessagingPlan): Record { + const availability: Record = {}; + for (const channel of plan.channels) { + for (const input of channel.inputs) { + if (input.kind !== "secret" || input.credentialAvailable !== true) continue; + availability[input.inputId] = true; + availability[`${channel.channelId}.${input.inputId}`] = true; + if (input.sourceEnv) availability[input.sourceEnv] = true; + } + } + for (const credential of plan.credentialBindings) { + if (!credential.credentialAvailable) continue; + availability[credential.credentialId] = true; + availability[`${credential.channelId}.${credential.credentialId}`] = true; + availability[credential.sourceInput] = true; + availability[`${credential.channelId}.${credential.sourceInput}`] = true; + availability[credential.providerEnvKey] = true; + } + return availability; +} + function channelHooksFromManifest( agent: MessagingAgentId, channelId: MessagingChannelId, diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index 75a89f58d2..f3a07d2d15 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -66,15 +66,83 @@ describe("parseSandboxMessagingPlan", () => { expect(parsed).not.toBe(source); }); - it("accepts compact persisted plans without render or channel hooks", () => { - const source = makePlan(); + it("accepts compact persisted plans without manifest-derived sections", () => { + const source = makePlan({ + channels: [ + { + ...makePlan().channels[0], + inputs: [ + { + channelId: "telegram", + inputId: "botToken", + kind: "secret", + required: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + makePlan().channels[0].inputs[0], + ], + }, + ], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "sb-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash", + }, + ], + }); const compact = compactSandboxMessagingPlanForPersistence(source); const parsed = parseSandboxMessagingPlan(compact); - expect(parsed).toEqual({ + expect(compact).not.toHaveProperty("networkPolicy"); + expect(compact).not.toHaveProperty("agentRender"); + expect(compact).not.toHaveProperty("buildSteps"); + expect(compact).not.toHaveProperty("runtimeSetup"); + expect(compact).not.toHaveProperty("stateUpdates"); + expect(compact).not.toHaveProperty("healthChecks"); + expect(compact.channels[0]).toEqual({ + channelId: "telegram", + configured: true, + disabled: false, + inputs: [ + { inputId: "allowedIds", value: "123" }, + { inputId: "botToken", credentialAvailable: true }, + ], + }); + expect(parsed).toMatchObject({ ...source, - agentRender: [], - channels: source.channels.map((channel) => ({ ...channel, hooks: [] })), + channels: [ + expect.objectContaining({ + channelId: "telegram", + active: true, + hooks: [], + inputs: expect.arrayContaining([ + expect.objectContaining({ + inputId: "botToken", + credentialAvailable: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + }), + expect.objectContaining({ + inputId: "allowedIds", + statePath: "allowedIds.telegram", + value: "123", + }), + ]), + }), + ], + credentialBindings: [ + expect.objectContaining({ + providerEnvKey: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash", + }), + ], }); }); diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index df51cb948e..d6c164c9a5 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -8,6 +8,10 @@ import type { MessagingSerializableValue, SandboxMessagingPlan, } from "./manifest"; +import { + type MaybeCompactMessagingPlan, + normalizePersistedSandboxMessagingPlanShape, +} from "./persistence"; export interface SandboxMessagingPlanParseOptions { sandboxName?: string | null; @@ -27,13 +31,13 @@ export function parseSandboxMessagingPlan( typeof value.workflow !== "string" || !Array.isArray(value.channels) || !Array.isArray(value.disabledChannels) || - !Array.isArray(value.credentialBindings) || - !isObject(value.networkPolicy) || + (Object.hasOwn(value, "credentialBindings") && !Array.isArray(value.credentialBindings)) || + (Object.hasOwn(value, "networkPolicy") && !isObject(value.networkPolicy)) || (Object.hasOwn(value, "agentRender") && !Array.isArray(value.agentRender)) || - !Array.isArray(value.buildSteps) || + (Object.hasOwn(value, "buildSteps") && !Array.isArray(value.buildSteps)) || !isRuntimeSetup(value.runtimeSetup) || - !Array.isArray(value.stateUpdates) || - !Array.isArray(value.healthChecks) + (Object.hasOwn(value, "stateUpdates") && !Array.isArray(value.stateUpdates)) || + (Object.hasOwn(value, "healthChecks") && !Array.isArray(value.healthChecks)) ) { return null; } @@ -47,11 +51,19 @@ export function parseSandboxMessagingPlan( : null; for (const [index, channel] of value.channels.entries()) { if (!isObject(channel) || typeof channel.channelId !== "string") return null; - if (typeof channel.configured !== "boolean") return null; - if (typeof channel.active !== "boolean") return null; - if (typeof channel.disabled !== "boolean") return null; - if (!Array.isArray(channel.inputs)) return null; + if (Object.hasOwn(channel, "configured") && typeof channel.configured !== "boolean") { + return null; + } + if (Object.hasOwn(channel, "active") && typeof channel.active !== "boolean") return null; + if (Object.hasOwn(channel, "disabled") && typeof channel.disabled !== "boolean") return null; + if (Object.hasOwn(channel, "inputs") && !Array.isArray(channel.inputs)) return null; if (Object.hasOwn(channel, "hooks") && !Array.isArray(channel.hooks)) return null; + if ( + Array.isArray(channel.inputs) && + channel.inputs.some((input) => !isObject(input) || typeof input.inputId !== "string") + ) { + return null; + } if (supported && !supported.has(channel.channelId)) return null; if ( value.channels.findIndex( @@ -64,7 +76,7 @@ export function parseSandboxMessagingPlan( if (!value.disabledChannels.every((channelId) => typeof channelId === "string")) return null; return cloneSandboxMessagingPlan( - normalizePersistedSandboxMessagingPlanShape(value as unknown as MaybeCompactMessagingPlan), + normalizePersistedSandboxMessagingPlanShape(value as MaybeCompactMessagingPlan), ); } @@ -146,28 +158,6 @@ function stringifyPlanStateValue(value: MessagingSerializableValue | undefined): return text.length > 0 ? text : null; } -type MaybeCompactMessagingChannelPlan = Omit & { - readonly hooks?: SandboxMessagingPlan["channels"][number]["hooks"]; -}; - -type MaybeCompactMessagingPlan = Omit & { - readonly agentRender?: SandboxMessagingPlan["agentRender"]; - readonly channels: readonly MaybeCompactMessagingChannelPlan[]; -}; - -function normalizePersistedSandboxMessagingPlanShape( - plan: MaybeCompactMessagingPlan, -): SandboxMessagingPlan { - return { - ...plan, - agentRender: Array.isArray(plan.agentRender) ? [...plan.agentRender] : [], - channels: plan.channels.map((channel) => ({ - ...channel, - hooks: Array.isArray(channel.hooks) ? [...channel.hooks] : [], - })), - }; -} - function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index c17b41ef69..4a7570c79e 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -579,7 +579,20 @@ describe("onboard session", () => { session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingPlan).toEqual(created.messagingPlan); + expect(loaded.messagingPlan).toMatchObject({ + schemaVersion: 1, + sandboxName: "my-assistant", + agent: "openclaw", + workflow: "onboard", + disabledChannels: ["slack"], + channels: [ + expect.objectContaining({ channelId: "telegram", configured: true, disabled: false }), + expect.objectContaining({ channelId: "slack", configured: true, disabled: true }), + ], + }); + expect(loaded.messagingPlan?.channels[0]?.inputs.map((input) => input.inputId)).toContain( + "botToken", + ); }); it("writes compact messagingPlan derived fields to onboard-session.json", () => { @@ -618,7 +631,16 @@ describe("onboard session", () => { session.saveSession(created); const raw = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8")); + expect(raw.messagingPlan.networkPolicy).toBeUndefined(); expect(raw.messagingPlan.agentRender).toBeUndefined(); + expect(raw.messagingPlan.buildSteps).toBeUndefined(); + expect(raw.messagingPlan.runtimeSetup).toBeUndefined(); + expect(raw.messagingPlan.stateUpdates).toBeUndefined(); + expect(raw.messagingPlan.healthChecks).toBeUndefined(); + expect(raw.messagingPlan.channels[0].displayName).toBeUndefined(); + expect(raw.messagingPlan.channels[0].authMode).toBeUndefined(); + expect(raw.messagingPlan.channels[0].active).toBeUndefined(); + expect(raw.messagingPlan.channels[0].selected).toBeUndefined(); expect(raw.messagingPlan.channels[0].hooks).toBeUndefined(); const reloadedPlan = requireLoadedSession(session.loadSession()).messagingPlan; expect(reloadedPlan?.agentRender).toEqual([]); @@ -665,7 +687,10 @@ describe("onboard session", () => { session.saveSession(session.createSession()); const plan = makeMessagingPlan("my-assistant", ["discord"]); session.markStepComplete("provider_selection", { messagingPlan: plan }); - expect(requireLoadedSession(session.loadSession()).messagingPlan).toEqual(plan); + expect(requireLoadedSession(session.loadSession()).messagingPlan).toMatchObject({ + sandboxName: "my-assistant", + channels: [expect.objectContaining({ channelId: "discord", configured: true })], + }); session.markStepComplete("provider_selection", { messagingPlan: null }); expect(requireLoadedSession(session.loadSession()).messagingPlan).toBeNull(); @@ -1076,7 +1101,10 @@ describe("onboard session", () => { const saved = session.saveSession(created); const loaded = requireLoadedSession(session.loadSession()); expect(saved.messagingPlan).toEqual(plan); - expect(loaded.messagingPlan).toEqual(plan); + expect(loaded.messagingPlan).toMatchObject({ + sandboxName: "my-assistant", + channels: [expect.objectContaining({ channelId: "telegram", configured: true })], + }); }); it("filterSafeUpdates preserves messagingPlan field", () => { @@ -1087,7 +1115,13 @@ describe("onboard session", () => { }); const loaded = requireLoadedSession(session.loadSession()); - expect(loaded.messagingPlan).toEqual(plan); + expect(loaded.messagingPlan).toMatchObject({ + sandboxName: "my-assistant", + channels: [ + expect.objectContaining({ channelId: "slack", configured: true }), + expect.objectContaining({ channelId: "discord", configured: true }), + ], + }); }); it("filterSafeUpdates ignores malformed messagingPlan values", () => { diff --git a/test/registry.test.ts b/test/registry.test.ts index e83d7c5a2a..2dd9bde76f 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -229,14 +229,51 @@ describe("registry", () => { }); it("stores messaging plan state at registration time", () => { - const plan = makeMessagingPlan("messaging", ["telegram"]); + const basePlan = makeMessagingPlan("messaging", ["telegram"]); + const plan = { + ...basePlan, + channels: [ + { + ...basePlan.channels[0], + inputs: [ + { + channelId: "telegram", + inputId: "botToken", + kind: "secret", + required: true, + sourceEnv: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + }, + ], + }, + ], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegramBotToken", + sourceInput: "botToken", + providerName: "messaging-telegram-bridge", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash", + }, + ], + }; registry.registerSandbox({ name: "messaging", messaging: { schemaVersion: 1, plan }, }); const sb = registry.getSandbox("messaging"); - expect(sb.messaging).toEqual({ schemaVersion: 1, plan }); + expect(sb.messaging).toMatchObject({ + schemaVersion: 1, + plan: { + schemaVersion: 1, + sandboxName: "messaging", + channels: [expect.objectContaining({ channelId: "telegram", active: true })], + }, + }); const rawSandbox = sb as unknown as Record; expect(rawSandbox.messagingChannels).toBeUndefined(); expect(rawSandbox.messagingChannelConfig).toBeUndefined(); @@ -257,8 +294,27 @@ describe("registry", () => { sandboxName: "messaging", channels: [{ channelId: "telegram" }], }); + expect(data.sandboxes.messaging.messaging.plan.networkPolicy).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.agentRender).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.buildSteps).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.runtimeSetup).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.stateUpdates).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.healthChecks).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.channels[0]).toEqual({ + channelId: "telegram", + configured: true, + disabled: false, + inputs: [{ inputId: "botToken", credentialAvailable: true }], + }); expect(data.sandboxes.messaging.messaging.plan.channels[0].hooks).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.credentialBindings).toEqual([ + { + channelId: "telegram", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + credentialAvailable: true, + credentialHash: "hash", + }, + ]); expect(data.sandboxes.messaging.messagingChannels).toBeUndefined(); expect(data.sandboxes.messaging.messagingChannelConfig).toBeUndefined(); }); From cbe09ef555a2372f469a839d31f09e4cb5a74e5d Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 14 Jun 2026 21:27:23 +0700 Subject: [PATCH 08/23] fix(messaging): preserve persisted channel plan fields --- .../compiler/workflow-planner.test.ts | 8 ++++ .../messaging/compiler/workflow-planner.ts | 40 +++++++++++++--- src/lib/messaging/persistence.ts | 4 +- src/lib/messaging/plan-validation.test.ts | 3 +- src/lib/onboard/dockerfile-patch.test.ts | 47 ++++++++++++++++++- src/lib/onboard/dockerfile-patch.ts | 8 +++- src/lib/state/onboard-session.test.ts | 4 +- test/e2e/test-onboard-repair.sh | 8 ++-- test/registry.test.ts | 6 ++- 9 files changed, 109 insertions(+), 19 deletions(-) diff --git a/src/lib/messaging/compiler/workflow-planner.test.ts b/src/lib/messaging/compiler/workflow-planner.test.ts index 2fa264c360..04440f3839 100644 --- a/src/lib/messaging/compiler/workflow-planner.test.ts +++ b/src/lib/messaging/compiler/workflow-planner.test.ts @@ -636,6 +636,9 @@ describe("MessagingWorkflowPlanner", () => { active: false, disabled: true, }); + expect( + (stopped?.runtimeSetup?.nodePreloads ?? []).some((entry) => entry.channelId === "telegram"), + ).toBe(false); const started = await planner().buildChannelStartPlanFromSandboxEntry({ sandboxName: "demo", @@ -656,6 +659,11 @@ describe("MessagingWorkflowPlanner", () => { active: true, disabled: false, }); + expect( + (started?.runtimeSetup?.nodePreloads ?? []).some( + (entry) => entry.channelId === "telegram" && entry.module === "telegram-diagnostics", + ), + ).toBe(true); }); it("removes a channel and its dependent plan entries from an existing sandbox entry plan", async () => { diff --git a/src/lib/messaging/compiler/workflow-planner.ts b/src/lib/messaging/compiler/workflow-planner.ts index a28401d6e8..ce901ad1d3 100644 --- a/src/lib/messaging/compiler/workflow-planner.ts +++ b/src/lib/messaging/compiler/workflow-planner.ts @@ -13,6 +13,7 @@ import type { SandboxMessagingPlan, SandboxMessagingRuntimeSetupPlan, } from "../manifest"; +import { planRuntimeSetup } from "./engines/runtime-setup-engine"; import type { RenderTemplateReferenceResolver } from "./engines/template"; import { ManifestCompiler } from "./manifest-compiler"; import type { ManifestCompilerContext, MessagingCompilerCredentialAvailability } from "./types"; @@ -82,14 +83,24 @@ export class MessagingWorkflowPlanner { context: MessagingWorkflowPlannerChannelMutationContext, ): Promise { const plan = await this.planForSandboxEntryMutation(context, "stop-channel"); - return plan ? setPlanChannelDisabled(plan, context.channelId, true, "stop-channel") : null; + return plan + ? refreshRuntimeSetup( + setPlanChannelDisabled(plan, context.channelId, true, "stop-channel"), + this.registry, + ) + : null; } async buildChannelStartPlanFromSandboxEntry( context: MessagingWorkflowPlannerChannelMutationContext, ): Promise { const plan = await this.planForSandboxEntryMutation(context, "start-channel"); - return plan ? setPlanChannelDisabled(plan, context.channelId, false, "start-channel") : null; + return plan + ? refreshRuntimeSetup( + setPlanChannelDisabled(plan, context.channelId, false, "start-channel"), + this.registry, + ) + : null; } async buildChannelRemovePlanFromSandboxEntry( @@ -104,10 +115,13 @@ export class MessagingWorkflowPlanner { ): Promise { const existingPlan = readSandboxEntryPlan(context); if (existingPlan) { - return setPlanDisabledChannels( - existingPlan, - disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), - "rebuild", + return refreshRuntimeSetup( + setPlanDisabledChannels( + existingPlan, + disabledChannelsFromSandboxEntry(context.sandboxEntry, existingPlan), + "rebuild", + ), + this.registry, ); } return null; @@ -413,6 +427,20 @@ function filterRuntimeSetup( }; } +function refreshRuntimeSetup( + plan: SandboxMessagingPlan, + registry: ChannelManifestRegistry, +): SandboxMessagingPlan { + const manifests = plan.channels.flatMap((channel) => { + const manifest = registry.get(channel.channelId); + return manifest ? [manifest] : []; + }); + return clonePlan({ + ...plan, + runtimeSetup: planRuntimeSetup(manifests, plan.agent, plan.channels), + }); +} + function isChannelPlanStartable(channel: SandboxMessagingChannelPlan): boolean { if (!channel.configured) return false; return channel.inputs.every((input) => { diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index de7a256289..f5a6001578 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -95,7 +95,7 @@ export function compactSandboxMessagingPlanForPersistence( const { channels, credentialBindings, - networkPolicy: _networkPolicy, + networkPolicy, agentRender: _agentRender, buildSteps: _buildSteps, runtimeSetup: _runtimeSetup, @@ -105,8 +105,10 @@ export function compactSandboxMessagingPlanForPersistence( } = clonePlan(plan); return { ...rest, + networkPolicy, channels: channels.map((channel) => ({ channelId: channel.channelId, + active: channel.active, configured: channel.configured, disabled: channel.disabled, inputs: channel.inputs diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index f3a07d2d15..b9df76b766 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -100,7 +100,7 @@ describe("parseSandboxMessagingPlan", () => { const compact = compactSandboxMessagingPlanForPersistence(source); const parsed = parseSandboxMessagingPlan(compact); - expect(compact).not.toHaveProperty("networkPolicy"); + expect(compact.networkPolicy).toEqual(source.networkPolicy); expect(compact).not.toHaveProperty("agentRender"); expect(compact).not.toHaveProperty("buildSteps"); expect(compact).not.toHaveProperty("runtimeSetup"); @@ -108,6 +108,7 @@ describe("parseSandboxMessagingPlan", () => { expect(compact).not.toHaveProperty("healthChecks"); expect(compact.channels[0]).toEqual({ channelId: "telegram", + active: true, configured: true, disabled: false, inputs: [ diff --git a/src/lib/onboard/dockerfile-patch.test.ts b/src/lib/onboard/dockerfile-patch.test.ts index 542c52cbd1..0dc72c7750 100644 --- a/src/lib/onboard/dockerfile-patch.test.ts +++ b/src/lib/onboard/dockerfile-patch.test.ts @@ -208,7 +208,27 @@ describe("dockerfile patch helpers", () => { expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=nemoclaw-local"); expect(patched).toContain("ARG NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=0.5"); expect(patched).toContain("ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1"); - assert.deepEqual(readMessagingPlanArg(patched), messagingPlan); + const patchedMessagingPlan = readMessagingPlanArg(patched) as { + channels?: Array<{ channelId?: string; active?: boolean }>; + buildSteps?: unknown; + runtimeSetup?: { + nodePreloads?: Array<{ channelId?: string; module?: string }>; + }; + }; + assert.deepEqual(patchedMessagingPlan.buildSteps, messagingPlan.buildSteps); + assert.deepEqual( + patchedMessagingPlan.channels?.map((channel) => ({ + channelId: channel.channelId, + active: channel.active, + })), + [{ channelId: "telegram", active: true }], + ); + assert.ok( + patchedMessagingPlan.runtimeSetup?.nodePreloads?.some( + (entry) => entry.channelId === "telegram" && entry.module === "telegram-diagnostics", + ), + "expected hydrated Telegram diagnostics preload in Dockerfile messaging plan", + ); }); it("uses the shared sandbox inference mapping", () => { @@ -489,7 +509,30 @@ describe("dockerfile patch helpers", () => { null, ); const patched = fs.readFileSync(dockerfilePath, "utf8"); - assert.deepEqual(readMessagingPlanArg(patched), messagingPlan); + const patchedMessagingPlan = readMessagingPlanArg(patched) as { + channels?: Array<{ channelId?: string; active?: boolean }>; + agentRender?: unknown; + runtimeSetup?: { + nodePreloads?: Array<{ channelId?: string; module?: string }>; + }; + }; + assert.deepEqual(patchedMessagingPlan.agentRender, messagingPlan.agentRender); + assert.deepEqual( + patchedMessagingPlan.channels?.map((channel) => ({ + channelId: channel.channelId, + active: channel.active, + })), + [ + { channelId: "discord", active: true }, + { channelId: "telegram", active: true }, + ], + ); + assert.ok( + patchedMessagingPlan.runtimeSetup?.nodePreloads?.some( + (entry) => entry.channelId === "telegram" && entry.module === "telegram-diagnostics", + ), + "expected hydrated Telegram diagnostics preload in Dockerfile messaging plan", + ); assert.doesNotMatch(patched, /NEMOCLAW_MESSAGING_CHANNELS_B64/); assert.doesNotMatch(patched, /NEMOCLAW_DISCORD_GUILDS_B64/); assert.doesNotMatch(patched, /NEMOCLAW_TELEGRAM_CONFIG_B64/); diff --git a/src/lib/onboard/dockerfile-patch.ts b/src/lib/onboard/dockerfile-patch.ts index cf2e4c1195..7258a4bf1f 100644 --- a/src/lib/onboard/dockerfile-patch.ts +++ b/src/lib/onboard/dockerfile-patch.ts @@ -5,7 +5,8 @@ import fs from "node:fs"; import { getSandboxInferenceConfig } from "../inference/config"; import type { WebSearchConfig } from "../inference/web-search"; -import { MessagingSetupApplier } from "../messaging"; +import { hydrateDerivedSandboxMessagingPlanFields, MessagingSetupApplier } from "../messaging"; +import { parseSandboxMessagingPlan } from "../messaging/plan-validation"; const SANDBOX_BASE_IMAGE = "ghcr.io/nvidia/nemoclaw/sandbox-base"; const PROXY_HOST_RE = /^[A-Za-z0-9._-]+$/; @@ -277,6 +278,9 @@ export function patchStagedDockerfile( ); const messagingPlan = MessagingSetupApplier.readPlanFromEnv(); if (messagingPlan) { + const hydratedMessagingPlan = hydrateDerivedSandboxMessagingPlanFields( + parseSandboxMessagingPlan(messagingPlan) ?? messagingPlan, + ); const messagingPlanArgPattern = /^ARG NEMOCLAW_MESSAGING_PLAN_B64=.*$/m; if (!messagingPlanArgPattern.test(dockerfile)) { throw new Error( @@ -285,7 +289,7 @@ export function patchStagedDockerfile( } dockerfile = dockerfile.replace( messagingPlanArgPattern, - `ARG NEMOCLAW_MESSAGING_PLAN_B64=${sanitizeDockerArg(MessagingSetupApplier.encodePlan(messagingPlan))}`, + `ARG NEMOCLAW_MESSAGING_PLAN_B64=${sanitizeDockerArg(MessagingSetupApplier.encodePlan(hydratedMessagingPlan))}`, ); } if (hermesToolGateways.length > 0) { diff --git a/src/lib/state/onboard-session.test.ts b/src/lib/state/onboard-session.test.ts index 4a7570c79e..b8c2f0318e 100644 --- a/src/lib/state/onboard-session.test.ts +++ b/src/lib/state/onboard-session.test.ts @@ -631,7 +631,7 @@ describe("onboard session", () => { session.saveSession(created); const raw = JSON.parse(fs.readFileSync(session.SESSION_FILE, "utf-8")); - expect(raw.messagingPlan.networkPolicy).toBeUndefined(); + expect(raw.messagingPlan.networkPolicy).toEqual({ presets: [], entries: [] }); expect(raw.messagingPlan.agentRender).toBeUndefined(); expect(raw.messagingPlan.buildSteps).toBeUndefined(); expect(raw.messagingPlan.runtimeSetup).toBeUndefined(); @@ -639,7 +639,7 @@ describe("onboard session", () => { expect(raw.messagingPlan.healthChecks).toBeUndefined(); expect(raw.messagingPlan.channels[0].displayName).toBeUndefined(); expect(raw.messagingPlan.channels[0].authMode).toBeUndefined(); - expect(raw.messagingPlan.channels[0].active).toBeUndefined(); + expect(raw.messagingPlan.channels[0].active).toBe(true); expect(raw.messagingPlan.channels[0].selected).toBeUndefined(); expect(raw.messagingPlan.channels[0].hooks).toBeUndefined(); const reloadedPlan = requireLoadedSession(session.loadSession()).messagingPlan; diff --git a/test/e2e/test-onboard-repair.sh b/test/e2e/test-onboard-repair.sh index c62db5ce00..39d14518ef 100755 --- a/test/e2e/test-onboard-repair.sh +++ b/test/e2e/test-onboard-repair.sh @@ -246,26 +246,26 @@ else exit 1 fi -if echo "$repair_output" | grep -q "\[resume\] Skipping preflight (cached)"; then +if grep -q "\[resume\] Skipping preflight (cached)" <<<"$repair_output"; then pass "Repair resume skipped preflight" else fail "Repair resume did not skip preflight" fi -if echo "$repair_output" | grep -q "\[resume\] Skipping gateway (running)"; then +if grep -q "\[resume\] Skipping gateway (running)" <<<"$repair_output"; then pass "Repair resume skipped gateway" else fail "Repair resume did not skip gateway" fi -if echo "$repair_output" | grep -q "\[resume\] Recorded sandbox state is unavailable; recreating it."; then +if grep -q "\[resume\] Recorded sandbox state is unavailable; recreating it." <<<"$repair_output"; then pass "Repair resume detected missing sandbox" else fail "Repair resume did not report missing sandbox recreation" fi # The step numbering is [6/8] in the current onboard flow. -if echo "$repair_output" | grep -q "Creating sandbox"; then +if grep -q "Creating sandbox" <<<"$repair_output"; then pass "Repair resume recreated sandbox" else fail "Repair resume did not rerun sandbox creation" diff --git a/test/registry.test.ts b/test/registry.test.ts index 2dd9bde76f..34334bd83c 100644 --- a/test/registry.test.ts +++ b/test/registry.test.ts @@ -294,7 +294,10 @@ describe("registry", () => { sandboxName: "messaging", channels: [{ channelId: "telegram" }], }); - expect(data.sandboxes.messaging.messaging.plan.networkPolicy).toBeUndefined(); + expect(data.sandboxes.messaging.messaging.plan.networkPolicy).toEqual({ + presets: [], + entries: [], + }); expect(data.sandboxes.messaging.messaging.plan.agentRender).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.buildSteps).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.runtimeSetup).toBeUndefined(); @@ -302,6 +305,7 @@ describe("registry", () => { expect(data.sandboxes.messaging.messaging.plan.healthChecks).toBeUndefined(); expect(data.sandboxes.messaging.messaging.plan.channels[0]).toEqual({ channelId: "telegram", + active: true, configured: true, disabled: false, inputs: [{ inputId: "botToken", credentialAvailable: true }], From 72ccad92d10da99933f62ba97dffbc501b91e894 Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 14 Jun 2026 22:44:40 +0700 Subject: [PATCH 09/23] fix(messaging): bake runtime setup artifact --- Dockerfile | 7 + scripts/lib/sandbox-init.sh | 45 +++-- scripts/nemoclaw-start.sh | 67 +++++-- .../applier/build/messaging-build-applier.mts | 180 +++++++++++++++++- test/messaging-build-applier.test.ts | 123 ++++++++++++ test/nemoclaw-start-telegram-runtime.test.ts | 62 ++++++ test/sandbox-init.test.ts | 23 +++ 7 files changed, 474 insertions(+), 33 deletions(-) diff --git a/Dockerfile b/Dockerfile index bbdc5a899c..bd85db8e62 100644 --- a/Dockerfile +++ b/Dockerfile @@ -645,6 +645,13 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME=${NEMOCLAW_OPENCLAW_OTEL_SERVICE_NAME} \ NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE=${NEMOCLAW_OPENCLAW_OTEL_SAMPLE_RATE} +# Bake reduced messaging runtime metadata for the entrypoint. The full +# NEMOCLAW_MESSAGING_PLAN_B64 is a build input; OpenShell sandbox create only +# forwards explicit runtime env, so nemoclaw-start reads this generic artifact +# when the env plan is absent. +# hadolint ignore=DL3059 +RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase runtime-setup + WORKDIR /sandbox USER sandbox diff --git a/scripts/lib/sandbox-init.sh b/scripts/lib/sandbox-init.sh index c28241958d..f468ed2649 100755 --- a/scripts/lib/sandbox-init.sh +++ b/scripts/lib/sandbox-init.sh @@ -768,10 +768,9 @@ harden_config_symlinks() { } # ── Messaging channels ────────────────────────────────────────── -# Channel entries are baked into the config at image build time via -# NEMOCLAW_MESSAGING_PLAN_B64 manifest render hooks. Placeholder tokens -# flow through to the L7 proxy for rewriting at egress. Real tokens are -# never visible inside the sandbox. +# Channel entries are baked into the config at image build time via manifest +# render hooks. Placeholder tokens flow through to the L7 proxy for rewriting +# at egress. Real tokens are never visible inside the sandbox. # # This function just logs which channels are active. Runtime patching # of config files is not possible — Landlock enforces read-only at @@ -792,25 +791,47 @@ EOF } read_messaging_plan_channels() { - [ -n "${NEMOCLAW_MESSAGING_PLAN_B64:-}" ] || return 0 python3 - <<'PY' import base64 import json import os -raw = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() -if not raw: - raise SystemExit(0) -try: - plan = json.loads(base64.b64decode(raw).decode("utf-8")) -except Exception: +DEFAULT_ARTIFACT_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json" + + +def read_plan(): + raw = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() + if raw: + try: + return json.loads(base64.b64decode(raw).decode("utf-8")) + except Exception: + raise SystemExit(0) + artifact_path = os.environ.get("NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH", DEFAULT_ARTIFACT_PATH) + if not artifact_path or not os.path.isfile(artifact_path): + raise SystemExit(0) + try: + with open(artifact_path, encoding="utf-8") as handle: + return json.load(handle) + except Exception: + raise SystemExit(0) + + +plan = read_plan() +if not isinstance(plan, dict): raise SystemExit(0) seen = set() +disabled = { + str(channel).strip().lower() + for channel in plan.get("disabledChannels", []) + if isinstance(channel, str) +} for item in plan.get("channels", []): + if not isinstance(item, dict): + continue channel = str(item.get("channelId") or "").strip().lower() if not channel or channel in seen: continue - if item.get("active") is True and item.get("disabled") is not True: + if item.get("active") is True and item.get("disabled") is not True and channel not in disabled: seen.add(channel) print(channel) PY diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 06823cc318..34459d8465 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -1163,6 +1163,7 @@ alias_marker = "-OPENSHELL-RESOLVE-ENV-" env_key_re = re.compile(r"^[A-Z][A-Z0-9_]{0,127}$") revision_re = re.compile(r"^v[0-9]+_") keys = set() +MESSAGING_RUNTIME_PLAN_DEFAULT_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json" def add_key(value): @@ -1194,15 +1195,31 @@ try: except Exception: pass -raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() -if raw_plan: +def read_messaging_plan(): + raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() + if raw_plan: + try: + return json.loads(base64.b64decode(raw_plan).decode("utf-8")) + except Exception: + return None + artifact_path = os.environ.get( + "NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH", + MESSAGING_RUNTIME_PLAN_DEFAULT_PATH, + ) + if not artifact_path or not os.path.isfile(artifact_path): + return None try: - plan = json.loads(base64.b64decode(raw_plan).decode("utf-8")) - for binding in plan.get("credentialBindings", []): - if isinstance(binding, dict) and isinstance(binding.get("providerEnvKey"), str): - add_key(binding["providerEnvKey"]) + with open(artifact_path, encoding="utf-8") as f: + return json.load(f) except Exception: - pass + return None + + +plan = read_messaging_plan() +if isinstance(plan, dict): + for binding in plan.get("credentialBindings", []): + if isinstance(binding, dict) and isinstance(binding.get("providerEnvKey"), str): + add_key(binding["providerEnvKey"]) base_keys = { key @@ -1460,14 +1477,16 @@ PYPLACEHOLDERS } # ── Messaging runtime setup from manifest metadata ─────────────── -# Channel-owned runtime setup is serialized into NEMOCLAW_MESSAGING_PLAN_B64 at -# image build time. The entrypoint only consumes generic declarations: -# envAliases, nodePreloads, and secretScans. +# Channel-owned runtime setup is compiled from manifests at image build time. +# The entrypoint consumes only generic declarations: envAliases, nodePreloads, +# and secretScans. Prefer a forwarded env plan when present; otherwise load the +# reduced image artifact written by the messaging build applier. +_MESSAGING_RUNTIME_PLAN_ARTIFACT="${NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH:-/usr/local/share/nemoclaw/messaging-runtime-plan.json}" _MESSAGING_RUNTIME_SETUP_PLAN="/tmp/nemoclaw-messaging-runtime-setup.json" _MESSAGING_CONNECT_PRELOADS_FILE="/tmp/nemoclaw-messaging-connect-preloads.list" write_messaging_runtime_setup_plan() { - python3 - <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN" + python3 - "$_MESSAGING_RUNTIME_PLAN_ARTIFACT" <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN" import base64 import json import os @@ -1577,15 +1596,27 @@ def clean_secret_scan(entry, index): } -raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() -if not raw_plan: +def load_messaging_plan(): + raw_plan = os.environ.get("NEMOCLAW_MESSAGING_PLAN_B64", "").strip() + if raw_plan: + try: + return json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) + except Exception as exc: + fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") + artifact_path = sys.argv[1] if len(sys.argv) > 1 else "" + if not artifact_path or not os.path.isfile(artifact_path): + return None + try: + with open(artifact_path, encoding="utf-8") as handle: + return json.load(handle) + except Exception as exc: + fail(f"messaging runtime plan artifact {artifact_path} is not valid JSON: {exc}") + + +plan = load_messaging_plan() +if plan is None: print(json.dumps(EMPTY, sort_keys=True)) raise SystemExit(0) - -try: - plan = json.loads(base64.b64decode(raw_plan, validate=True).decode("utf-8")) -except Exception as exc: - fail(f"NEMOCLAW_MESSAGING_PLAN_B64 is not valid base64 JSON: {exc}") if not isinstance(plan, dict): fail("decoded plan must be an object") diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index 36cf5578f6..cd89966b49 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -12,6 +12,7 @@ type Env = Record; type JsonObject = Record; type MessagingAgentId = "openclaw" | "hermes"; type MessagingHookPhase = "agent-install" | "post-agent-install"; +type MessagingRuntimeSetupKey = "nodePreloads" | "envAliases" | "secretScans"; type MessagingSerializableValue = | string | number @@ -77,10 +78,13 @@ export type MessagingBuildPlan = { readonly schemaVersion: 1; readonly sandboxName: string; readonly agent: MessagingAgentId; + readonly workflow?: string; readonly channels: readonly MessagingPlanChannel[]; + readonly disabledChannels?: readonly string[]; readonly credentialBindings: readonly MessagingCredentialBinding[]; readonly agentRender: readonly MessagingRenderEntry[]; readonly buildSteps: readonly MessagingBuildStep[]; + readonly runtimeSetup?: Partial>; }; export type BuildFileOutput = { @@ -92,6 +96,7 @@ export type BuildFileOutput = { export type BuildCommandResult = { readonly channels: readonly string[]; + readonly runtimePlanPath: string; readonly doctorEnv: Record; readonly installSpecs: readonly string[]; readonly openclawVersion: string; @@ -99,6 +104,9 @@ export type BuildCommandResult = { export class MessagingBuildApplierError extends Error {} +export const DEFAULT_MESSAGING_RUNTIME_PLAN_PATH = + "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; + const OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES: Readonly> = { discord: "@openclaw/discord", slack: "@openclaw/slack", @@ -234,6 +242,163 @@ export function activeChannels(plan: MessagingBuildPlan | null): string[] { return channels; } +export function messagingRuntimePlanPath(env: Env = process.env): string { + const configured = env.NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH?.trim(); + return configured || DEFAULT_MESSAGING_RUNTIME_PLAN_PATH; +} + +export function buildMessagingRuntimePlanArtifact( + plan: MessagingBuildPlan | null, +): JsonObject | null { + if (!plan) return null; + return { + schemaVersion: 1, + sandboxName: plan.sandboxName, + agent: plan.agent, + ...(typeof plan.workflow === "string" && plan.workflow ? { workflow: plan.workflow } : {}), + channels: sanitizeRuntimeArtifactChannels(plan.channels), + disabledChannels: sanitizeStringArray(plan.disabledChannels ?? []), + credentialBindings: sanitizeRuntimeArtifactCredentialBindings(plan.credentialBindings), + runtimeSetup: sanitizeRuntimeSetup(plan.runtimeSetup), + }; +} + +export function writeMessagingRuntimePlanArtifact( + plan: MessagingBuildPlan | null, + targetPath: string, +): string | null { + const artifact = buildMessagingRuntimePlanArtifact(plan); + if (!artifact) return null; + mkdirSync(dirname(targetPath), { recursive: true }); + writeFileSync(targetPath, `${JSON.stringify(artifact, null, 2)}\n`); + chmodSync(targetPath, 0o644); + return targetPath; +} + +function sanitizeRuntimeArtifactChannels( + channels: readonly MessagingPlanChannel[], +): readonly JsonObject[] { + return channels.flatMap((channel): JsonObject[] => { + const channelId = sanitizeOptionalString(channel.channelId); + if (!channelId) return []; + return [ + { + channelId, + active: channel.active === true, + disabled: channel.disabled === true, + }, + ]; + }); +} + +function sanitizeRuntimeArtifactCredentialBindings( + bindings: readonly MessagingCredentialBinding[], +): readonly JsonObject[] { + return bindings.flatMap((binding): JsonObject[] => { + const channelId = sanitizeOptionalString(binding.channelId); + const providerEnvKey = sanitizeOptionalString(binding.providerEnvKey); + if (!channelId || !providerEnvKey) return []; + return [{ channelId, providerEnvKey }]; + }); +} + +function sanitizeRuntimeSetup( + setup: MessagingBuildPlan["runtimeSetup"] | undefined, +): Record { + return { + nodePreloads: sanitizeRuntimeSetupEntries(setup?.nodePreloads, [ + "channelId", + "source", + "target", + "injectInto", + "optional", + "installMessage", + "installedMessage", + ]), + envAliases: sanitizeRuntimeSetupEntries(setup?.envAliases, [ + "channelId", + "envKey", + "match", + "value", + "message", + ]), + secretScans: sanitizeRuntimeSetupEntries(setup?.secretScans, [ + "channelId", + "path", + "pattern", + "message", + "exitCode", + ]), + }; +} + +function sanitizeRuntimeSetupEntries( + entries: readonly JsonObject[] | undefined, + allowedKeys: readonly string[], +): readonly JsonObject[] { + if (!Array.isArray(entries)) return []; + return entries.map((entry, index) => { + if (!isObject(entry)) { + throw new MessagingBuildApplierError( + `Messaging runtime setup entry ${index} must be an object`, + ); + } + const channelId = sanitizeOptionalString(entry.channelId); + if (!channelId) { + throw new MessagingBuildApplierError( + `Messaging runtime setup entry ${index} must include channelId`, + ); + } + const sanitized: JsonObject = { channelId }; + for (const key of allowedKeys) { + if (key === "channelId" || entry[key] === undefined) continue; + sanitized[key] = cloneRuntimeArtifactValue(entry[key], `runtime setup entry ${index}.${key}`); + } + return sanitized; + }); +} + +function cloneRuntimeArtifactValue(value: unknown, label: string): MessagingSerializableValue { + if ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return value; + } + if (Array.isArray(value)) { + return value.map((entry, index) => + cloneRuntimeArtifactValue(entry, `${label}[${String(index)}]`), + ); + } + if (isObject(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => { + assertSafeObjectKey(key, label); + return [key, cloneRuntimeArtifactValue(entry, `${label}.${key}`)]; + }), + ); + } + throw new MessagingBuildApplierError(`${label} must be JSON-serializable`); +} + +function sanitizeStringArray(values: readonly unknown[]): readonly string[] { + const seen = new Set(); + const out: string[] = []; + for (const value of values) { + const clean = sanitizeOptionalString(value); + if (!clean || seen.has(clean)) continue; + seen.add(clean); + out.push(clean); + } + return out; +} + +function sanitizeOptionalString(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + export function collectOpenClawMessagingPluginInstallSpecs( plan: MessagingBuildPlan | null, env: Env, @@ -1121,13 +1286,17 @@ function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export type MessagingBuildPhase = "agent-install" | "post-agent-install"; +export type MessagingBuildPhase = "runtime-setup" | "agent-install" | "post-agent-install"; export function applyMessagingBuildPhase( plan: MessagingBuildPlan | null, phase: MessagingBuildPhase, env: Env = process.env, ): readonly string[] { + if (phase === "runtime-setup") { + const target = writeMessagingRuntimePlanArtifact(plan, messagingRuntimePlanPath(env)); + return target ? [target] : []; + } if (phase === "agent-install") { installMessagingPackages(plan, env); return []; @@ -1173,6 +1342,7 @@ export function describeMessagingBuildPhase( agent: plan?.agent ?? "unknown", phase, channels: activeChannels(plan), + runtimePlanPath: phase === "runtime-setup" ? messagingRuntimePlanPath(env) : "", doctorEnv: plan?.agent === "openclaw" ? openClawDoctorEnvOverrides(plan, env) : {}, installSpecs: plan?.agent === "openclaw" ? collectOpenClawMessagingPluginInstallSpecs(plan, env) : [], @@ -1243,8 +1413,12 @@ function readAgentArg(value: string | undefined): MessagingAgentId { } function readPhaseArg(value: string | undefined): MessagingBuildPhase { - if (value === "agent-install" || value === "post-agent-install") return value; - throw new MessagingBuildApplierError("--phase must be 'agent-install' or 'post-agent-install'"); + if (value === "runtime-setup" || value === "agent-install" || value === "post-agent-install") { + return value; + } + throw new MessagingBuildApplierError( + "--phase must be 'runtime-setup', 'agent-install', or 'post-agent-install'", + ); } function isMainModule(): boolean { diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index badb124c0c..f0e4bd0257 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -197,6 +197,129 @@ describe("messaging-build-applier.mts: agent-install", () => { expect(result.stderr).toContain("NEMOCLAW_MESSAGING_PLAN_B64"); }); + it("writes a reduced runtime plan artifact for entrypoint startup", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-plan-artifact-")); + const artifactPath = path.join(tmp, "runtime", "messaging-runtime-plan.json"); + const plan = { + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { + channelId: "telegram", + active: true, + disabled: false, + inputs: [{ value: "do-not-persist-input-value" }], + }, + { channelId: "slack", active: false, disabled: true }, + ], + disabledChannels: ["slack"], + credentialBindings: [ + { + channelId: "telegram", + credentialId: "telegram-bot-token", + providerName: "telegram-provider-name", + providerEnvKey: "TELEGRAM_BOT_TOKEN", + placeholder: "openshell:resolve:env:TELEGRAM_BOT_TOKEN", + credentialHash: "do-not-persist-hash", + }, + ], + agentRender: [ + { + channelId: "telegram", + agent: "openclaw", + target: "openclaw.json", + kind: "json-fragment", + path: "channels.telegram", + value: { token: "do-not-persist-render-value" }, + }, + ], + buildSteps: [ + { + channelId: "telegram", + kind: "build-file", + outputId: "seed-file", + value: { content: "do-not-persist-build-step" }, + }, + ], + runtimeSetup: { + nodePreloads: [ + { + channelId: "telegram", + module: "telegram-diagnostics", + source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", + target: "/tmp/nemoclaw-telegram-diagnostics.js", + injectInto: ["boot", "connect"], + optional: false, + installMessage: "[channels] install telegram diagnostics", + installedMessage: "[channels] installed telegram diagnostics", + }, + ], + envAliases: [], + secretScans: [], + }, + }; + + try { + const result = spawnSync( + "node", + [ + "--experimental-strip-types", + SCRIPT_PATH, + "--agent", + "openclaw", + "--phase", + "runtime-setup", + ], + { + encoding: "utf-8", + stdio: ["pipe", "pipe", "pipe"], + env: { + PATH: process.env.PATH || "/usr/bin:/bin", + NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: artifactPath, + NEMOCLAW_MESSAGING_PLAN_B64: Buffer.from(JSON.stringify(plan)).toString("base64"), + }, + timeout: 10_000, + }, + ); + + expect(result.status, result.stderr).toBe(0); + const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf-8")); + expect(artifact).toMatchObject({ + schemaVersion: 1, + sandboxName: "test-sandbox", + agent: "openclaw", + workflow: "rebuild", + channels: [ + { channelId: "telegram", active: true, disabled: false }, + { channelId: "slack", active: false, disabled: true }, + ], + disabledChannels: ["slack"], + credentialBindings: [{ channelId: "telegram", providerEnvKey: "TELEGRAM_BOT_TOKEN" }], + runtimeSetup: { + nodePreloads: [ + { + channelId: "telegram", + source: "/usr/local/lib/nemoclaw/preloads/telegram-diagnostics.js", + target: "/tmp/nemoclaw-telegram-diagnostics.js", + injectInto: ["boot", "connect"], + optional: false, + }, + ], + envAliases: [], + secretScans: [], + }, + }); + expect(JSON.stringify(artifact)).not.toContain("do-not-persist"); + expect(JSON.stringify(artifact)).not.toContain("openshell:resolve:env"); + expect(artifact.runtimeSetup.nodePreloads[0]).not.toHaveProperty("module"); + expect((fs.statSync(artifactPath).mode & 0o777).toString(8)).toBe("644"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("rejects tampered package-install specs before invoking OpenClaw", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-package-allowlist-")); const tracePath = path.join(tmp, "openclaw.trace"); diff --git a/test/nemoclaw-start-telegram-runtime.test.ts b/test/nemoclaw-start-telegram-runtime.test.ts index 311b552e78..feba408ba5 100644 --- a/test/nemoclaw-start-telegram-runtime.test.ts +++ b/test/nemoclaw-start-telegram-runtime.test.ts @@ -167,4 +167,66 @@ describe("Telegram runtime preload installation", () => { fs.rmSync(tmpDir, { recursive: true, force: true }); } }); + + it("loads Telegram diagnostics from the baked runtime artifact when env plan is absent", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-telegram-artifact-")); + const sourcePrefix = path.join(tmpDir, "preloads") + path.sep; + const sourcePath = path.join(sourcePrefix, "telegram-diagnostics.js"); + const preloadPath = path.join(tmpDir, "telegram-diagnostics.js"); + const planPath = path.join(tmpDir, "runtime-plan.json"); + const artifactPath = path.join(tmpDir, "messaging-runtime-plan.json"); + const connectPreloadsPath = path.join(tmpDir, "connect-preloads.list"); + const scriptPath = path.join(tmpDir, "run.sh"); + fs.mkdirSync(sourcePrefix, { recursive: true }); + fs.copyFileSync(TELEGRAM_RUNTIME_PRELOAD, sourcePath); + const runtimeSetup = { + nodePreloads: [ + { + source: sourcePath, + target: preloadPath, + injectInto: ["boot", "connect"], + optional: false, + installedMessage: "[channels] Telegram diagnostics installed from artifact", + }, + ], + }; + fs.writeFileSync( + artifactPath, + Buffer.from(encodeRuntimeSetupPlan("telegram", runtimeSetup), "base64").toString("utf-8"), + ); + fs.writeFileSync( + scriptPath, + [ + "#!/usr/bin/env bash", + "set -euo pipefail", + 'id() { if [ "${1:-}" = "-u" ]; then printf "1000"; else command id "$@"; fi; }', + 'emit_sandbox_sourced_file() { local target="$1"; cat > "$target"; chmod 444 "$target"; }', + "NODE_OPTIONS='--require /already-loaded.js'", + `export NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH=${JSON.stringify(artifactPath)}`, + "unset NEMOCLAW_MESSAGING_PLAN_B64 || true", + messagingRuntimeSetupSection(src, { + planPath, + connectPreloadsPath, + sourcePrefix, + targetPrefix: tmpDir + path.sep, + }), + "write_messaging_runtime_setup_plan", + "install_messaging_runtime_preloads", + 'printf "NODE_OPTIONS=%s\\n" "$NODE_OPTIONS"', + ].join("\n"), + { mode: 0o700 }, + ); + + try { + const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + expect(result.status, result.stderr).toBe(0); + expect(fs.existsSync(preloadPath)).toBe(true); + expect(result.stdout).toContain("--require /already-loaded.js"); + expect(result.stdout).toContain(`--require ${preloadPath}`); + expect(result.stderr).toContain("Telegram diagnostics installed from artifact"); + expect(fs.readFileSync(connectPreloadsPath, "utf-8")).toContain(preloadPath); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); }); diff --git a/test/sandbox-init.test.ts b/test/sandbox-init.test.ts index cdd3e89b12..6422013c9c 100644 --- a/test/sandbox-init.test.ts +++ b/test/sandbox-init.test.ts @@ -881,6 +881,29 @@ EOF expect(stdout).toContain("slack"); expect(stdout).not.toContain("discord"); }); + + it("logs active channels from the baked runtime artifact when env plan is absent", () => { + const workDir = mkdtempSync(join(tmpdir(), "nemoclaw-messaging-artifact-log-")); + const artifactPath = join(workDir, "messaging-runtime-plan.json"); + writeFileSync( + artifactPath, + Buffer.from(messagingPlanEnv(["telegram", "whatsapp"]), "base64").toString("utf-8"), + ); + + try { + const { stdout } = runWithLib("configure_messaging_channels 2>&1", { + env: { + NEMOCLAW_MESSAGING_PLAN_B64: "", + NEMOCLAW_MESSAGING_RUNTIME_PLAN_PATH: artifactPath, + }, + }); + expect(stdout).toContain("telegram"); + expect(stdout).toContain("whatsapp"); + expect(stdout).not.toContain("discord"); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); }); describe("cleanup_on_signal", () => { From c02fedb0423d7407cd7d26f3b97116795c13840f Mon Sep 17 00:00:00 2001 From: San Dang Date: Sun, 14 Jun 2026 23:20:18 +0700 Subject: [PATCH 10/23] test(e2e): align WhatsApp preload guard assertion --- test/e2e/test-messaging-providers.sh | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 8ce252a890..4c7bef3d06 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -956,17 +956,19 @@ else fail "M-WA6b: WhatsApp compact-QR preload has unexpected owner/mode: ${whatsapp_qr_preload_stat} (entrypoint start log: ${entrypoint_start_log_stat}) (#4522)" fi -# Assert on the actual NODE_OPTIONS injection line, not just the filename: the -# filename also appears in the install banner and the literal path assignment, -# so a filename-only grep would still pass if the `--require` wiring regressed. -# The guard body is emitted inside a single-quoted heredoc, so the proxy-env -# file contains the literal token `--require $_whatsapp_qr_compact`. Escape `$` -# so the host shell does not expand it before sandbox_exec ships the command. -whatsapp_qr_guard_wiring=$(sandbox_exec "grep -cF -- '--require \$_whatsapp_qr_compact' /tmp/nemoclaw-proxy-env.sh 2>/dev/null || echo 0") -if [ "${whatsapp_qr_guard_wiring:-0}" -ge 1 ] 2>/dev/null; then - pass "M-WA6c: openclaw() guard injects compact-QR preload via NODE_OPTIONS for WhatsApp login (#4522)" -else - fail "M-WA6c: openclaw() guard missing compact-QR preload --require injection for WhatsApp login (#4522)" +# Assert on the generic manifest-runtime wiring, not just the filename: the +# filename also appears in install banners and path assignments. After the +# messaging manifest migration, WhatsApp contributes a connect preload entry +# and the shared openclaw() guard reads that list for WhatsApp login. +whatsapp_qr_connect_list=$(sandbox_exec "grep -cFx -- '/tmp/nemoclaw-whatsapp-qr-compact.js' /tmp/nemoclaw-messaging-connect-preloads.list 2>/dev/null || echo 0") +whatsapp_qr_connect_export=$(sandbox_exec "grep -cF -- '--require \$_nemoclaw_preload' /tmp/nemoclaw-proxy-env.sh 2>/dev/null || echo 0") +whatsapp_qr_guard_wiring=$(sandbox_exec "grep -cF -- '_nemoclaw_messaging_connect_node_options' /tmp/nemoclaw-proxy-env.sh 2>/dev/null || echo 0") +if [ "${whatsapp_qr_connect_list:-0}" -ge 1 ] 2>/dev/null \ + && [ "${whatsapp_qr_connect_export:-0}" -ge 1 ] 2>/dev/null \ + && [ "${whatsapp_qr_guard_wiring:-0}" -ge 1 ] 2>/dev/null; then + pass "M-WA6c: openclaw() guard injects manifest connect preloads for WhatsApp login (#4522)" +else + fail "M-WA6c: openclaw() guard missing manifest connect preload injection for WhatsApp login (#4522)" fi # M-WA6d: Prove the rendered QR SIZE in the real sandbox, not just that the From 75075b0f34d1fa441cfaf75c34f7099c4cc7c56c Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 00:10:52 +0700 Subject: [PATCH 11/23] refactor(messaging): move Slack deny feedback into runtime preload --- Dockerfile | 19 +- .../patch-openclaw-slack-deny-feedback.mts | 243 ------------------ .../slack/runtime/slack-channel-guard.js | 131 ++++++++++ src/lib/sandbox/build-context.ts | 4 - ...openclaw-slack-deny-feedback-patch.test.ts | 219 +++++++++------- test/sandbox-build-context.test.ts | 4 - 6 files changed, 251 insertions(+), 369 deletions(-) delete mode 100755 scripts/patch-openclaw-slack-deny-feedback.mts diff --git a/Dockerfile b/Dockerfile index bd85db8e62..ed6c2cefef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -92,10 +92,8 @@ ENV NPM_CONFIG_AUDIT=false \ RUN npm ci --omit=dev COPY scripts/patch-openclaw-tool-catalog.js /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js COPY scripts/patch-openclaw-chat-send.js /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js -COPY scripts/patch-openclaw-slack-deny-feedback.mts /usr/local/lib/nemoclaw/patch-openclaw-slack-deny-feedback.mts RUN chmod 755 /usr/local/lib/nemoclaw/patch-openclaw-tool-catalog.js \ - /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js \ - /usr/local/lib/nemoclaw/patch-openclaw-slack-deny-feedback.mts + /usr/local/lib/nemoclaw/patch-openclaw-chat-send.js # Upgrade OpenClaw if the base image is stale. # @@ -695,21 +693,6 @@ RUN set -eu; \ # hadolint ignore=DL3059,DL4006 RUN node --experimental-strip-types /src/lib/messaging/applier/build/messaging-build-applier.mts --agent openclaw --phase agent-install -# Patch the OpenClaw Slack channel (@openclaw/slack) so a denied explicit -# @-mention still blocks the command but sends one bounded sender-facing -# feedback message instead of dropping silently (NemoClaw #4752). The script -# classifies the installed Slack dist by content signature, fails the build if -# a @openclaw/slack package is present but the deny path shape is unrecognized, -# and is a no-op when the Slack channel is not enabled for this image. -# Scoped to the sandbox-writable OpenClaw config dir: `openclaw plugins install` -# stages external channel packages under $HOME/.openclaw/npm, and this step runs -# as the sandbox user, so do not scan the root-owned global node_modules tree. -# Removal criteria: drop when upstream OpenClaw notifies the sender on a denied -# explicit Slack @-mention, or when NemoClaw no longer ships @openclaw/slack. -# hadolint ignore=DL3059 -RUN node --experimental-strip-types /usr/local/lib/nemoclaw/patch-openclaw-slack-deny-feedback.mts \ - /sandbox/.openclaw - # Lock down npm for the next RUN: the local OpenClaw plugin install must # resolve from /opt/nemoclaw and the staged plugin-runtime-deps tree without # touching the registry. Reset to false after that RUN so the runtime image diff --git a/scripts/patch-openclaw-slack-deny-feedback.mts b/scripts/patch-openclaw-slack-deny-feedback.mts deleted file mode 100755 index edca0b9e6d..0000000000 --- a/scripts/patch-openclaw-slack-deny-feedback.mts +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/* - * NemoClaw compatibility shim for the OpenClaw Slack channel (@openclaw/slack). - * - * When a non-allowlisted human explicitly @-mentions the bot in a channel, - * OpenClaw blocks the command (correct) but drops the event silently, leaving - * the sender with no indication the bot saw the mention (NemoClaw #4752). - * - * This patch keeps the command denied (still returns no prepared command) but - * adds exactly one bounded, sender-facing feedback message — an ephemeral reply - * in the mentioned channel, falling back to a DM — without revealing the - * configured allowlist or processing the command text. - * - * The patch classifies the compiled @openclaw/slack dist by content signature. - * It fails loudly when a @openclaw/slack package is present but the deny path - * shape is unrecognized, and is a no-op when @openclaw/slack is not installed - * (e.g. a sandbox image built without the Slack channel enabled). - * - * Removal criteria: drop when upstream OpenClaw notifies the sender on a denied - * explicit Slack @-mention, or when NemoClaw no longer ships @openclaw/slack. - * - * Usage: patch-openclaw-slack-deny-feedback.mts [...] - * Each is scanned (bounded depth) for installed @openclaw/slack - * packages; the OpenClaw runtime dirs (HOME/.openclaw, npm global root) are - * the expected roots. - */ - -import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; -import { basename, join, relative, resolve } from "node:path"; - -const HELPER_MARKER = "__nemoclawNotifyDeniedSlackMention"; -const CALL_MARKER = "nemoclaw: bounded denial feedback for explicit slack @-mentions"; -const DENY_LOG_SIGNATURE = "Blocked unauthorized slack sender"; -const MAX_SCAN_DEPTH = 12; - -const roots = process.argv.slice(2); -if (roots.length === 0) { - console.error("Usage: patch-openclaw-slack-deny-feedback.mts [...]"); - process.exit(2); -} - -function fail(message: string): never { - console.error(`ERROR: ${message}`); - process.exit(1); -} - -function readJsonSafe(file: string): { name?: string } | null { - try { - return JSON.parse(readFileSync(file, "utf8")) as { name?: string }; - } catch { - return null; - } -} - -// Locate installed @openclaw/slack package roots under the given search roots. -function findSlackPackageRoots(searchRoots: string[]): string[] { - const found = new Set(); - const visited = new Set(); - const visit = (dir: string, depth: number): void => { - if (depth > MAX_SCAN_DEPTH) return; - let real: string; - try { - real = realpathSync(dir); - } catch { - return; - } - if (visited.has(real)) return; - visited.add(real); - - const manifest = join(dir, "package.json"); - if (existsSync(manifest)) { - const parsed = readJsonSafe(manifest); - if (parsed && parsed.name === "@openclaw/slack") { - found.add(dir); - return; // do not descend into a matched package - } - } - - let entries: import("node:fs").Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return; - } - for (const entry of entries) { - if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; - visit(join(dir, entry.name), depth + 1); - } - }; - for (const root of searchRoots) { - if (existsSync(root)) visit(resolve(root), 0); - } - return [...found]; -} - -function listJsFiles(dir: string): string[] { - let entries: import("node:fs").Dirent[]; - try { - entries = readdirSync(dir, { withFileTypes: true }); - } catch { - return []; - } - return entries - .filter((entry) => entry.isFile() && entry.name.endsWith(".js")) - .map((entry) => join(dir, entry.name)); -} - -function locatePrepareModule(packageRoot: string): string { - const distDir = join(packageRoot, "dist"); - const candidates = listJsFiles(distDir).filter((file) => { - const source = readFileSync(file, "utf8"); - return ( - source.includes("async function prepareSlackMessage") && source.includes(DENY_LOG_SIGNATURE) - ); - }); - if (candidates.length !== 1) { - fail( - `expected exactly one OpenClaw Slack prepare module under ${distDir}, found ${candidates.length}; ` + - "inspect the @openclaw/slack dist and update this patch for the new layout", - ); - } - return candidates[0]; -} - -// Sender-facing feedback helper injected into the prepare module. Indented with -// tabs to match the compiled OpenClaw dist. -function buildHelperSource(): string { - return [ - "async function __nemoclawNotifyDeniedSlackMention(params) {", - "\t// nemoclaw: bounded sender-facing feedback for an explicit @-mention whose", - "\t// command was denied by the channel allowlist. Keeps the command blocked,", - "\t// never reveals the allowlist, and emits exactly one sender-facing message", - "\t// (ephemeral in-channel, DM fallback). (#4752)", - "\tconst { ctx, message, senderId } = params;", - "\tif (!params.explicitMention) return;", - "\tconst client = ctx?.app?.client;", - "\tconst channel = message?.channel;", - "\tconst user = senderId ?? message?.user;", - "\tif (!client?.chat || !channel || !user) return;", - '\tconst text = "Sorry, you\'re not authorized to use this assistant in this channel, so your request was not processed.";', - "\tconst threadTs = message?.thread_ts ?? message?.ts;", - "\ttry {", - "\t\tawait client.chat.postEphemeral({ channel, user, text, ...threadTs ? { thread_ts: threadTs } : {} });", - "\t\treturn;", - "\t} catch (ephemeralError) {", - "\t\t// Only fall back to a DM when Slack definitively did not deliver the", - "\t\t// ephemeral. Ambiguous failures (network/HTTP, timeout, service errors)", - "\t\t// may have been accepted, so a DM there could double-notify the sender.", - "\t\tconst ephemeralErrorCode = ephemeralError?.data?.error ?? ephemeralError?.code;", - '\t\tctx?.logger?.warn?.({ err: ephemeralError, channel, code: ephemeralErrorCode }, "nemoclaw: slack denial ephemeral feedback failed (#4752)");', - '\t\tconst nonDeliveryCodes = ["user_not_in_channel", "not_in_channel", "channel_not_found", "cannot_reply_to_message", "is_archived", "messages_tab_disabled"];', - "\t\tif (!nonDeliveryCodes.includes(ephemeralErrorCode)) return;", - "\t\ttry {", - "\t\t\tconst opened = await client.conversations?.open?.({ users: user });", - "\t\t\tconst dmChannel = opened?.channel?.id;", - "\t\t\tif (dmChannel) await client.chat.postMessage({ channel: dmChannel, text });", - "\t\t} catch (dmError) {", - '\t\t\tctx?.logger?.warn?.({ err: dmError }, "nemoclaw: slack denial DM feedback failed (#4752)");', - "\t\t}", - "\t}", - "}", - "", - ].join("\n"); -} - -function patchPrepareModule(file: string): boolean { - let source = readFileSync(file, "utf8"); - const original = source; - - // The denial feedback only fires for explicit bot mentions. Require the - // mention-state identifiers so the patch fails loudly if the deny path no - // longer exposes them, rather than emitting code that references undefined - // variables. - if ( - !source.includes("explicitlyMentionedBotUser") || - !source.includes("explicitlyMentionedBotSubteam") - ) { - fail( - `OpenClaw Slack mention-state shape not recognized in ${file}; ` + - "expected explicitlyMentionedBotUser/explicitlyMentionedBotSubteam in the prepare deny path", - ); - } - - if (!source.includes(CALL_MARKER)) { - const denyGate = new RegExp( - "(logVerbose\\(`Blocked unauthorized slack sender \\$\\{senderId\\} \\(not in channel users\\)`\\);\\n)(\\s*)return null;", - ); - const next = source.replace( - denyGate, - (_match, logLine: string, indent: string) => - `${logLine}${indent}await __nemoclawNotifyDeniedSlackMention({ ctx, message, senderId, ` + - 'explicitMention: opts.source === "app_mention" || explicitlyMentionedBotUser || explicitlyMentionedBotSubteam }); ' + - `// ${CALL_MARKER} (#4752)\n${indent}return null;`, - ); - if (next === source) { - fail(`OpenClaw Slack channel-users deny gate shape not recognized in ${file}`); - } - source = next; - } - - if (!source.includes(`async function ${HELPER_MARKER}(`)) { - const anchor = "async function prepareSlackMessage(params) {"; - if (!source.includes(anchor)) { - fail(`OpenClaw Slack prepareSlackMessage definition not found in ${file}`); - } - source = source.replace(anchor, `${buildHelperSource()}${anchor}`); - } - - if (source !== original) { - writeFileSync(file, source); - return true; - } - return false; -} - -const packageRoots = findSlackPackageRoots(roots); -if (packageRoots.length === 0) { - console.log( - `INFO: no @openclaw/slack package found under ${roots.join(", ")}; skipping Slack denial-feedback patch`, - ); - process.exit(0); -} - -const patchedFiles: string[] = []; -for (const packageRoot of packageRoots) { - const prepareFile = locatePrepareModule(packageRoot); - patchPrepareModule(prepareFile); - const patched = readFileSync(prepareFile, "utf8"); - if (!patched.includes(`async function ${HELPER_MARKER}(`)) { - fail(`Slack denial-feedback helper did not apply in ${prepareFile}`); - } - if (!patched.includes(CALL_MARKER)) { - fail(`Slack denial-feedback deny-gate call did not apply in ${prepareFile}`); - } - patchedFiles.push(prepareFile); -} - -console.log( - `INFO: patched OpenClaw Slack denial feedback in ${patchedFiles.map((file) => relative(process.cwd(), file)).join(", ")}`, -); diff --git a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js index 1ab1cff9d9..c02f3ec538 100644 --- a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js +++ b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js @@ -12,11 +12,22 @@ // provider startup failure as fatal. Non-Slack failures pass through to the // original event machinery unchanged. // +// It also patches @openclaw/slack at module-load time so a denied explicit +// @-mention still blocks the command but sends one bounded sender-facing +// feedback message. Keeping this in the Slack runtime preload lets the channel +// manifest own the behavior through runtime.nodePreloads instead of Dockerfile +// channel-specific patch commands. +// // Ref: https://github.com/NVIDIA/NemoClaw/issues/2340 +// Ref: https://github.com/NVIDIA/NemoClaw/issues/4752 (function () { 'use strict'; + var HELPER_MARKER = '__nemoclawNotifyDeniedSlackMention'; + var CALL_MARKER = 'nemoclaw: bounded denial feedback for explicit slack @-mentions'; + var DENY_LOG_SIGNATURE = 'Blocked unauthorized slack sender'; + // Slack-specific error codes from @slack/web-api that indicate auth failure. // These appear as error.code on the WebAPIRequestError or CodedError objects. var SLACK_AUTH_ERRORS = [ @@ -54,6 +65,7 @@ function isSlackRejection(reason) { if (!reason) return false; + if (isSlackDenyFeedbackPatchError(reason)) return false; // Check error code (Slack SDK sets .code on its errors) var code = reason.code || ''; @@ -85,6 +97,14 @@ return false; } + function isSlackDenyFeedbackPatchError(reason) { + var msg = String((reason && reason.message) || reason || ''); + return msg.indexOf('OpenClaw Slack ') !== -1 && ( + msg.indexOf('shape not recognized') !== -1 || + msg.indexOf('prepareSlackMessage definition not found') !== -1 + ); + } + function handleSlackError(reason, source) { if (isSlackRejection(reason)) { var msg = (reason && reason.message) ? reason.message : String(reason); @@ -104,6 +124,117 @@ process.__nemoclawSlackChannelGuardInstalled = true; } + function buildDeniedMentionFeedbackHelperSource() { + return [ + 'async function __nemoclawNotifyDeniedSlackMention(params) {', + '\t// nemoclaw: bounded sender-facing feedback for an explicit @-mention whose', + '\t// command was denied by the channel allowlist. Keeps the command blocked,', + '\t// never reveals the allowlist, and emits exactly one sender-facing message', + '\t// (ephemeral in-channel, DM fallback). (#4752)', + '\tconst { ctx, message, senderId } = params;', + '\tif (!params.explicitMention) return;', + '\tconst client = ctx?.app?.client;', + '\tconst channel = message?.channel;', + '\tconst user = senderId ?? message?.user;', + '\tif (!client?.chat || !channel || !user) return;', + '\tconst text = "Sorry, you\\\'re not authorized to use this assistant in this channel, so your request was not processed.";', + '\tconst threadTs = message?.thread_ts ?? message?.ts;', + '\ttry {', + '\t\tawait client.chat.postEphemeral({ channel, user, text, ...threadTs ? { thread_ts: threadTs } : {} });', + '\t\treturn;', + '\t} catch (ephemeralError) {', + '\t\t// Only fall back to a DM when Slack definitively did not deliver the', + '\t\t// ephemeral. Ambiguous failures (network/HTTP, timeout, service errors)', + '\t\t// may have been accepted, so a DM there could double-notify the sender.', + '\t\tconst ephemeralErrorCode = ephemeralError?.data?.error ?? ephemeralError?.code;', + '\t\tctx?.logger?.warn?.({ err: ephemeralError, channel, code: ephemeralErrorCode }, "nemoclaw: slack denial ephemeral feedback failed (#4752)");', + '\t\tconst nonDeliveryCodes = ["user_not_in_channel", "not_in_channel", "channel_not_found", "cannot_reply_to_message", "is_archived", "messages_tab_disabled"];', + '\t\tif (!nonDeliveryCodes.includes(ephemeralErrorCode)) return;', + '\t\ttry {', + '\t\t\tconst opened = await client.conversations?.open?.({ users: user });', + '\t\t\tconst dmChannel = opened?.channel?.id;', + '\t\t\tif (dmChannel) await client.chat.postMessage({ channel: dmChannel, text });', + '\t\t} catch (dmError) {', + '\t\t\tctx?.logger?.warn?.({ err: dmError }, "nemoclaw: slack denial DM feedback failed (#4752)");', + '\t\t}', + '\t}', + '}', + '', + ].join('\n'); + } + + function isOpenClawSlackFile(filename) { + var normalized = String(filename || '').replace(/\\/g, '/'); + return normalized.indexOf('/@openclaw/slack/') !== -1 && normalized.endsWith('.js'); + } + + function patchSlackPrepareSource(source, filename) { + if (source.indexOf('async function prepareSlackMessage') === -1) return source; + if (source.indexOf('async function ' + HELPER_MARKER + '(') !== -1 && + source.indexOf(CALL_MARKER) !== -1) { + return source; + } + if (source.indexOf(DENY_LOG_SIGNATURE) === -1) { + throw new Error( + 'OpenClaw Slack prepare module shape not recognized in ' + filename + + '; expected denied-sender log signature' + ); + } + if (source.indexOf('explicitlyMentionedBotUser') === -1 || + source.indexOf('explicitlyMentionedBotSubteam') === -1) { + throw new Error( + 'OpenClaw Slack mention-state shape not recognized in ' + filename + + '; expected explicitlyMentionedBotUser/explicitlyMentionedBotSubteam in the prepare deny path' + ); + } + + var next = source; + if (next.indexOf(CALL_MARKER) === -1) { + next = next.replace( + /(logVerbose\(`Blocked unauthorized slack sender \$\{senderId\} \(not in channel users\)`\);\n)(\s*)return null;/, + function (_match, logLine, indent) { + return logLine + indent + + 'await __nemoclawNotifyDeniedSlackMention({ ctx, message, senderId, ' + + 'explicitMention: opts.source === "app_mention" || explicitlyMentionedBotUser || explicitlyMentionedBotSubteam }); ' + + '// ' + CALL_MARKER + ' (#4752)\n' + + indent + 'return null;'; + } + ); + if (next === source) { + throw new Error('OpenClaw Slack channel-users deny gate shape not recognized in ' + filename); + } + } + + if (next.indexOf('async function ' + HELPER_MARKER + '(') === -1) { + var anchor = 'async function prepareSlackMessage(params) {'; + if (next.indexOf(anchor) === -1) { + throw new Error('OpenClaw Slack prepareSlackMessage definition not found in ' + filename); + } + next = next.replace(anchor, buildDeniedMentionFeedbackHelperSource() + anchor); + } + return next; + } + + function installSlackDenyFeedbackPatch() { + var Module = require('module'); + var fs = require('fs'); + var originalJsLoader = Module._extensions && Module._extensions['.js']; + if (typeof originalJsLoader !== 'function') return; + + Module._extensions['.js'] = function nemoclawSlackJsLoader(mod, filename) { + if (isOpenClawSlackFile(filename)) { + var source = fs.readFileSync(filename, 'utf8'); + var patched = patchSlackPrepareSource(source, filename); + if (patched !== source) { + return mod._compile(patched, filename); + } + } + return originalJsLoader.apply(this, arguments); + }; + } + + installSlackDenyFeedbackPatch(); + var origEmit = process.emit; process.emit = function (eventName) { if (eventName === 'unhandledRejection') { diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index cb681864f8..cea2d7ff35 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -161,10 +161,6 @@ function stageOptimizedSandboxBuildContext( path.join(rootDir, "scripts", "patch-openclaw-chat-send.js"), path.join(stagedScriptsDir, "patch-openclaw-chat-send.js"), ); - fs.copyFileSync( - path.join(rootDir, "scripts", "patch-openclaw-slack-deny-feedback.mts"), - path.join(stagedScriptsDir, "patch-openclaw-slack-deny-feedback.mts"), - ); return { buildCtx, stagedDockerfile }; } diff --git a/test/openclaw-slack-deny-feedback-patch.test.ts b/test/openclaw-slack-deny-feedback-patch.test.ts index 32297fa7f2..80700a420a 100644 --- a/test/openclaw-slack-deny-feedback-patch.test.ts +++ b/test/openclaw-slack-deny-feedback-patch.test.ts @@ -5,14 +5,18 @@ import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import vm from "node:vm"; import { describe, expect, it } from "vitest"; -const PATCH_SCRIPT = path.join( +const SLACK_GUARD = path.join( import.meta.dirname, "..", - "scripts", - "patch-openclaw-slack-deny-feedback.mts", + "src", + "lib", + "messaging", + "channels", + "slack", + "runtime", + "slack-channel-guard.js", ); // Minimal stand-in for the compiled @openclaw/slack prepare module: a denying @@ -45,6 +49,7 @@ function prepareModuleSource( "\t}", "\treturn { prepared: true };", "}", + "module.exports = { prepareSlackMessage };", "", ].join("\n"); } @@ -65,52 +70,40 @@ function writeSlackPackage( return prepareFile; } -function runPatch(...roots: string[]) { - return spawnSync(process.execPath, ["--experimental-strip-types", PATCH_SCRIPT, ...roots], { - encoding: "utf-8", - timeout: 10000, - }); -} - type FeedbackCall = { method: string; channel?: string; user?: string; text?: string }; -async function runPatchedDenyPath( - patchedSource: string, - params: { - opts: { source?: string }; - explicitlyMentionedBotUser?: boolean; - explicitlyMentionedBotSubteam?: boolean; - ephemeralErrorCode?: string; - }, -) { - const calls: FeedbackCall[] = []; - const client = { - chat: { - postEphemeral: async (payload: Omit) => { - calls.push({ ...payload, method: "chat.postEphemeral" }); - if (params.ephemeralErrorCode) { - throw Object.assign(new Error("postEphemeral failed"), { - data: { error: params.ephemeralErrorCode }, - }); - } - return { ok: true }; - }, - postMessage: async (payload: Omit) => { - calls.push({ ...payload, method: "chat.postMessage" }); - return { ok: true }; - }, +function runGuardProbe(prepareFile: string, options: { requireGuardTwice?: boolean } = {}) { + const script = ` +const guard = ${JSON.stringify(SLACK_GUARD)}; +require(guard); +${options.requireGuardTwice ? "require(guard);" : ""} +const { prepareSlackMessage } = require(process.env.PREPARE_FILE); +const calls = []; +const client = { + chat: { + postEphemeral: async (payload) => { + calls.push({ ...payload, method: "chat.postEphemeral" }); + if (globalThis.ephemeralErrorCode) { + throw Object.assign(new Error("postEphemeral failed"), { + data: { error: globalThis.ephemeralErrorCode }, + }); + } + return { ok: true }; }, - conversations: { - open: async ({ users }: { users: string }) => ({ ok: true, channel: { id: `D${users}` } }), + postMessage: async (payload) => { + calls.push({ ...payload, method: "chat.postMessage" }); + return { ok: true }; }, - }; - const ctx = { app: { client }, logger: { warn: () => {} } }; - const message = { channel: "C1", user: "U999DENIED", ts: "100.1" }; - const sandbox: Record = { Boolean, Promise, JSON, Object }; - const prepareSlackMessage = vm.runInNewContext( - `${patchedSource}\nprepareSlackMessage;`, - sandbox, - ) as (input: unknown) => Promise; + }, + conversations: { + open: async ({ users }) => ({ ok: true, channel: { id: "D" + users } }), + }, +}; +const ctx = { app: { client }, logger: { warn: () => {} } }; +const message = { channel: "C1", user: "U999DENIED", ts: "100.1" }; +async function run(params) { + calls.length = 0; + globalThis.ephemeralErrorCode = params.ephemeralErrorCode; const result = await prepareSlackMessage({ ctx, account: {}, @@ -119,26 +112,68 @@ async function runPatchedDenyPath( explicitlyMentionedBotUser: params.explicitlyMentionedBotUser, explicitlyMentionedBotSubteam: params.explicitlyMentionedBotSubteam, }); - return { result, calls }; + return { result, calls: calls.slice() }; +} +(async () => { + const output = { + mention: await run({ opts: { source: "app_mention" } }), + silent: await run({ opts: { source: "message" } }), + explicitUser: await run({ opts: { source: "message" }, explicitlyMentionedBotUser: true }), + explicitSubteam: await run({ opts: { source: "message" }, explicitlyMentionedBotSubteam: true }), + fallback: await run({ opts: { source: "app_mention" }, ephemeralErrorCode: "user_not_in_channel" }), + ambiguous: await run({ opts: { source: "app_mention" }, ephemeralErrorCode: "service_unavailable" }), + }; + console.log(JSON.stringify(output)); +})().catch((error) => { + console.error(error && error.stack ? error.stack : String(error)); + process.exit(1); +}); +`; + const result = spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + env: { + ...process.env, + PREPARE_FILE: prepareFile, + }, + timeout: 10000, + }); + return { + result, + output: + result.status === 0 && result.stdout.trim() + ? (JSON.parse(result.stdout) as Record) + : null, + }; +} + +function runGuardRequire(prepareFile?: string) { + const script = [ + `require(${JSON.stringify(SLACK_GUARD)});`, + prepareFile ? "require(process.env.PREPARE_FILE);" : "", + ].join("\n"); + return spawnSync(process.execPath, ["-e", script], { + encoding: "utf-8", + env: { + ...process.env, + ...(prepareFile ? { PREPARE_FILE: prepareFile } : {}), + }, + timeout: 10000, + }); } describe("OpenClaw Slack denial-feedback patch", () => { - it("injects bounded sender feedback while keeping the command denied", async () => { + it("injects bounded sender feedback at runtime while keeping the command denied", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-")); const prepareFile = writeSlackPackage(tmp); try { - const patch = runPatch(tmp); - expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); - expect(patch.stdout).toContain("patched OpenClaw Slack denial feedback"); - - const patched = fs.readFileSync(prepareFile, "utf-8"); - expect(patched).toContain("async function __nemoclawNotifyDeniedSlackMention("); - expect(patched).toContain( - "nemoclaw: bounded denial feedback for explicit slack @-mentions (#4752)", + const { result, output } = runGuardProbe(prepareFile); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(prepareFile, "utf-8")).not.toContain( + "__nemoclawNotifyDeniedSlackMention", ); // Denied explicit @-mention: command stays denied, exactly one ephemeral feedback. - const mention = await runPatchedDenyPath(patched, { opts: { source: "app_mention" } }); + const mention = output?.mention as { result: unknown; calls: FeedbackCall[] }; expect(mention.result).toBeNull(); expect(mention.calls).toHaveLength(1); expect(mention.calls[0]).toMatchObject({ @@ -151,32 +186,23 @@ describe("OpenClaw Slack denial-feedback patch", () => { expect(mention.calls[0].text?.toLowerCase()).not.toContain("allowlist"); // Denied non-mention: no sender feedback (stays silent, as before). - const silent = await runPatchedDenyPath(patched, { opts: { source: "message" } }); + const silent = output?.silent as { result: unknown; calls: FeedbackCall[] }; expect(silent.result).toBeNull(); expect(silent.calls).toHaveLength(0); // Explicit bot mention on a non-app_mention event also triggers feedback. - const explicitUser = await runPatchedDenyPath(patched, { - opts: { source: "message" }, - explicitlyMentionedBotUser: true, - }); + const explicitUser = output?.explicitUser as { result: unknown; calls: FeedbackCall[] }; expect(explicitUser.result).toBeNull(); expect(explicitUser.calls).toHaveLength(1); expect(explicitUser.calls[0].method).toBe("chat.postEphemeral"); - const explicitSubteam = await runPatchedDenyPath(patched, { - opts: { source: "message" }, - explicitlyMentionedBotSubteam: true, - }); + const explicitSubteam = output?.explicitSubteam as { result: unknown; calls: FeedbackCall[] }; expect(explicitSubteam.result).toBeNull(); expect(explicitSubteam.calls).toHaveLength(1); expect(explicitSubteam.calls[0].method).toBe("chat.postEphemeral"); // Definitive non-delivery (user_not_in_channel) falls back to a DM. - const fallback = await runPatchedDenyPath(patched, { - opts: { source: "app_mention" }, - ephemeralErrorCode: "user_not_in_channel", - }); + const fallback = output?.fallback as { result: unknown; calls: FeedbackCall[] }; expect(fallback.result).toBeNull(); expect(fallback.calls.map((call) => call.method)).toEqual([ "chat.postEphemeral", @@ -185,10 +211,7 @@ describe("OpenClaw Slack denial-feedback patch", () => { expect(fallback.calls[1]).toMatchObject({ channel: "DU999DENIED" }); // Ambiguous failure (Slack may have accepted it): log, no DM, no double-notify. - const ambiguous = await runPatchedDenyPath(patched, { - opts: { source: "app_mention" }, - ephemeralErrorCode: "service_unavailable", - }); + const ambiguous = output?.ambiguous as { result: unknown; calls: FeedbackCall[] }; expect(ambiguous.result).toBeNull(); expect(ambiguous.calls.map((call) => call.method)).toEqual(["chat.postEphemeral"]); } finally { @@ -200,40 +223,33 @@ describe("OpenClaw Slack denial-feedback patch", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-idem-")); const prepareFile = writeSlackPackage(tmp); try { - expect(runPatch(tmp).status).toBe(0); - const rerun = runPatch(tmp); - expect(rerun.status, `${rerun.stdout}${rerun.stderr}`).toBe(0); - const patched = fs.readFileSync(prepareFile, "utf-8"); - expect(patched.match(/async function __nemoclawNotifyDeniedSlackMention\(/g)).toHaveLength(1); - expect( - patched.match(/nemoclaw: bounded denial feedback for explicit slack @-mentions/g), - ).toHaveLength(1); + const { result, output } = runGuardProbe(prepareFile, { requireGuardTwice: true }); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + const mention = output?.mention as { result: unknown; calls: FeedbackCall[] }; + expect(mention.result).toBeNull(); + expect(mention.calls).toHaveLength(1); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } }); - it("is a no-op when no @openclaw/slack package is present", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-none-")); - fs.mkdirSync(path.join(tmp, "node_modules", "openclaw"), { recursive: true }); - try { - const patch = runPatch(tmp); - expect(patch.status, `${patch.stdout}${patch.stderr}`).toBe(0); - expect(patch.stdout).toContain("no @openclaw/slack package found"); - } finally { - fs.rmSync(tmp, { recursive: true, force: true }); - } + it("loads as a no-op when no @openclaw/slack module is required", () => { + const result = runGuardRequire(); + expect(result.status, result.stderr).toBe(0); }); it("fails loudly when the deny-gate shape changes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-shape-")); - writeSlackPackage(tmp, { + const prepareFile = writeSlackPackage(tmp, { denyLine: "logVerbose(`Blocked unauthorized slack sender ${senderId} (renamed gate)`);", }); try { - const patch = runPatch(tmp); - expect(patch.status).toBe(1); - expect(patch.stderr).toContain("deny gate shape not recognized"); + const result = runGuardRequire(prepareFile); + expect( + result.status, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}\nsource:\n${fs.readFileSync(prepareFile, "utf-8")}`, + ).toBe(1); + expect(result.stderr).toContain("deny gate shape not recognized"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } @@ -241,11 +257,14 @@ describe("OpenClaw Slack denial-feedback patch", () => { it("fails loudly when the mention-state shape changes", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-mention-")); - writeSlackPackage(tmp, { withMentionState: false }); + const prepareFile = writeSlackPackage(tmp, { withMentionState: false }); try { - const patch = runPatch(tmp); - expect(patch.status).toBe(1); - expect(patch.stderr).toContain("mention-state shape not recognized"); + const result = runGuardRequire(prepareFile); + expect( + result.status, + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}\nsource:\n${fs.readFileSync(prepareFile, "utf-8")}`, + ).toBe(1); + expect(result.stderr).toContain("mention-state shape not recognized"); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index 61fe374596..a27655d17e 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -84,7 +84,6 @@ describe("sandbox build context staging", () => { ); writeFixture(path.join("scripts", "patch-openclaw-tool-catalog.js")); writeFixture(path.join("scripts", "patch-openclaw-chat-send.js")); - writeFixture(path.join("scripts", "patch-openclaw-slack-deny-feedback.mts")); } function expectDockerfileScriptCopiesExist(buildCtx: string, stagedDockerfile: string) { @@ -297,9 +296,6 @@ describe("sandbox build context staging", () => { expect(fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-chat-send.js"))).toBe( true, ); - expect( - fs.existsSync(path.join(buildCtx, "scripts", "patch-openclaw-slack-deny-feedback.mts")), - ).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "lib", "sandbox-init.sh"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "scripts", "setup.sh"))).toBe(false); } finally { From e49781522053f91bbc395e69fce45697fabb40cb Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 00:38:09 +0700 Subject: [PATCH 12/23] refactor(messaging): install plugins from package plan --- .../applier/build/messaging-build-applier.mts | 70 +++++++------------ src/lib/messaging/channels/manifests.test.ts | 8 +++ .../wechat/hooks/implementations.test.ts | 3 +- .../wechat/hooks/seed-openclaw-account.ts | 4 +- src/lib/messaging/channels/wechat/manifest.ts | 3 +- src/lib/messaging/channels/wechat/qr.ts | 3 +- test/messaging-build-applier.test.ts | 17 ++--- 7 files changed, 47 insertions(+), 61 deletions(-) diff --git a/src/lib/messaging/applier/build/messaging-build-applier.mts b/src/lib/messaging/applier/build/messaging-build-applier.mts index cd89966b49..5adf696145 100755 --- a/src/lib/messaging/applier/build/messaging-build-applier.mts +++ b/src/lib/messaging/applier/build/messaging-build-applier.mts @@ -102,21 +102,16 @@ export type BuildCommandResult = { readonly openclawVersion: string; }; +type OpenClawPluginInstall = { + readonly spec: string; + readonly pin: boolean; +}; + export class MessagingBuildApplierError extends Error {} export const DEFAULT_MESSAGING_RUNTIME_PLAN_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"; -const OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES: Readonly> = { - discord: "@openclaw/discord", - slack: "@openclaw/slack", - whatsapp: "@openclaw/whatsapp", -}; - -const OPENCLAW_FIXED_MESSAGING_PLUGIN_INSTALL_SPECS: Readonly> = { - wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3", -}; - export function readMessagingBuildPlanFromEnv( env: Env, agent: MessagingAgentId, @@ -403,7 +398,15 @@ export function collectOpenClawMessagingPluginInstallSpecs( plan: MessagingBuildPlan | null, env: Env, ): string[] { - const specs: string[] = []; + return collectOpenClawMessagingPluginInstalls(plan, env).map((install) => install.spec); +} + +function collectOpenClawMessagingPluginInstalls( + plan: MessagingBuildPlan | null, + env: Env, +): OpenClawPluginInstall[] { + const installs: OpenClawPluginInstall[] = []; + const seen = new Set(); for (const step of enabledBuildStepsForPhase(plan, "agent-install")) { if (step.kind !== "package-install") continue; if (step.value === undefined) { @@ -416,10 +419,13 @@ export function collectOpenClawMessagingPluginInstallSpecs( } const install = readOpenClawPackageInstall(step.value, step.outputId); const resolvedSpec = resolveOpenClawPackageSpec(install.spec, env); - assertAllowedOpenClawPackageSpec(step.channelId, resolvedSpec, env); - specs.push(resolvedSpec); + const resolvedInstall = { spec: resolvedSpec, pin: install.pin === true }; + const key = JSON.stringify(resolvedInstall); + if (seen.has(key)) continue; + seen.add(key); + installs.push(resolvedInstall); } - return uniqueStrings(specs); + return installs; } export function openClawDoctorEnvOverrides( @@ -442,8 +448,11 @@ export function openClawDoctorEnvOverrides( } export function installOpenClawMessagingPlugins(plan: MessagingBuildPlan | null, env: Env): void { - for (const spec of collectOpenClawMessagingPluginInstallSpecs(plan, env)) { - runCommand(["openclaw", "plugins", "install", spec, "--pin"], env); + for (const install of collectOpenClawMessagingPluginInstalls(plan, env)) { + runCommand( + ["openclaw", "plugins", "install", install.spec, ...(install.pin ? ["--pin"] : [])], + env, + ); } } @@ -842,35 +851,6 @@ function resolveOpenClawPackageSpec(spec: string, env: Env): string { return resolved; } -function assertAllowedOpenClawPackageSpec(channelId: string, resolvedSpec: string, env: Env): void { - const allowedSpecs = allowedOpenClawPackageSpecsForChannel(channelId, env); - if (!allowedSpecs.includes(resolvedSpec)) { - throw new MessagingBuildApplierError( - `Messaging package-install spec for ${channelId} is not allowed: ${resolvedSpec}`, - ); - } -} - -function allowedOpenClawPackageSpecsForChannel(channelId: string, env: Env): readonly string[] { - const versionedPackage = OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES[channelId]; - if (versionedPackage) { - return ["npm:" + versionedPackage + "@" + requiredOpenClawVersion(env)]; - } - - const fixedSpec = OPENCLAW_FIXED_MESSAGING_PLUGIN_INSTALL_SPECS[channelId]; - return fixedSpec ? [fixedSpec] : []; -} - -function requiredOpenClawVersion(env: Env): string { - const version = (env.OPENCLAW_VERSION || "").trim(); - if (!version) { - throw new MessagingBuildApplierError( - "OPENCLAW_VERSION is required when OpenClaw package install hooks are active", - ); - } - return version; -} - function runCommand(args: readonly string[], env: Env): void { console.log(`+ ${args.join(" ")}`); const result = spawnSync(args[0] as string, args.slice(1), { diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index 6d0d4ed399..cb772a4488 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -537,6 +537,14 @@ describe("built-in channel manifests", () => { expect(renderJson(wechatManifest)).toContain("platforms.weixin"); expect(renderJson(wechatManifest)).toContain("WEIXIN_TOKEN"); expect(renderJson(wechatManifest)).toContain("credential.wechatBotToken.placeholder"); + expect(wechatManifest.agentPackages).toContainEqual({ + id: "openclawPluginPackage", + agent: "openclaw", + manager: "openclaw-plugin", + spec: "npm:@tencent-weixin/openclaw-weixin@2.4.3", + pin: true, + required: true, + }); expect(wechatManifest.hooks.map((hook) => hook.handler)).toEqual([ "wechat.ilinkLogin", "common.configPrompt", diff --git a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts index fc053c6b00..966d65d798 100644 --- a/src/lib/messaging/channels/wechat/hooks/implementations.test.ts +++ b/src/lib/messaging/channels/wechat/hooks/implementations.test.ts @@ -10,7 +10,6 @@ import { createWechatIlinkLoginHook, WECHAT_ILINK_LOGIN_HOOK_ID } from "./ilink- import { buildWechatSeedOpenClawAccountOutputs, createWechatSeedOpenClawAccountHook, - WECHAT_PLUGIN_SPEC, WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID, } from "./seed-openclaw-account"; @@ -266,7 +265,7 @@ describe("WeChat hook implementations", () => { plugins: { installs: { "openclaw-weixin": { - spec: WECHAT_PLUGIN_SPEC, + spec: "@tencent-weixin/openclaw-weixin@2.4.3", }, }, }, diff --git a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts index 77416b6023..f613c2eec8 100644 --- a/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts +++ b/src/lib/messaging/channels/wechat/hooks/seed-openclaw-account.ts @@ -13,8 +13,6 @@ export const WECHAT_SEED_OPENCLAW_ACCOUNT_HOOK_ID = "wechat.seedOpenClawAccount" export const WECHAT_TOKEN_PLACEHOLDER = "openshell:resolve:env:WECHAT_BOT_TOKEN"; export const WECHAT_PLUGIN_ID = "openclaw-weixin"; export const WECHAT_PLUGIN_INSTALL_PATH = "/sandbox/.openclaw/extensions/openclaw-weixin"; -export const WECHAT_PLUGIN_SPEC = "@tencent-weixin/openclaw-weixin@2.4.3"; -export const WECHAT_PLUGIN_INSTALL_SPEC = `npm:${WECHAT_PLUGIN_SPEC}`; export interface WechatSeedOpenClawAccountHookOptions { readonly now?: () => Date | string; @@ -52,7 +50,7 @@ export function buildWechatSeedOpenClawAccountOutputs( WECHAT_TOKEN_PLACEHOLDER; const savedAt = isoTimestamp(options.now); const pluginInstallPath = options.pluginInstallPath ?? WECHAT_PLUGIN_INSTALL_PATH; - const pluginSpec = options.pluginSpec ?? WECHAT_PLUGIN_SPEC; + const pluginSpec = options.pluginSpec ?? "@tencent-weixin/openclaw-weixin@2.4.3"; return { openclawWeixinAccountsIndex: { diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 7299c64d9f..62ebeed145 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -2,7 +2,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { ChannelManifest } from "../../manifest"; -import { WECHAT_PLUGIN_INSTALL_SPEC } from "./hooks/seed-openclaw-account"; export const wechatManifest = { schemaVersion: 1, @@ -131,7 +130,7 @@ export const wechatManifest = { id: "openclawPluginPackage", agent: "openclaw", manager: "openclaw-plugin", - spec: WECHAT_PLUGIN_INSTALL_SPEC, + spec: "npm:@tencent-weixin/openclaw-weixin@2.4.3", pin: true, required: true, }, diff --git a/src/lib/messaging/channels/wechat/qr.ts b/src/lib/messaging/channels/wechat/qr.ts index 24dfaca8c8..7f66a6b2a8 100644 --- a/src/lib/messaging/channels/wechat/qr.ts +++ b/src/lib/messaging/channels/wechat/qr.ts @@ -37,7 +37,8 @@ export const WECHAT_ILINK_APP_ID = "bot"; * Pinned in lockstep with the @tencent-weixin/openclaw-weixin version * installed in the sandbox image, so the iLink gateway sees the same * client version from both the host login and the in-sandbox plugin. - * Bump together with WECHAT_PLUGIN_INSTALL_SPEC in the messaging WeChat hook. */ + * Bump together with the fixed WeChat package spec in the manifest and + * seed hook. */ export const WECHAT_ILINK_CLIENT_VERSION = encodeIlinkClientVersion("2.4.3"); /** Client-side ceiling for a single status long-poll. 35s keeps us within diff --git a/test/messaging-build-applier.test.ts b/test/messaging-build-applier.test.ts index f0e4bd0257..09218d9786 100644 --- a/test/messaging-build-applier.test.ts +++ b/test/messaging-build-applier.test.ts @@ -320,15 +320,15 @@ describe("messaging-build-applier.mts: agent-install", () => { } }); - it("rejects tampered package-install specs before invoking OpenClaw", () => { - const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-package-allowlist-")); + it("installs package-install specs supplied by the compiled plan", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-openclaw-package-plan-")); const tracePath = path.join(tmp, "openclaw.trace"); const fakeOpenclaw = path.join(tmp, "openclaw"); fs.writeFileSync( fakeOpenclaw, [ "#!/usr/bin/env node", - "require('node:fs').appendFileSync(process.env.OPENCLAW_TRACE, 'invoked\\n');", + "require('node:fs').appendFileSync(process.env.OPENCLAW_TRACE, `${process.argv.slice(2).join('|')}\\n`);", "process.exit(0);", "", ].join("\n"), @@ -350,8 +350,8 @@ describe("messaging-build-applier.mts: agent-install", () => { required: true, value: { manager: "openclaw-plugin", - spec: "npm:@evil/plugin@1.0.0", - pin: true, + spec: "npm:@example/manifest-owned-plugin@{{openclaw.version}}", + pin: false, }, }, ], @@ -381,9 +381,10 @@ describe("messaging-build-applier.mts: agent-install", () => { }, ); - expect(result.status).not.toBe(0); - expect(result.stderr).toContain("not allowed"); - expect(fs.existsSync(tracePath)).toBe(false); + expect(result.status, result.stderr).toBe(0); + expect(fs.readFileSync(tracePath, "utf-8").trim()).toBe( + "plugins|install|npm:@example/manifest-owned-plugin@2026.5.22", + ); } finally { fs.rmSync(tmp, { recursive: true, force: true }); } From a465123ea26f1f5cd069ee2fba3bcef5a9120673 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 00:43:54 +0700 Subject: [PATCH 13/23] fix(messaging): validate plan object array entries --- .../wechat/hooks/host-qr-login-runtime.ts | 7 +---- src/lib/messaging/plan-validation.test.ts | 27 +++++++++++++++++++ src/lib/messaging/plan-validation.ts | 24 ++++++++++++----- 3 files changed, 46 insertions(+), 12 deletions(-) diff --git a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts index af27994a8c..26586a0539 100644 --- a/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts +++ b/src/lib/messaging/channels/wechat/hooks/host-qr-login-runtime.ts @@ -17,12 +17,7 @@ function createWechatHostQrLoginRunner(): () => Promise { return async () => { logEnrollmentHelp(); - let result: WechatHostQrLoginResult; - try { - result = await runWechatHostQrLogin(); - } catch (error) { - result = { kind: "error", message: error instanceof Error ? error.message : String(error) }; - } + const result: WechatHostQrLoginResult = await runWechatHostQrLogin(); if (result.kind !== "ok") { return result; diff --git a/src/lib/messaging/plan-validation.test.ts b/src/lib/messaging/plan-validation.test.ts index b9df76b766..18cc4c815a 100644 --- a/src/lib/messaging/plan-validation.test.ts +++ b/src/lib/messaging/plan-validation.test.ts @@ -164,6 +164,33 @@ describe("parseSandboxMessagingPlan", () => { expect(parseSandboxMessagingPlan(plan)).toBeNull(); }); + + it("rejects malformed object arrays without throwing", () => { + for (const field of [ + "credentialBindings", + "agentRender", + "buildSteps", + "stateUpdates", + "healthChecks", + ]) { + const plan = makePlan() as unknown as Record; + plan[field] = [null]; + + expect(parseSandboxMessagingPlan(plan), field).toBeNull(); + } + + const channelHooksPlan = makePlan() as unknown as { channels: { hooks: unknown[] }[] }; + channelHooksPlan.channels[0].hooks = [null]; + expect(parseSandboxMessagingPlan(channelHooksPlan), "channel hooks").toBeNull(); + + const runtimeSetupPlan = makePlan() as unknown as Record; + runtimeSetupPlan.runtimeSetup = { + nodePreloads: [null], + envAliases: [], + secretScans: [], + }; + expect(parseSandboxMessagingPlan(runtimeSetupPlan), "runtimeSetup.nodePreloads").toBeNull(); + }); }); describe("plan channel derivation", () => { diff --git a/src/lib/messaging/plan-validation.ts b/src/lib/messaging/plan-validation.ts index d6c164c9a5..dce275201a 100644 --- a/src/lib/messaging/plan-validation.ts +++ b/src/lib/messaging/plan-validation.ts @@ -31,13 +31,13 @@ export function parseSandboxMessagingPlan( typeof value.workflow !== "string" || !Array.isArray(value.channels) || !Array.isArray(value.disabledChannels) || - (Object.hasOwn(value, "credentialBindings") && !Array.isArray(value.credentialBindings)) || + !isOptionalObjectArray(value, "credentialBindings") || (Object.hasOwn(value, "networkPolicy") && !isObject(value.networkPolicy)) || - (Object.hasOwn(value, "agentRender") && !Array.isArray(value.agentRender)) || - (Object.hasOwn(value, "buildSteps") && !Array.isArray(value.buildSteps)) || + !isOptionalObjectArray(value, "agentRender") || + !isOptionalObjectArray(value, "buildSteps") || !isRuntimeSetup(value.runtimeSetup) || - (Object.hasOwn(value, "stateUpdates") && !Array.isArray(value.stateUpdates)) || - (Object.hasOwn(value, "healthChecks") && !Array.isArray(value.healthChecks)) + !isOptionalObjectArray(value, "stateUpdates") || + !isOptionalObjectArray(value, "healthChecks") ) { return null; } @@ -64,6 +64,9 @@ export function parseSandboxMessagingPlan( ) { return null; } + if (Array.isArray(channel.hooks) && channel.hooks.some((hook) => !isObject(hook))) { + return null; + } if (supported && !supported.has(channel.channelId)) return null; if ( value.channels.findIndex( @@ -162,12 +165,21 @@ function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } +function isOptionalObjectArray(value: Record, key: string): boolean { + if (!Object.hasOwn(value, key)) return true; + const entries = value[key]; + return Array.isArray(entries) && entries.every(isObject); +} + function isRuntimeSetup(value: unknown): boolean { if (value === undefined) return true; return ( isObject(value) && Array.isArray(value.nodePreloads) && Array.isArray(value.envAliases) && - Array.isArray(value.secretScans) + Array.isArray(value.secretScans) && + value.nodePreloads.every(isObject) && + value.envAliases.every(isObject) && + value.secretScans.every(isObject) ); } From 848c9ec16fa0eb82bc4bd2eda784fca650b0082c Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 02:39:03 +0700 Subject: [PATCH 14/23] refactor(messaging): move runtime preload sources to ts --- Dockerfile | 6 +- ...hannel-guard.js => slack-channel-guard.ts} | 201 +++++++------ ...diagnostics.js => telegram-diagnostics.ts} | 263 +++++++++++------- ...t-diagnostics.js => wechat-diagnostics.ts} | 102 ++++--- ...p-qr-compact.js => whatsapp-qr-compact.ts} | 37 ++- .../live/whatsapp-qr-compact.test.ts | 2 +- test/local-slack-auth-test.sh | 2 +- test/nemoclaw-start-telegram-runtime.test.ts | 2 +- test/nemoclaw-start.test.ts | 6 +- ...openclaw-slack-deny-feedback-patch.test.ts | 2 +- test/telegram-diagnostics.test.ts | 4 +- test/wechat-diagnostics.test.ts | 4 +- test/whatsapp-qr-compact.test.ts | 2 +- 13 files changed, 364 insertions(+), 269 deletions(-) rename src/lib/messaging/channels/slack/runtime/{slack-channel-guard.js => slack-channel-guard.ts} (51%) rename src/lib/messaging/channels/telegram/runtime/{telegram-diagnostics.js => telegram-diagnostics.ts} (50%) rename src/lib/messaging/channels/wechat/runtime/{wechat-diagnostics.js => wechat-diagnostics.ts} (56%) rename src/lib/messaging/channels/whatsapp/runtime/{whatsapp-qr-compact.js => whatsapp-qr-compact.ts} (87%) diff --git a/Dockerfile b/Dockerfile index ed6c2cefef..e3010f908a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -527,8 +527,10 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # Copy NODE_OPTIONS preload modules to a Landlock-accessible path. OpenShell ≥0.0.36 # blocks /opt/nemoclaw-blueprint/ from non-root users, but the entrypoint # needs to read these files to install Node runtime preloads under /tmp. +# Channel runtime preloads are TypeScript source files constrained to the +# Node-executable JS subset; rename them to .js in the image for --require. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ -COPY src/lib/messaging/channels/*/runtime/*.js /usr/local/lib/nemoclaw/preloads/ +COPY src/lib/messaging/channels/*/runtime/*.ts /usr/local/lib/nemoclaw/preloads-ts/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.mts /scripts/generate-openclaw-config.mts COPY src/lib/messaging/ /src/lib/messaging/ @@ -540,6 +542,8 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ && chmod -R a+rX /src/lib/messaging \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \ + && if [ -d /usr/local/lib/nemoclaw/preloads-ts ]; then find /usr/local/lib/nemoclaw/preloads-ts -type f -name '*.ts' -exec sh -c 'for file do cp "$file" "/usr/local/lib/nemoclaw/preloads/$(basename "$file" .ts).js"; done' sh {} +; fi \ + && rm -rf /usr/local/lib/nemoclaw/preloads-ts \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ /usr/local/share/nemoclaw/openclaw-plugins \ diff --git a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts similarity index 51% rename from src/lib/messaging/channels/slack/runtime/slack-channel-guard.js rename to src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts index c02f3ec538..2568dc575e 100644 --- a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.js +++ b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts @@ -1,7 +1,8 @@ +// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// slack-channel-guard.js — catches unhandled promise rejections from Slack +// slack-channel-guard.ts — catches unhandled promise rejections from Slack // channel initialization so a single channel auth failure does not crash // the entire OpenClaw gateway. Node v22 treats unhandled rejections as // fatal (--unhandled-rejections=throw is the default), taking down @@ -22,40 +23,40 @@ // Ref: https://github.com/NVIDIA/NemoClaw/issues/4752 (function () { - 'use strict'; + "use strict"; - var HELPER_MARKER = '__nemoclawNotifyDeniedSlackMention'; - var CALL_MARKER = 'nemoclaw: bounded denial feedback for explicit slack @-mentions'; - var DENY_LOG_SIGNATURE = 'Blocked unauthorized slack sender'; + var HELPER_MARKER = "__nemoclawNotifyDeniedSlackMention"; + var CALL_MARKER = "nemoclaw: bounded denial feedback for explicit slack @-mentions"; + var DENY_LOG_SIGNATURE = "Blocked unauthorized slack sender"; // Slack-specific error codes from @slack/web-api that indicate auth failure. // These appear as error.code on the WebAPIRequestError or CodedError objects. var SLACK_AUTH_ERRORS = [ - 'slack_webapi_platform_error', - 'slack_webapi_request_error', - 'slackbot_error', + "slack_webapi_platform_error", + "slack_webapi_request_error", + "slackbot_error", ]; // Slack-specific error messages that indicate auth/token problems. var SLACK_AUTH_MESSAGES = [ - 'invalid_auth', - 'not_authed', - 'token_revoked', - 'token_expired', - 'account_inactive', - 'missing_scope', - 'not_allowed_token_type', - 'An API error occurred: invalid_auth', + "invalid_auth", + "not_authed", + "token_revoked", + "token_expired", + "account_inactive", + "missing_scope", + "not_allowed_token_type", + "An API error occurred: invalid_auth", ]; function mentionsSlackHost(value) { - var tokens = String(value || '').split(/\s+/); + var tokens = String(value || "").split(/\s+/); for (var i = 0; i < tokens.length; i++) { - var candidate = tokens[i].replace(/^[<("']+|[>),."']+$/g, ''); + var candidate = tokens[i].replace(/^[<("']+|[>),."']+$/g, ""); try { var parsed = new URL(candidate); var host = parsed.hostname.toLowerCase(); - if (host === 'slack.com' || host.endsWith('.slack.com')) return true; + if (host === "slack.com" || host.endsWith(".slack.com")) return true; } catch (_e) { if (/^(?:[a-z0-9-]+\.)*slack\.com(?::\d+)?$/i.test(candidate)) return true; } @@ -68,7 +69,7 @@ if (isSlackDenyFeedbackPatchError(reason)) return false; // Check error code (Slack SDK sets .code on its errors) - var code = reason.code || ''; + var code = reason.code || ""; for (var i = 0; i < SLACK_AUTH_ERRORS.length; i++) { if (code === SLACK_AUTH_ERRORS[i]) return true; } @@ -80,8 +81,8 @@ } // Check stack trace for @slack/ packages - var stack = reason.stack || ''; - if (stack.indexOf('@slack/') !== -1 || stack.indexOf('slack-') !== -1) { + var stack = reason.stack || ""; + if (stack.indexOf("@slack/") !== -1 || stack.indexOf("slack-") !== -1) { return true; } @@ -98,19 +99,23 @@ } function isSlackDenyFeedbackPatchError(reason) { - var msg = String((reason && reason.message) || reason || ''); - return msg.indexOf('OpenClaw Slack ') !== -1 && ( - msg.indexOf('shape not recognized') !== -1 || - msg.indexOf('prepareSlackMessage definition not found') !== -1 + var msg = String((reason && reason.message) || reason || ""); + return ( + msg.indexOf("OpenClaw Slack ") !== -1 && + (msg.indexOf("shape not recognized") !== -1 || + msg.indexOf("prepareSlackMessage definition not found") !== -1) ); } function handleSlackError(reason, source) { if (isSlackRejection(reason)) { - var msg = (reason && reason.message) ? reason.message : String(reason); + var msg = reason && reason.message ? reason.message : String(reason); process.stderr.write( - '[channels] [slack] provider failed to start: ' + msg + - ' \u2014 ' + source + ' caught by safety net, gateway continues\n' + "[channels] [slack] provider failed to start: " + + msg + + " \u2014 " + + source + + " caught by safety net, gateway continues\n", ); return true; // handled } @@ -119,72 +124,78 @@ if (process.__nemoclawSlackChannelGuardInstalled) return; try { - Object.defineProperty(process, '__nemoclawSlackChannelGuardInstalled', { value: true }); + Object.defineProperty(process, "__nemoclawSlackChannelGuardInstalled", { value: true }); } catch (_e) { process.__nemoclawSlackChannelGuardInstalled = true; } function buildDeniedMentionFeedbackHelperSource() { return [ - 'async function __nemoclawNotifyDeniedSlackMention(params) {', - '\t// nemoclaw: bounded sender-facing feedback for an explicit @-mention whose', - '\t// command was denied by the channel allowlist. Keeps the command blocked,', - '\t// never reveals the allowlist, and emits exactly one sender-facing message', - '\t// (ephemeral in-channel, DM fallback). (#4752)', - '\tconst { ctx, message, senderId } = params;', - '\tif (!params.explicitMention) return;', - '\tconst client = ctx?.app?.client;', - '\tconst channel = message?.channel;', - '\tconst user = senderId ?? message?.user;', - '\tif (!client?.chat || !channel || !user) return;', + "async function __nemoclawNotifyDeniedSlackMention(params) {", + "\t// nemoclaw: bounded sender-facing feedback for an explicit @-mention whose", + "\t// command was denied by the channel allowlist. Keeps the command blocked,", + "\t// never reveals the allowlist, and emits exactly one sender-facing message", + "\t// (ephemeral in-channel, DM fallback). (#4752)", + "\tconst { ctx, message, senderId } = params;", + "\tif (!params.explicitMention) return;", + "\tconst client = ctx?.app?.client;", + "\tconst channel = message?.channel;", + "\tconst user = senderId ?? message?.user;", + "\tif (!client?.chat || !channel || !user) return;", '\tconst text = "Sorry, you\\\'re not authorized to use this assistant in this channel, so your request was not processed.";', - '\tconst threadTs = message?.thread_ts ?? message?.ts;', - '\ttry {', - '\t\tawait client.chat.postEphemeral({ channel, user, text, ...threadTs ? { thread_ts: threadTs } : {} });', - '\t\treturn;', - '\t} catch (ephemeralError) {', - '\t\t// Only fall back to a DM when Slack definitively did not deliver the', - '\t\t// ephemeral. Ambiguous failures (network/HTTP, timeout, service errors)', - '\t\t// may have been accepted, so a DM there could double-notify the sender.', - '\t\tconst ephemeralErrorCode = ephemeralError?.data?.error ?? ephemeralError?.code;', + "\tconst threadTs = message?.thread_ts ?? message?.ts;", + "\ttry {", + "\t\tawait client.chat.postEphemeral({ channel, user, text, ...threadTs ? { thread_ts: threadTs } : {} });", + "\t\treturn;", + "\t} catch (ephemeralError) {", + "\t\t// Only fall back to a DM when Slack definitively did not deliver the", + "\t\t// ephemeral. Ambiguous failures (network/HTTP, timeout, service errors)", + "\t\t// may have been accepted, so a DM there could double-notify the sender.", + "\t\tconst ephemeralErrorCode = ephemeralError?.data?.error ?? ephemeralError?.code;", '\t\tctx?.logger?.warn?.({ err: ephemeralError, channel, code: ephemeralErrorCode }, "nemoclaw: slack denial ephemeral feedback failed (#4752)");', '\t\tconst nonDeliveryCodes = ["user_not_in_channel", "not_in_channel", "channel_not_found", "cannot_reply_to_message", "is_archived", "messages_tab_disabled"];', - '\t\tif (!nonDeliveryCodes.includes(ephemeralErrorCode)) return;', - '\t\ttry {', - '\t\t\tconst opened = await client.conversations?.open?.({ users: user });', - '\t\t\tconst dmChannel = opened?.channel?.id;', - '\t\t\tif (dmChannel) await client.chat.postMessage({ channel: dmChannel, text });', - '\t\t} catch (dmError) {', + "\t\tif (!nonDeliveryCodes.includes(ephemeralErrorCode)) return;", + "\t\ttry {", + "\t\t\tconst opened = await client.conversations?.open?.({ users: user });", + "\t\t\tconst dmChannel = opened?.channel?.id;", + "\t\t\tif (dmChannel) await client.chat.postMessage({ channel: dmChannel, text });", + "\t\t} catch (dmError) {", '\t\t\tctx?.logger?.warn?.({ err: dmError }, "nemoclaw: slack denial DM feedback failed (#4752)");', - '\t\t}', - '\t}', - '}', - '', - ].join('\n'); + "\t\t}", + "\t}", + "}", + "", + ].join("\n"); } function isOpenClawSlackFile(filename) { - var normalized = String(filename || '').replace(/\\/g, '/'); - return normalized.indexOf('/@openclaw/slack/') !== -1 && normalized.endsWith('.js'); + var normalized = String(filename || "").replace(/\\/g, "/"); + return normalized.indexOf("/@openclaw/slack/") !== -1 && normalized.endsWith(".js"); } function patchSlackPrepareSource(source, filename) { - if (source.indexOf('async function prepareSlackMessage') === -1) return source; - if (source.indexOf('async function ' + HELPER_MARKER + '(') !== -1 && - source.indexOf(CALL_MARKER) !== -1) { + if (source.indexOf("async function prepareSlackMessage") === -1) return source; + if ( + source.indexOf("async function " + HELPER_MARKER + "(") !== -1 && + source.indexOf(CALL_MARKER) !== -1 + ) { return source; } if (source.indexOf(DENY_LOG_SIGNATURE) === -1) { throw new Error( - 'OpenClaw Slack prepare module shape not recognized in ' + filename + - '; expected denied-sender log signature' + "OpenClaw Slack prepare module shape not recognized in " + + filename + + "; expected denied-sender log signature", ); } - if (source.indexOf('explicitlyMentionedBotUser') === -1 || - source.indexOf('explicitlyMentionedBotSubteam') === -1) { + if ( + source.indexOf("explicitlyMentionedBotUser") === -1 || + source.indexOf("explicitlyMentionedBotSubteam") === -1 + ) { throw new Error( - 'OpenClaw Slack mention-state shape not recognized in ' + filename + - '; expected explicitlyMentionedBotUser/explicitlyMentionedBotSubteam in the prepare deny path' + "OpenClaw Slack mention-state shape not recognized in " + + filename + + "; expected explicitlyMentionedBotUser/explicitlyMentionedBotSubteam in the prepare deny path", ); } @@ -193,22 +204,30 @@ next = next.replace( /(logVerbose\(`Blocked unauthorized slack sender \$\{senderId\} \(not in channel users\)`\);\n)(\s*)return null;/, function (_match, logLine, indent) { - return logLine + indent + - 'await __nemoclawNotifyDeniedSlackMention({ ctx, message, senderId, ' + + return ( + logLine + + indent + + "await __nemoclawNotifyDeniedSlackMention({ ctx, message, senderId, " + 'explicitMention: opts.source === "app_mention" || explicitlyMentionedBotUser || explicitlyMentionedBotSubteam }); ' + - '// ' + CALL_MARKER + ' (#4752)\n' + - indent + 'return null;'; - } + "// " + + CALL_MARKER + + " (#4752)\n" + + indent + + "return null;" + ); + }, ); if (next === source) { - throw new Error('OpenClaw Slack channel-users deny gate shape not recognized in ' + filename); + throw new Error( + "OpenClaw Slack channel-users deny gate shape not recognized in " + filename, + ); } } - if (next.indexOf('async function ' + HELPER_MARKER + '(') === -1) { - var anchor = 'async function prepareSlackMessage(params) {'; + if (next.indexOf("async function " + HELPER_MARKER + "(") === -1) { + var anchor = "async function prepareSlackMessage(params) {"; if (next.indexOf(anchor) === -1) { - throw new Error('OpenClaw Slack prepareSlackMessage definition not found in ' + filename); + throw new Error("OpenClaw Slack prepareSlackMessage definition not found in " + filename); } next = next.replace(anchor, buildDeniedMentionFeedbackHelperSource() + anchor); } @@ -216,14 +235,14 @@ } function installSlackDenyFeedbackPatch() { - var Module = require('module'); - var fs = require('fs'); - var originalJsLoader = Module._extensions && Module._extensions['.js']; - if (typeof originalJsLoader !== 'function') return; + var Module = require("module"); + var fs = require("fs"); + var originalJsLoader = Module._extensions && Module._extensions[".js"]; + if (typeof originalJsLoader !== "function") return; - Module._extensions['.js'] = function nemoclawSlackJsLoader(mod, filename) { + Module._extensions[".js"] = function nemoclawSlackJsLoader(mod, filename) { if (isOpenClawSlackFile(filename)) { - var source = fs.readFileSync(filename, 'utf8'); + var source = fs.readFileSync(filename, "utf8"); var patched = patchSlackPrepareSource(source, filename); if (patched !== source) { return mod._compile(patched, filename); @@ -237,10 +256,10 @@ var origEmit = process.emit; process.emit = function (eventName) { - if (eventName === 'unhandledRejection') { - if (handleSlackError(arguments[1], 'unhandledRejection')) return true; - } else if (eventName === 'uncaughtException') { - if (handleSlackError(arguments[1], 'uncaughtException')) return true; + if (eventName === "unhandledRejection") { + if (handleSlackError(arguments[1], "unhandledRejection")) return true; + } else if (eventName === "uncaughtException") { + if (handleSlackError(arguments[1], "uncaughtException")) return true; } return origEmit.apply(this, arguments); }; diff --git a/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js b/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts similarity index 50% rename from src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js rename to src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts index e1ca1a61f8..b25bddc9c5 100644 --- a/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js +++ b/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts @@ -1,18 +1,19 @@ +// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// telegram-diagnostics.js — adds runtime breadcrumbs for OpenClaw's Telegram +// telegram-diagnostics.ts — adds runtime breadcrumbs for OpenClaw's Telegram // channel without changing channel behavior. The important distinction for // NemoClaw#2766 is that "[telegram] [default] starting provider" means the // channel is initializing; an agent-turn failure later can be an inference // provider failure through inference.local, not a Telegram Bot API failure. (function () { - 'use strict'; + "use strict"; if (process.__nemoclawTelegramDiagnosticsInstalled) return; try { - Object.defineProperty(process, '__nemoclawTelegramDiagnosticsInstalled', { value: true }); + Object.defineProperty(process, "__nemoclawTelegramDiagnosticsInstalled", { value: true }); } catch (_e) { process.__nemoclawTelegramDiagnosticsInstalled = true; } @@ -28,13 +29,13 @@ var inDiagnosticWrite = false; function sanitize(value) { - var text = String(value || ''); - text = text.replace(/\/bot[^/\s"']+/g, '/bot'); - text = text.replace(/\/file\/bot[^/\s"']+/g, '/file/bot'); - text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, 'Bearer '); + var text = String(value || ""); + text = text.replace(/\/bot[^/\s"']+/g, "/bot"); + text = text.replace(/\/file\/bot[^/\s"']+/g, "/file/bot"); + text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, "Bearer "); text = text.replace( /\b(api[_-]?key|token|authorization)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi, - '$1$2' + "$1$2", ); return text; } @@ -45,7 +46,7 @@ if (inDiagnosticWrite) return; inDiagnosticWrite = true; try { - originalStderrWrite(line + '\n'); + originalStderrWrite(line + "\n"); } finally { inDiagnosticWrite = false; } @@ -54,40 +55,40 @@ function describeRequest(arg1, arg2) { var url = null; var opts = null; - if (typeof arg1 === 'string' || arg1 instanceof URL) { + if (typeof arg1 === "string" || arg1 instanceof URL) { try { url = new URL(String(arg1)); } catch (_e) { url = null; } - if (arg2 && typeof arg2 === 'object' && typeof arg2 !== 'function') opts = arg2; - } else if (arg1 && typeof arg1 === 'object') { + if (arg2 && typeof arg2 === "object" && typeof arg2 !== "function") opts = arg2; + } else if (arg1 && typeof arg1 === "object") { opts = arg1; } - var hostname = ''; - var path = ''; + var hostname = ""; + var path = ""; if (url) { - hostname = url.hostname || ''; - path = (url.pathname || '') + (url.search || ''); + hostname = url.hostname || ""; + path = (url.pathname || "") + (url.search || ""); } if (opts) { - hostname = String(opts.hostname || opts.host || hostname || ''); - path = String(opts.path || path || ''); + hostname = String(opts.hostname || opts.host || hostname || ""); + path = String(opts.path || path || ""); } - if (hostname.indexOf(':') !== -1) hostname = hostname.split(':')[0]; + if (hostname.indexOf(":") !== -1) hostname = hostname.split(":")[0]; return { hostname: hostname, path: path }; } function telegramApiMethod(info) { - if (!info || info.hostname !== 'api.telegram.org') return; - var match = /\/(?:bot[^/]+\/)?([^/?]+)(?:\?|$)/.exec(info.path || ''); - return match && match[1] ? match[1] : ''; + if (!info || info.hostname !== "api.telegram.org") return; + var match = /\/(?:bot[^/]+\/)?([^/?]+)(?:\?|$)/.exec(info.path || ""); + return match && match[1] ? match[1] : ""; } function isTelegramStartupProbe(info) { var method = telegramApiMethod(info); - return method === 'getUpdates' || method === 'getMe' || method === 'getWebhookInfo'; + return method === "getUpdates" || method === "getMe" || method === "getWebhookInfo"; } function maybeLogTelegramStartupProbe(info, statusCode) { @@ -97,17 +98,23 @@ if (status >= 200 && status < 300) { if (readyLogged) return; readyLogged = true; - emit('[telegram] [default] provider ready (Bot API reachable; agent replies use inference.local)'); + emit( + "[telegram] [default] provider ready (Bot API reachable; agent replies use inference.local)", + ); return; } if (startupProbeLogged) return; startupProbeLogged = true; if (status === 401 || status === 404) { - emit('[telegram] [default] Bot API rejected startup probe with HTTP ' + status + '; token invalid or credential placeholder unresolved'); + emit( + "[telegram] [default] Bot API rejected startup probe with HTTP " + + status + + "; token invalid or credential placeholder unresolved", + ); return; } if (status >= 300) { - emit('[telegram] [default] Bot API startup probe returned HTTP ' + status); + emit("[telegram] [default] Bot API startup probe returned HTTP " + status); } } @@ -115,102 +122,124 @@ if (!isTelegramStartupProbe(info) || startupProbeLogged) return; providerStarted = true; startupProbeLogged = true; - var detail = error && (error.code || error.message) ? (error.code || error.message) : error; - emit('[telegram] [default] Bot API startup probe failed: ' + sanitize(detail).slice(0, 300)); + var detail = error && (error.code || error.message) ? error.code || error.message : error; + emit("[telegram] [default] Bot API startup probe failed: " + sanitize(detail).slice(0, 300)); } function maybeLogTelegramSendMessage(info, statusCode) { - if (sendMessageLogged || telegramApiMethod(info) !== 'sendMessage') return; + if (sendMessageLogged || telegramApiMethod(info) !== "sendMessage") return; sendMessageLogged = true; - emit('[telegram] [default] outbound sendMessage attempted; Bot API returned HTTP ' + Number(statusCode || 0)); + emit( + "[telegram] [default] outbound sendMessage attempted; Bot API returned HTTP " + + Number(statusCode || 0), + ); } function senderAllowlistState(senderId) { - if (senderId === undefined || senderId === null) return 'unknown'; - var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; + if (senderId === undefined || senderId === null) return "unknown"; + var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; try { - var fs = require('fs'); - var account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, 'utf8'))); - if (!account || account.dmPolicy !== 'allowlist') return 'not-applicable'; + var fs = require("fs"); + var account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, "utf8"))); + if (!account || account.dmPolicy !== "allowlist") return "not-applicable"; var allowFrom = Array.isArray(account.allowFrom) ? account.allowFrom.map(String) : []; - return allowFrom.indexOf(String(senderId)) === -1 ? 'false' : 'true'; + return allowFrom.indexOf(String(senderId)) === -1 ? "false" : "true"; } catch (_e) { - return 'unknown'; + return "unknown"; } } function maybeLogTelegramInboundUpdate(info, body) { - if (inboundUpdateLogged || telegramApiMethod(info) !== 'getUpdates') return; + if (inboundUpdateLogged || telegramApiMethod(info) !== "getUpdates") return; var payload = null; try { - payload = JSON.parse(String(body || '')); + payload = JSON.parse(String(body || "")); } catch (_e) { return; } if (!payload || payload.ok !== true || !Array.isArray(payload.result)) return; for (var i = 0; i < payload.result.length; i += 1) { var update = payload.result[i]; - if (!update || typeof update !== 'object') continue; - var message = update.message || update.edited_message || update.channel_post || update.edited_channel_post; - if (!message || typeof message !== 'object') continue; + if (!update || typeof update !== "object") continue; + var message = + update.message || + update.edited_message || + update.channel_post || + update.edited_channel_post; + if (!message || typeof message !== "object") continue; inboundUpdateLogged = true; - var chat = message.chat && typeof message.chat === 'object' ? message.chat : {}; - var from = message.from && typeof message.from === 'object' ? message.from : {}; - var chatType = typeof chat.type === 'string' ? sanitize(chat.type).replace(/[^A-Za-z0-9_-]/g, '').slice(0, 40) : 'unknown'; - var updateIdState = update.update_id === undefined || update.update_id === null ? 'missing' : 'present'; - var messageIdState = message.message_id === undefined || message.message_id === null ? 'missing' : 'present'; + var chat = message.chat && typeof message.chat === "object" ? message.chat : {}; + var from = message.from && typeof message.from === "object" ? message.from : {}; + var chatType = + typeof chat.type === "string" + ? sanitize(chat.type) + .replace(/[^A-Za-z0-9_-]/g, "") + .slice(0, 40) + : "unknown"; + var updateIdState = + update.update_id === undefined || update.update_id === null ? "missing" : "present"; + var messageIdState = + message.message_id === undefined || message.message_id === null ? "missing" : "present"; emit( - '[telegram] [default] inbound update received (update_id=' + + "[telegram] [default] inbound update received (update_id=" + updateIdState + - '; message_id=' + + "; message_id=" + messageIdState + - '; chat_type=' + + "; chat_type=" + chatType + - '; sender_allowlisted=' + + "; sender_allowlisted=" + senderAllowlistState(from.id) + - ')' + ")", ); return; } } function readTelegramAccount(config) { - if (!config || typeof config !== 'object') return null; + if (!config || typeof config !== "object") return null; var channel = config.channels && config.channels.telegram; - if (!channel || typeof channel !== 'object') return null; + if (!channel || typeof channel !== "object") return null; var accounts = channel.accounts; - if (!accounts || typeof accounts !== 'object') return null; + if (!accounts || typeof accounts !== "object") return null; var account = accounts.default || accounts.main; - if (!account || typeof account !== 'object') { + if (!account || typeof account !== "object") { var keys = Object.keys(accounts); account = keys.length ? accounts[keys[0]] : null; } - return account && typeof account === 'object' ? account : null; + return account && typeof account === "object" ? account : null; } function readTelegramBotToken(config) { var account = readTelegramAccount(config); - return account && typeof account.botToken === 'string' ? account.botToken : ''; + return account && typeof account.botToken === "string" ? account.botToken : ""; } function maybeLogRuntimeConfigDiagnostics() { if (runtimeConfigLogged) return; runtimeConfigLogged = true; - var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; + var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; var account = null; try { - var fs = require('fs'); - account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, 'utf8'))); + var fs = require("fs"); + account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, "utf8"))); } catch (_e) { return; } if (!account) return; var allowFrom = Array.isArray(account.allowFrom) ? account.allowFrom : []; - if (account.dmPolicy === 'allowlist') { + if (account.dmPolicy === "allowlist") { if (allowFrom.length > 0) { - emit('[telegram] [default] DM allowlist configured (' + allowFrom.length + ' entr' + (allowFrom.length === 1 ? 'y' : 'ies') + ')'); + emit( + "[telegram] [default] DM allowlist configured (" + + allowFrom.length + + " entr" + + (allowFrom.length === 1 ? "y" : "ies") + + ")", + ); } else { - emit('[telegram] [default] DM allowlist is empty; set TELEGRAM_ALLOWED_IDS before rebuild or complete OpenClaw pairing before expecting direct-message replies'); + emit( + "[telegram] [default] DM allowlist is empty; set TELEGRAM_ALLOWED_IDS before rebuild or complete OpenClaw pairing before expecting direct-message replies", + ); } } } @@ -218,52 +247,61 @@ function maybeLogCredentialPlaceholderDiagnostics() { if (credentialLogged) return; credentialLogged = true; - var prefix = 'openshell:resolve:env:'; - var envToken = process.env.TELEGRAM_BOT_TOKEN || ''; - var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; - var configToken = ''; + var prefix = "openshell:resolve:env:"; + var envToken = process.env.TELEGRAM_BOT_TOKEN || ""; + var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; + var configToken = ""; try { - var fs = require('fs'); - configToken = readTelegramBotToken(JSON.parse(fs.readFileSync(configPath, 'utf8'))); + var fs = require("fs"); + configToken = readTelegramBotToken(JSON.parse(fs.readFileSync(configPath, "utf8"))); } catch (_e) { return; } if (!configToken || configToken.indexOf(prefix) !== 0) return; if (!envToken) { - emit('[telegram] [default] credential placeholder configured but TELEGRAM_BOT_TOKEN is missing from runtime env'); + emit( + "[telegram] [default] credential placeholder configured but TELEGRAM_BOT_TOKEN is missing from runtime env", + ); return; } if (envToken.indexOf(prefix) !== 0) return; if (configToken !== envToken) { - emit('[telegram] [default] credential placeholder mismatch: openclaw.json botToken does not match runtime TELEGRAM_BOT_TOKEN placeholder'); + emit( + "[telegram] [default] credential placeholder mismatch: openclaw.json botToken does not match runtime TELEGRAM_BOT_TOKEN placeholder", + ); } } function wrapHttp(mod, methodName) { var original = mod[methodName]; - if (typeof original !== 'function') return; + if (typeof original !== "function") return; mod[methodName] = function () { var info = describeRequest(arguments[0], arguments[1]); var req = original.apply(this, arguments); - if (info && info.hostname === 'api.telegram.org' && req && typeof req.once === 'function') { - req.once('response', function (res) { + if (info && info.hostname === "api.telegram.org" && req && typeof req.once === "function") { + req.once("response", function (res) { maybeLogTelegramStartupProbe(info, res && res.statusCode); maybeLogTelegramSendMessage(info, res && res.statusCode); - if (!inboundUpdateLogged && telegramApiMethod(info) === 'getUpdates' && res && typeof res.on === 'function') { + if ( + !inboundUpdateLogged && + telegramApiMethod(info) === "getUpdates" && + res && + typeof res.on === "function" + ) { var responseChunks = []; var responseBytes = 0; - res.on('data', function (chunk) { + res.on("data", function (chunk) { if (responseBytes >= 65536) return; - var text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); + var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); responseBytes += Buffer.byteLength(text); if (responseBytes <= 65536) responseChunks.push(text); }); - res.on('end', function () { - maybeLogTelegramInboundUpdate(info, responseChunks.join('')); + res.on("end", function () { + maybeLogTelegramInboundUpdate(info, responseChunks.join("")); }); } }); - req.once('error', function (error) { + req.once("error", function (error) { maybeLogTelegramStartupError(info, error); }); } @@ -274,27 +312,36 @@ process.stderr.write = function (chunk, encoding, cb) { var ret = originalStderrWrite.apply(process.stderr, arguments); if (!inDiagnosticWrite && !inferenceLogged) { - var text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); + var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); if (!providerStarted && /\[telegram\] \[default\] starting provider\b/i.test(text)) { providerStarted = true; } - if (providerStarted && /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text)) { + if ( + providerStarted && + /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text) + ) { inferenceLogged = true; - var line = text.split(/\r?\n/).find(function (entry) { - return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(entry); - }) || text; - emit('[telegram] [default] agent turn failed after provider startup; inference error: ' + sanitize(line).slice(0, 600)); + var line = + text.split(/\r?\n/).find(function (entry) { + return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test( + entry, + ); + }) || text; + emit( + "[telegram] [default] agent turn failed after provider startup; inference error: " + + sanitize(line).slice(0, 600), + ); } } return ret; }; - var http = require('http'); - var https = require('https'); - wrapHttp(http, 'request'); - wrapHttp(http, 'get'); - wrapHttp(https, 'request'); - wrapHttp(https, 'get'); + var http = require("http"); + var https = require("https"); + wrapHttp(http, "request"); + wrapHttp(http, "get"); + wrapHttp(https, "request"); + wrapHttp(https, "get"); process.nextTick(maybeLogCredentialPlaceholderDiagnostics); // Defense in depth for #4314/#4390: if Telegram is configured but the @@ -309,25 +356,27 @@ // not start" line from every Node command even while the real gateway // bridge is healthy. Mirrors sandbox-safety-net.js's gatewayProcessFlavor. function basename(value) { - return String(value || '').split(/[\\/]/).pop(); + return String(value || "") + .split(/[\\/]/) + .pop(); } function gatewayProcessFlavor() { - if (basename(process.argv0) === 'openclaw-gateway') return 'openclaw-gateway'; - if (basename(process.title) === 'openclaw-gateway') return 'openclaw-gateway'; - if (process.argv[2] === 'gateway') return 'launcher'; - if (basename(process.argv[1]) === 'openclaw-gateway') return 'openclaw-gateway'; - if (basename(process.argv[0]) === 'openclaw-gateway') return 'openclaw-gateway'; - return ''; + if (basename(process.argv0) === "openclaw-gateway") return "openclaw-gateway"; + if (basename(process.title) === "openclaw-gateway") return "openclaw-gateway"; + if (process.argv[2] === "gateway") return "launcher"; + if (basename(process.argv[1]) === "openclaw-gateway") return "openclaw-gateway"; + if (basename(process.argv[0]) === "openclaw-gateway") return "openclaw-gateway"; + return ""; } if (!gatewayProcessFlavor()) return; process.nextTick(maybeLogRuntimeConfigDiagnostics); - var STARTUP_GRACE_MS = Number(process.env.NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS || '') || 15000; + var STARTUP_GRACE_MS = Number(process.env.NEMOCLAW_TELEGRAM_STARTUP_GRACE_MS || "") || 15000; var noStartupTimer = setTimeout(function () { if (providerStarted || startupProbeLogged) return; - var configPath = process.env.OPENCLAW_CONFIG_PATH || '/sandbox/.openclaw/openclaw.json'; + var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; try { - var fs = require('fs'); - var cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); + var fs = require("fs"); + var cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); var telegram = cfg && cfg.channels && cfg.channels.telegram; if (!telegram || telegram.enabled === false) return; var accounts = telegram.accounts || {}; @@ -336,10 +385,10 @@ return; } emit( - '[telegram] [default] bridge did not start within ' + + "[telegram] [default] bridge did not start within " + Math.round(STARTUP_GRACE_MS / 1000) + - 's; check channels.telegram.enabled, plugin entries, and gateway log' + "s; check channels.telegram.enabled, plugin entries, and gateway log", ); }, STARTUP_GRACE_MS); - if (typeof noStartupTimer.unref === 'function') noStartupTimer.unref(); + if (typeof noStartupTimer.unref === "function") noStartupTimer.unref(); })(); diff --git a/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js b/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts similarity index 56% rename from src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js rename to src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts index e713bad16e..f510bac0c2 100644 --- a/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js +++ b/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts @@ -1,19 +1,20 @@ +// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// wechat-diagnostics.js — adds runtime breadcrumbs for the +// wechat-diagnostics.ts — adds runtime breadcrumbs for the // @tencent-weixin/openclaw-weixin channel without changing channel behavior. -// Mirrors telegram-diagnostics.js: surfaces a single "provider ready" line +// Mirrors telegram-diagnostics.ts: surfaces a single "provider ready" line // once iLink answers a CGI call, and prints an annotated line if an agent // turn fails after the WeChat bridge has connected so operators can tell // "channel up, inference broken" apart from "channel never connected". (function () { - 'use strict'; + "use strict"; if (process.__nemoclawWechatDiagnosticsInstalled) return; try { - Object.defineProperty(process, '__nemoclawWechatDiagnosticsInstalled', { value: true }); + Object.defineProperty(process, "__nemoclawWechatDiagnosticsInstalled", { value: true }); } catch (_e) { process.__nemoclawWechatDiagnosticsInstalled = true; } @@ -24,16 +25,16 @@ var inDiagnosticWrite = false; function sanitize(value) { - var text = String(value || ''); + var text = String(value || ""); // iLink puts the bot token in URL query params (?bot_token=...) and // sometimes in JSON bodies; redact both shapes. Keep the parameter name // visible so an operator can still see the request shape. - text = text.replace(/(bot_token=)[^&\s"']+/gi, '$1'); - text = text.replace(/("bot_token"\s*:\s*")[^"]+/gi, '$1'); - text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, 'Bearer '); + text = text.replace(/(bot_token=)[^&\s"']+/gi, "$1"); + text = text.replace(/("bot_token"\s*:\s*")[^"]+/gi, "$1"); + text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, "Bearer "); text = text.replace( /\b(api[_-]?key|token|authorization|wechat[_-]?bot[_-]?token)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi, - '$1$2' + "$1$2", ); return text; } @@ -44,7 +45,7 @@ if (inDiagnosticWrite) return; inDiagnosticWrite = true; try { - originalStderrWrite(line + '\n'); + originalStderrWrite(line + "\n"); } finally { inDiagnosticWrite = false; } @@ -53,28 +54,28 @@ function describeRequest(arg1, arg2) { var url = null; var opts = null; - if (typeof arg1 === 'string' || arg1 instanceof URL) { + if (typeof arg1 === "string" || arg1 instanceof URL) { try { url = new URL(String(arg1)); } catch (_e) { url = null; } - if (arg2 && typeof arg2 === 'object' && typeof arg2 !== 'function') opts = arg2; - } else if (arg1 && typeof arg1 === 'object') { + if (arg2 && typeof arg2 === "object" && typeof arg2 !== "function") opts = arg2; + } else if (arg1 && typeof arg1 === "object") { opts = arg1; } - var hostname = ''; - var pathStr = ''; + var hostname = ""; + var pathStr = ""; if (url) { - hostname = url.hostname || ''; - pathStr = (url.pathname || '') + (url.search || ''); + hostname = url.hostname || ""; + pathStr = (url.pathname || "") + (url.search || ""); } if (opts) { - hostname = String(opts.hostname || opts.host || hostname || ''); - pathStr = String(opts.path || pathStr || ''); + hostname = String(opts.hostname || opts.host || hostname || ""); + pathStr = String(opts.path || pathStr || ""); } - if (hostname.indexOf(':') !== -1) hostname = hostname.split(':')[0]; + if (hostname.indexOf(":") !== -1) hostname = hostname.split(":")[0]; return { hostname: hostname, path: pathStr }; } @@ -85,38 +86,42 @@ function isWechatHost(hostname) { if (!hostname) return false; return ( - hostname === 'weixin.qq.com' || - hostname.endsWith('.weixin.qq.com') || - hostname === 'wechat.com' || - hostname.endsWith('.wechat.com') + hostname === "weixin.qq.com" || + hostname.endsWith(".weixin.qq.com") || + hostname === "wechat.com" || + hostname.endsWith(".wechat.com") ); } function accountIdFromEnv() { var raw = process.env.WECHAT_ACCOUNT_ID; - if (typeof raw !== 'string') return 'default'; + if (typeof raw !== "string") return "default"; var trimmed = raw.trim(); - return trimmed || 'default'; + return trimmed || "default"; } function maybeLogWechatReady(info, statusCode) { if (readyLogged) return; if (!info || !isWechatHost(info.hostname)) return; - if (info.path.indexOf('/ilink/bot/') !== 0 && info.path.indexOf('/ilink/bot') !== 0) return; + if (info.path.indexOf("/ilink/bot/") !== 0 && info.path.indexOf("/ilink/bot") !== 0) return; if (Number(statusCode) < 200 || Number(statusCode) >= 300) return; providerStarted = true; readyLogged = true; - emit('[wechat] [' + accountIdFromEnv() + '] provider ready (iLink reachable; agent replies use inference.local)'); + emit( + "[wechat] [" + + accountIdFromEnv() + + "] provider ready (iLink reachable; agent replies use inference.local)", + ); } function wrapHttp(mod, methodName) { var original = mod[methodName]; - if (typeof original !== 'function') return; + if (typeof original !== "function") return; mod[methodName] = function () { var info = describeRequest(arguments[0], arguments[1]); var req = original.apply(this, arguments); - if (isWechatHost(info.hostname) && req && typeof req.once === 'function') { - req.once('response', function (res) { + if (isWechatHost(info.hostname) && req && typeof req.once === "function") { + req.once("response", function (res) { maybeLogWechatReady(info, res && res.statusCode); }); } @@ -127,25 +132,36 @@ process.stderr.write = function (chunk, _encoding, _cb) { var ret = originalStderrWrite.apply(process.stderr, arguments); if (!inDiagnosticWrite && !inferenceLogged) { - var text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk || ''); + var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); if (!providerStarted && /\[wechat\]\s*\[[^\]]+\]\s*starting provider\b/i.test(text)) { providerStarted = true; } - if (providerStarted && /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text)) { + if ( + providerStarted && + /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(text) + ) { inferenceLogged = true; - var line = text.split(/\r?\n/).find(function (entry) { - return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test(entry); - }) || text; - emit('[wechat] [' + accountIdFromEnv() + '] agent turn failed after provider startup; inference error: ' + sanitize(line).slice(0, 600)); + var line = + text.split(/\r?\n/).find(function (entry) { + return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test( + entry, + ); + }) || text; + emit( + "[wechat] [" + + accountIdFromEnv() + + "] agent turn failed after provider startup; inference error: " + + sanitize(line).slice(0, 600), + ); } } return ret; }; - var http = require('http'); - var https = require('https'); - wrapHttp(http, 'request'); - wrapHttp(http, 'get'); - wrapHttp(https, 'request'); - wrapHttp(https, 'get'); + var http = require("http"); + var https = require("https"); + wrapHttp(http, "request"); + wrapHttp(http, "get"); + wrapHttp(https, "request"); + wrapHttp(https, "get"); })(); diff --git a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.js b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts similarity index 87% rename from src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.js rename to src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts index 7a1e5c3e06..dd126249f4 100644 --- a/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.js +++ b/src/lib/messaging/channels/whatsapp/runtime/whatsapp-qr-compact.ts @@ -1,7 +1,8 @@ +// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// whatsapp-qr-compact.js — force compact, scan-friendly QR rendering during +// whatsapp-qr-compact.ts — force compact, scan-friendly QR rendering during // in-sandbox WhatsApp pairing. // // THE BUG (NemoClaw#4522): a WhatsApp Web Linked-Devices pairing payload is a @@ -45,21 +46,21 @@ // Ref: https://github.com/NVIDIA/NemoClaw/issues/4522 (function () { - 'use strict'; + "use strict"; if (process.__nemoclawWhatsappQrCompactInstalled) return; try { - Object.defineProperty(process, '__nemoclawWhatsappQrCompactInstalled', { value: true }); + Object.defineProperty(process, "__nemoclawWhatsappQrCompactInstalled", { value: true }); } catch (_e) { process.__nemoclawWhatsappQrCompactInstalled = true; } - var Module = require('module'); + var Module = require("module"); var origLoad = Module._load; function markPatched(mod) { try { - Object.defineProperty(mod, '__nemoclawCompactPatched', { value: true }); + Object.defineProperty(mod, "__nemoclawCompactPatched", { value: true }); } catch (_e) { mod.__nemoclawCompactPatched = true; } @@ -76,15 +77,21 @@ // inherited toString — and needlessly mutate them). The package main exposes // its own toString + create; the submodules do not have an own toString. function isQrcodePackage(mod) { - return hasOwn(mod, 'toString') && typeof mod.toString === 'function' && - typeof mod.create === 'function'; + return ( + hasOwn(mod, "toString") && + typeof mod.toString === "function" && + typeof mod.create === "function" + ); } // `qrcode-terminal` package: exposes its own generate(text, opts, cb) and, // unlike `qrcode`, has no create(). function isQrcodeTerminalPackage(mod) { - return hasOwn(mod, 'generate') && typeof mod.generate === 'function' && - typeof mod.create !== 'function'; + return ( + hasOwn(mod, "generate") && + typeof mod.generate === "function" && + typeof mod.create !== "function" + ); } function patchQrcode(mod) { @@ -92,12 +99,12 @@ var origToString = mod.toString; mod.toString = function (text, opts, cb) { // Support toString(text, cb) and toString(text, opts, cb) / (text, opts). - if (typeof opts === 'function') { + if (typeof opts === "function") { cb = opts; opts = undefined; } var merged = {}; - if (opts && typeof opts === 'object') { + if (opts && typeof opts === "object") { for (var key in opts) { if (Object.prototype.hasOwnProperty.call(opts, key)) merged[key] = opts[key]; } @@ -106,7 +113,7 @@ // to "utf8" in the qrcode package, but the WhatsApp path always passes // "terminal" explicitly; force small there and leave every other type // (svg/png/utf8 data URIs used elsewhere) exactly as the caller asked. - if (merged.type === 'terminal') { + if (merged.type === "terminal") { merged.small = true; } return origToString.call(this, text, merged, cb); @@ -119,12 +126,12 @@ if (mod.__nemoclawCompactPatched) return mod; var origGenerate = mod.generate; mod.generate = function (text, opts, cb) { - if (typeof opts === 'function') { + if (typeof opts === "function") { cb = opts; opts = undefined; } var merged = {}; - if (opts && typeof opts === 'object') { + if (opts && typeof opts === "object") { for (var key in opts) { if (Object.prototype.hasOwnProperty.call(opts, key)) merged[key] = opts[key]; } @@ -142,7 +149,7 @@ // `import("qrcode")` arrives here as the resolved absolute path // (…/qrcode/lib/index.js), so match on the path segment too, not just the // bare specifier. - if (typeof request === 'string' && request.indexOf('qrcode') !== -1) { + if (typeof request === "string" && request.indexOf("qrcode") !== -1) { try { if (isQrcodePackage(loaded)) return patchQrcode(loaded); if (isQrcodeTerminalPackage(loaded)) return patchQrcodeTerminal(loaded); diff --git a/test/e2e-scenario/live/whatsapp-qr-compact.test.ts b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts index b41f8ca538..5f27ab0c9b 100644 --- a/test/e2e-scenario/live/whatsapp-qr-compact.test.ts +++ b/test/e2e-scenario/live/whatsapp-qr-compact.test.ts @@ -27,7 +27,7 @@ const PRELOAD = path.join( "channels", "whatsapp", "runtime", - "whatsapp-qr-compact.js", + "whatsapp-qr-compact.ts", ); const INSTALL_TIMEOUT_MS = 180_000; const PROBE_TIMEOUT_MS = 30_000; diff --git a/test/local-slack-auth-test.sh b/test/local-slack-auth-test.sh index b76195578b..15fbb31656 100755 --- a/test/local-slack-auth-test.sh +++ b/test/local-slack-auth-test.sh @@ -16,7 +16,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -GUARD_SOURCE="$SCRIPT_DIR/../src/lib/messaging/channels/slack/runtime/slack-channel-guard.js" +GUARD_SOURCE="$SCRIPT_DIR/../src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts" PASS=0 FAIL=0 diff --git a/test/nemoclaw-start-telegram-runtime.test.ts b/test/nemoclaw-start-telegram-runtime.test.ts index feba408ba5..6e9806bdcd 100644 --- a/test/nemoclaw-start-telegram-runtime.test.ts +++ b/test/nemoclaw-start-telegram-runtime.test.ts @@ -12,7 +12,7 @@ const START_SCRIPT = path.join(import.meta.dirname, "..", "scripts", "nemoclaw-s const TELEGRAM_RUNTIME_PRELOAD = path.join( import.meta.dirname, "..", - "src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js", + "src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts", ); function messagingRuntimeSetupSection( diff --git a/test/nemoclaw-start.test.ts b/test/nemoclaw-start.test.ts index c07326d91c..ad3b1ac4fa 100644 --- a/test/nemoclaw-start.test.ts +++ b/test/nemoclaw-start.test.ts @@ -136,9 +136,9 @@ function startScriptHeredoc(src: string, marker: string): string { if (preload) return fs.readFileSync(path.join(PRELOAD_SCRIPTS, preload), "utf-8"); const channelPreload = marker === "SLACK_GUARD_EOF" - ? ["slack", "slack-channel-guard.js"] + ? ["slack", "slack-channel-guard.ts"] : marker === "TELEGRAM_DIAGNOSTICS_EOF" - ? ["telegram", "telegram-diagnostics.js"] + ? ["telegram", "telegram-diagnostics.ts"] : undefined; expect(channelPreload).toBeTruthy(); return fs.readFileSync( @@ -1473,7 +1473,7 @@ ${body}`, const scriptPath = path.join(tmpDir, "run.sh"); fs.mkdirSync(sourcePrefix, { recursive: true }); fs.copyFileSync( - path.join(CHANNEL_RUNTIME_SCRIPTS, "slack", "runtime", "slack-channel-guard.js"), + path.join(CHANNEL_RUNTIME_SCRIPTS, "slack", "runtime", "slack-channel-guard.ts"), guardSource, ); const runtimeValue = { diff --git a/test/openclaw-slack-deny-feedback-patch.test.ts b/test/openclaw-slack-deny-feedback-patch.test.ts index 80700a420a..b1aa59ec9f 100644 --- a/test/openclaw-slack-deny-feedback-patch.test.ts +++ b/test/openclaw-slack-deny-feedback-patch.test.ts @@ -16,7 +16,7 @@ const SLACK_GUARD = path.join( "channels", "slack", "runtime", - "slack-channel-guard.js", + "slack-channel-guard.ts", ); // Minimal stand-in for the compiled @openclaw/slack prepare module: a denying diff --git a/test/telegram-diagnostics.test.ts b/test/telegram-diagnostics.test.ts index 0457a248c6..d2f952f846 100644 --- a/test/telegram-diagnostics.test.ts +++ b/test/telegram-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Unit tests for src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.js. +// Unit tests for src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts. // // The diagnostics preload mutates global state on require (process.stderr, // http.request / https.request); each scenario runs in its own child Node @@ -27,7 +27,7 @@ const DIAGNOSTICS_PATH = path.join( "channels", "telegram", "runtime", - "telegram-diagnostics.js", + "telegram-diagnostics.ts", ); function runDriver(driverBody: string, env: Record = {}) { diff --git a/test/wechat-diagnostics.test.ts b/test/wechat-diagnostics.test.ts index eafe9b2246..eb816bbac8 100644 --- a/test/wechat-diagnostics.test.ts +++ b/test/wechat-diagnostics.test.ts @@ -2,7 +2,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // -// Unit tests for src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.js. +// Unit tests for src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts. // // The script is a self-contained IIFE that mutates process.stderr.write, // http.request, http.get, https.request, and https.get globally on require — @@ -26,7 +26,7 @@ const DIAGNOSTICS_PATH = path.join( "channels", "wechat", "runtime", - "wechat-diagnostics.js", + "wechat-diagnostics.ts", ); function runDriver(driverBody: string, env: Record = {}) { diff --git a/test/whatsapp-qr-compact.test.ts b/test/whatsapp-qr-compact.test.ts index ba5664f98f..8152a2cbf6 100644 --- a/test/whatsapp-qr-compact.test.ts +++ b/test/whatsapp-qr-compact.test.ts @@ -18,7 +18,7 @@ const PRELOAD_SOURCE = path.join( "channels", "whatsapp", "runtime", - "whatsapp-qr-compact.js", + "whatsapp-qr-compact.ts", ); // The WhatsApp pairing QR is rendered by the `qrcode` package (bundled inside From 9fc314131ba989e0314f574d5f1b5dc8e844b995 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 10:43:19 +0700 Subject: [PATCH 15/23] fix(messaging): patch Slack ESM helper imports --- .../slack/runtime/slack-channel-guard.ts | 62 ++++++++++++++----- ...openclaw-slack-deny-feedback-patch.test.ts | 54 +++++++++++++--- 2 files changed, 95 insertions(+), 21 deletions(-) diff --git a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts index 2568dc575e..031af3f88e 100644 --- a/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts +++ b/src/lib/messaging/channels/slack/runtime/slack-channel-guard.ts @@ -225,31 +225,65 @@ } if (next.indexOf("async function " + HELPER_MARKER + "(") === -1) { - var anchor = "async function prepareSlackMessage(params) {"; - if (next.indexOf(anchor) === -1) { + var prepareAnchor = /((?:export\s+)?async function prepareSlackMessage\(params\) \{)/; + if (!prepareAnchor.test(next)) { throw new Error("OpenClaw Slack prepareSlackMessage definition not found in " + filename); } - next = next.replace(anchor, buildDeniedMentionFeedbackHelperSource() + anchor); + next = next.replace(prepareAnchor, buildDeniedMentionFeedbackHelperSource() + "$1"); } return next; } + function fileNameFromModuleUrl(urlValue) { + if (typeof urlValue !== "string" || !urlValue.startsWith("file:")) return ""; + try { + return require("url").fileURLToPath(urlValue); + } catch (_e) { + return ""; + } + } + + function sourceToText(source) { + if (typeof source === "string") return source; + if (typeof Buffer !== "undefined") { + if (Buffer.isBuffer(source)) return source.toString("utf8"); + if (source instanceof Uint8Array) return Buffer.from(source).toString("utf8"); + if (source instanceof ArrayBuffer) return Buffer.from(source).toString("utf8"); + } + return null; + } + function installSlackDenyFeedbackPatch() { var Module = require("module"); var fs = require("fs"); var originalJsLoader = Module._extensions && Module._extensions[".js"]; - if (typeof originalJsLoader !== "function") return; - - Module._extensions[".js"] = function nemoclawSlackJsLoader(mod, filename) { - if (isOpenClawSlackFile(filename)) { - var source = fs.readFileSync(filename, "utf8"); - var patched = patchSlackPrepareSource(source, filename); - if (patched !== source) { - return mod._compile(patched, filename); + if (typeof originalJsLoader === "function") { + Module._extensions[".js"] = function nemoclawSlackJsLoader(mod, filename) { + if (isOpenClawSlackFile(filename)) { + var source = fs.readFileSync(filename, "utf8"); + var patched = patchSlackPrepareSource(source, filename); + if (patched !== source) { + return mod._compile(patched, filename); + } } - } - return originalJsLoader.apply(this, arguments); - }; + return originalJsLoader.apply(this, arguments); + }; + } + + if (typeof Module.registerHooks === "function") { + Module.registerHooks({ + load: function nemoclawSlackLoadHook(urlValue, context, nextLoad) { + var result = nextLoad(urlValue, context); + var filename = fileNameFromModuleUrl(urlValue); + if (!isOpenClawSlackFile(filename)) return result; + var sourceText = sourceToText(result && result.source); + if (sourceText === null) return result; + var patched = patchSlackPrepareSource(sourceText, filename); + if (patched === sourceText) return result; + return Object.assign({}, result, { source: patched }); + }, + }); + } } installSlackDenyFeedbackPatch(); diff --git a/test/openclaw-slack-deny-feedback-patch.test.ts b/test/openclaw-slack-deny-feedback-patch.test.ts index b1aa59ec9f..4fc18f9b44 100644 --- a/test/openclaw-slack-deny-feedback-patch.test.ts +++ b/test/openclaw-slack-deny-feedback-patch.test.ts @@ -23,15 +23,16 @@ const SLACK_GUARD = path.join( // channel gate that mirrors the real dist's deny-log line and exposes the same // in-scope identifiers the patch references. function prepareModuleSource( - options: { withMentionState?: boolean; denyLine?: string } = {}, + options: { moduleType?: "commonjs" | "esm"; withMentionState?: boolean; denyLine?: string } = {}, ): string { + const moduleType = options.moduleType ?? "commonjs"; const withMentionState = options.withMentionState ?? true; const denyLine = options.denyLine ?? "logVerbose(`Blocked unauthorized slack sender ${senderId} (not in channel users)`);"; return [ "function logVerbose() {}", - "async function prepareSlackMessage(params) {", + `${moduleType === "esm" ? "export " : ""}async function prepareSlackMessage(params) {`, "\tconst { ctx, account, message, opts } = params;", "\tconst senderId = message.user;", withMentionState @@ -49,21 +50,25 @@ function prepareModuleSource( "\t}", "\treturn { prepared: true };", "}", - "module.exports = { prepareSlackMessage };", + moduleType === "commonjs" ? "module.exports = { prepareSlackMessage };" : "", "", ].join("\n"); } function writeSlackPackage( root: string, - options: { withMentionState?: boolean; denyLine?: string } = {}, + options: { moduleType?: "commonjs" | "esm"; withMentionState?: boolean; denyLine?: string } = {}, ): string { const pkgDir = path.join(root, "node_modules", "@openclaw", "slack"); const distDir = path.join(pkgDir, "dist"); fs.mkdirSync(distDir, { recursive: true }); fs.writeFileSync( path.join(pkgDir, "package.json"), - JSON.stringify({ name: "@openclaw/slack", version: "2026.5.27" }), + JSON.stringify({ + name: "@openclaw/slack", + version: "2026.5.27", + ...(options.moduleType === "esm" ? { type: "module" } : {}), + }), ); const prepareFile = path.join(distDir, "prepare-fixture.js"); fs.writeFileSync(prepareFile, prepareModuleSource(options)); @@ -72,12 +77,23 @@ function writeSlackPackage( type FeedbackCall = { method: string; channel?: string; user?: string; text?: string }; -function runGuardProbe(prepareFile: string, options: { requireGuardTwice?: boolean } = {}) { +function runGuardProbe( + prepareFile: string, + options: { loadMode?: "require" | "import"; requireGuardTwice?: boolean } = {}, +) { const script = ` const guard = ${JSON.stringify(SLACK_GUARD)}; require(guard); ${options.requireGuardTwice ? "require(guard);" : ""} -const { prepareSlackMessage } = require(process.env.PREPARE_FILE); +const { pathToFileURL } = require("node:url"); +let prepareSlackMessage; +async function loadPrepareSlackMessage() { + if (${JSON.stringify(options.loadMode ?? "require")} === "import") { + const module = await import(pathToFileURL(process.env.PREPARE_FILE).href); + return module.prepareSlackMessage; + } + return require(process.env.PREPARE_FILE).prepareSlackMessage; +} const calls = []; const client = { chat: { @@ -115,6 +131,7 @@ async function run(params) { return { result, calls: calls.slice() }; } (async () => { + prepareSlackMessage = await loadPrepareSlackMessage(); const output = { mention: await run({ opts: { source: "app_mention" } }), silent: await run({ opts: { source: "message" } }), @@ -219,6 +236,29 @@ describe("OpenClaw Slack denial-feedback patch", () => { } }); + it("injects bounded sender feedback for ESM imports of @openclaw/slack", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-esm-")); + const prepareFile = writeSlackPackage(tmp, { moduleType: "esm" }); + try { + const { result, output } = runGuardProbe(prepareFile, { loadMode: "import" }); + expect(result.status, `${result.stdout}${result.stderr}`).toBe(0); + expect(fs.readFileSync(prepareFile, "utf-8")).not.toContain( + "__nemoclawNotifyDeniedSlackMention", + ); + + const mention = output?.mention as { result: unknown; calls: FeedbackCall[] }; + expect(mention.result).toBeNull(); + expect(mention.calls).toHaveLength(1); + expect(mention.calls[0]).toMatchObject({ + method: "chat.postEphemeral", + channel: "C1", + user: "U999DENIED", + }); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + it("is idempotent across repeated runs", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-slack-deny-idem-")); const prepareFile = writeSlackPackage(tmp); From f7ba9f73b27679e154fb8fe53b830bf76aaaf663 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 10:53:34 +0700 Subject: [PATCH 16/23] fix(messaging): type channel diagnostics preloads --- .../telegram/runtime/telegram-diagnostics.ts | 180 +++++++++++------- .../wechat/runtime/wechat-diagnostics.ts | 77 +++++--- 2 files changed, 162 insertions(+), 95 deletions(-) diff --git a/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts b/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts index b25bddc9c5..c8b189bd8c 100644 --- a/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts +++ b/src/lib/messaging/channels/telegram/runtime/telegram-diagnostics.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -8,14 +7,36 @@ // channel is initializing; an agent-turn failure later can be an inference // provider failure through inference.local, not a Telegram Bot API failure. +type TelegramDiagnosticsProcess = NodeJS.Process & { + __nemoclawTelegramDiagnosticsInstalled?: boolean; +}; +type TelegramJsonObject = Record; +type TelegramRequestInfo = { hostname: string; path: string }; +type TelegramStderrWrite = (...args: unknown[]) => boolean; +type TelegramHttpModuleLike = Record; +type TelegramRequestLike = { + once(eventName: string, listener: (...args: unknown[]) => void): unknown; +}; +type TelegramResponseLike = { + on(eventName: string, listener: (...args: unknown[]) => void): unknown; + statusCode?: unknown; +}; +type TelegramHttpRequestLike = ( + this: unknown, + ...args: unknown[] +) => TelegramRequestLike | undefined; + (function () { "use strict"; - if (process.__nemoclawTelegramDiagnosticsInstalled) return; + var diagnosticsProcess = process as TelegramDiagnosticsProcess; + if (diagnosticsProcess.__nemoclawTelegramDiagnosticsInstalled) return; try { - Object.defineProperty(process, "__nemoclawTelegramDiagnosticsInstalled", { value: true }); + Object.defineProperty(diagnosticsProcess, "__nemoclawTelegramDiagnosticsInstalled", { + value: true, + }); } catch (_e) { - process.__nemoclawTelegramDiagnosticsInstalled = true; + diagnosticsProcess.__nemoclawTelegramDiagnosticsInstalled = true; } var providerStarted = false; @@ -28,7 +49,13 @@ var inboundUpdateLogged = false; var inDiagnosticWrite = false; - function sanitize(value) { + function asObject(value: unknown): TelegramJsonObject | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as TelegramJsonObject) + : null; + } + + function sanitize(value: unknown): string { var text = String(value || ""); text = text.replace(/\/bot[^/\s"']+/g, "/bot"); text = text.replace(/\/file\/bot[^/\s"']+/g, "/file/bot"); @@ -40,9 +67,10 @@ return text; } - var originalStderrWrite = process.stderr.write.bind(process.stderr); + var stderr = process.stderr as NodeJS.WriteStream & { write: TelegramStderrWrite }; + var originalStderrWrite = stderr.write.bind(stderr) as TelegramStderrWrite; - function emit(line) { + function emit(line: string): void { if (inDiagnosticWrite) return; inDiagnosticWrite = true; try { @@ -52,18 +80,18 @@ } } - function describeRequest(arg1, arg2) { - var url = null; - var opts = null; + function describeRequest(arg1: unknown, arg2: unknown): TelegramRequestInfo { + var url: URL | null = null; + var opts: TelegramJsonObject | null = null; if (typeof arg1 === "string" || arg1 instanceof URL) { try { url = new URL(String(arg1)); } catch (_e) { url = null; } - if (arg2 && typeof arg2 === "object" && typeof arg2 !== "function") opts = arg2; + opts = asObject(arg2); } else if (arg1 && typeof arg1 === "object") { - opts = arg1; + opts = asObject(arg1); } var hostname = ""; @@ -80,18 +108,18 @@ return { hostname: hostname, path: path }; } - function telegramApiMethod(info) { - if (!info || info.hostname !== "api.telegram.org") return; + function telegramApiMethod(info: TelegramRequestInfo): string { + if (info.hostname !== "api.telegram.org") return ""; var match = /\/(?:bot[^/]+\/)?([^/?]+)(?:\?|$)/.exec(info.path || ""); return match && match[1] ? match[1] : ""; } - function isTelegramStartupProbe(info) { + function isTelegramStartupProbe(info: TelegramRequestInfo): boolean { var method = telegramApiMethod(info); return method === "getUpdates" || method === "getMe" || method === "getWebhookInfo"; } - function maybeLogTelegramStartupProbe(info, statusCode) { + function maybeLogTelegramStartupProbe(info: TelegramRequestInfo, statusCode: unknown): void { if (!isTelegramStartupProbe(info)) return; providerStarted = true; var status = Number(statusCode); @@ -118,15 +146,19 @@ } } - function maybeLogTelegramStartupError(info, error) { + function maybeLogTelegramStartupError(info: TelegramRequestInfo, error: unknown): void { if (!isTelegramStartupProbe(info) || startupProbeLogged) return; providerStarted = true; startupProbeLogged = true; - var detail = error && (error.code || error.message) ? error.code || error.message : error; + var errorObject = asObject(error); + var detail = + errorObject && (errorObject.code || errorObject.message) + ? errorObject.code || errorObject.message + : error; emit("[telegram] [default] Bot API startup probe failed: " + sanitize(detail).slice(0, 300)); } - function maybeLogTelegramSendMessage(info, statusCode) { + function maybeLogTelegramSendMessage(info: TelegramRequestInfo, statusCode: unknown): void { if (sendMessageLogged || telegramApiMethod(info) !== "sendMessage") return; sendMessageLogged = true; emit( @@ -135,7 +167,7 @@ ); } - function senderAllowlistState(senderId) { + function senderAllowlistState(senderId: unknown): string { if (senderId === undefined || senderId === null) return "unknown"; var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; try { @@ -149,27 +181,27 @@ } } - function maybeLogTelegramInboundUpdate(info, body) { + function maybeLogTelegramInboundUpdate(info: TelegramRequestInfo, body: unknown): void { if (inboundUpdateLogged || telegramApiMethod(info) !== "getUpdates") return; - var payload = null; + var payload: TelegramJsonObject | null = null; try { - payload = JSON.parse(String(body || "")); + payload = asObject(JSON.parse(String(body || ""))); } catch (_e) { return; } if (!payload || payload.ok !== true || !Array.isArray(payload.result)) return; for (var i = 0; i < payload.result.length; i += 1) { - var update = payload.result[i]; - if (!update || typeof update !== "object") continue; + var update = asObject(payload.result[i]); + if (!update) continue; var message = - update.message || - update.edited_message || - update.channel_post || - update.edited_channel_post; - if (!message || typeof message !== "object") continue; + asObject(update.message) || + asObject(update.edited_message) || + asObject(update.channel_post) || + asObject(update.edited_channel_post); + if (!message) continue; inboundUpdateLogged = true; - var chat = message.chat && typeof message.chat === "object" ? message.chat : {}; - var from = message.from && typeof message.from === "object" ? message.from : {}; + var chat = asObject(message.chat) || {}; + var from = asObject(message.from) || {}; var chatType = typeof chat.type === "string" ? sanitize(chat.type) @@ -195,30 +227,32 @@ } } - function readTelegramAccount(config) { - if (!config || typeof config !== "object") return null; - var channel = config.channels && config.channels.telegram; - if (!channel || typeof channel !== "object") return null; - var accounts = channel.accounts; - if (!accounts || typeof accounts !== "object") return null; - var account = accounts.default || accounts.main; - if (!account || typeof account !== "object") { + function readTelegramAccount(config: unknown): TelegramJsonObject | null { + var root = asObject(config); + if (!root) return null; + var channels = asObject(root.channels); + var channel = channels ? asObject(channels.telegram) : null; + if (!channel) return null; + var accounts = asObject(channel.accounts); + if (!accounts) return null; + var account = asObject(accounts.default) || asObject(accounts.main); + if (!account) { var keys = Object.keys(accounts); - account = keys.length ? accounts[keys[0]] : null; + account = keys.length ? asObject(accounts[keys[0]]) : null; } - return account && typeof account === "object" ? account : null; + return account; } - function readTelegramBotToken(config) { + function readTelegramBotToken(config: unknown): string { var account = readTelegramAccount(config); return account && typeof account.botToken === "string" ? account.botToken : ""; } - function maybeLogRuntimeConfigDiagnostics() { + function maybeLogRuntimeConfigDiagnostics(): void { if (runtimeConfigLogged) return; runtimeConfigLogged = true; var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; - var account = null; + var account: TelegramJsonObject | null = null; try { var fs = require("fs"); account = readTelegramAccount(JSON.parse(fs.readFileSync(configPath, "utf8"))); @@ -244,7 +278,7 @@ } } - function maybeLogCredentialPlaceholderDiagnostics() { + function maybeLogCredentialPlaceholderDiagnostics(): void { if (credentialLogged) return; credentialLogged = true; var prefix = "openshell:resolve:env:"; @@ -272,31 +306,33 @@ } } - function wrapHttp(mod, methodName) { - var original = mod[methodName]; + function wrapHttp(mod: TelegramHttpModuleLike, methodName: string): void { + var original = mod[methodName] as TelegramHttpRequestLike | undefined; if (typeof original !== "function") return; - mod[methodName] = function () { - var info = describeRequest(arguments[0], arguments[1]); - var req = original.apply(this, arguments); - if (info && info.hostname === "api.telegram.org" && req && typeof req.once === "function") { + var originalRequest: TelegramHttpRequestLike = original; + mod[methodName] = function (this: unknown, ...args: unknown[]) { + var info = describeRequest(args[0], args[1]); + var req = originalRequest.apply(this, args); + if (info.hostname === "api.telegram.org" && req && typeof req.once === "function") { req.once("response", function (res) { - maybeLogTelegramStartupProbe(info, res && res.statusCode); - maybeLogTelegramSendMessage(info, res && res.statusCode); + var response = res as TelegramResponseLike | null; + maybeLogTelegramStartupProbe(info, response && response.statusCode); + maybeLogTelegramSendMessage(info, response && response.statusCode); if ( !inboundUpdateLogged && telegramApiMethod(info) === "getUpdates" && - res && - typeof res.on === "function" + response && + typeof response.on === "function" ) { - var responseChunks = []; + var responseChunks: string[] = []; var responseBytes = 0; - res.on("data", function (chunk) { + response.on("data", function (chunk) { if (responseBytes >= 65536) return; var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); responseBytes += Buffer.byteLength(text); if (responseBytes <= 65536) responseChunks.push(text); }); - res.on("end", function () { + response.on("end", function () { maybeLogTelegramInboundUpdate(info, responseChunks.join("")); }); } @@ -309,8 +345,9 @@ }; } - process.stderr.write = function (chunk, encoding, cb) { - var ret = originalStderrWrite.apply(process.stderr, arguments); + stderr.write = function (...args: unknown[]): boolean { + var chunk = args[0]; + var ret = originalStderrWrite(...args); if (!inDiagnosticWrite && !inferenceLogged) { var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); if (!providerStarted && /\[telegram\] \[default\] starting provider\b/i.test(text)) { @@ -322,7 +359,7 @@ ) { inferenceLogged = true; var line = - text.split(/\r?\n/).find(function (entry) { + text.split(/\r?\n/).find(function (entry: string) { return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test( entry, ); @@ -355,12 +392,14 @@ // this file; without the gate the timer would emit a false "bridge did // not start" line from every Node command even while the real gateway // bridge is healthy. Mirrors sandbox-safety-net.js's gatewayProcessFlavor. - function basename(value) { - return String(value || "") - .split(/[\\/]/) - .pop(); + function basename(value: unknown): string { + return ( + String(value || "") + .split(/[\\/]/) + .pop() || "" + ); } - function gatewayProcessFlavor() { + function gatewayProcessFlavor(): string { if (basename(process.argv0) === "openclaw-gateway") return "openclaw-gateway"; if (basename(process.title) === "openclaw-gateway") return "openclaw-gateway"; if (process.argv[2] === "gateway") return "launcher"; @@ -376,10 +415,11 @@ var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json"; try { var fs = require("fs"); - var cfg = JSON.parse(fs.readFileSync(configPath, "utf8")); - var telegram = cfg && cfg.channels && cfg.channels.telegram; + var cfg = asObject(JSON.parse(fs.readFileSync(configPath, "utf8"))); + var channels = cfg ? asObject(cfg.channels) : null; + var telegram = channels ? asObject(channels.telegram) : null; if (!telegram || telegram.enabled === false) return; - var accounts = telegram.accounts || {}; + var accounts = asObject(telegram.accounts) || {}; if (!Object.keys(accounts).length) return; } catch (_e) { return; diff --git a/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts b/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts index f510bac0c2..ce61b0a29e 100644 --- a/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts +++ b/src/lib/messaging/channels/wechat/runtime/wechat-diagnostics.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 // @@ -9,14 +8,32 @@ // turn fails after the WeChat bridge has connected so operators can tell // "channel up, inference broken" apart from "channel never connected". +type WechatDiagnosticsProcess = NodeJS.Process & { + __nemoclawWechatDiagnosticsInstalled?: boolean; +}; +type WechatJsonObject = Record; +type WechatRequestInfo = { hostname: string; path: string }; +type WechatStderrWrite = (...args: unknown[]) => boolean; +type WechatHttpModuleLike = Record; +type WechatRequestLike = { + once(eventName: string, listener: (...args: unknown[]) => void): unknown; +}; +type WechatResponseLike = { + statusCode?: unknown; +}; +type WechatHttpRequestLike = (this: unknown, ...args: unknown[]) => WechatRequestLike | undefined; + (function () { "use strict"; - if (process.__nemoclawWechatDiagnosticsInstalled) return; + var diagnosticsProcess = process as WechatDiagnosticsProcess; + if (diagnosticsProcess.__nemoclawWechatDiagnosticsInstalled) return; try { - Object.defineProperty(process, "__nemoclawWechatDiagnosticsInstalled", { value: true }); + Object.defineProperty(diagnosticsProcess, "__nemoclawWechatDiagnosticsInstalled", { + value: true, + }); } catch (_e) { - process.__nemoclawWechatDiagnosticsInstalled = true; + diagnosticsProcess.__nemoclawWechatDiagnosticsInstalled = true; } var providerStarted = false; @@ -24,7 +41,13 @@ var inferenceLogged = false; var inDiagnosticWrite = false; - function sanitize(value) { + function asObject(value: unknown): WechatJsonObject | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as WechatJsonObject) + : null; + } + + function sanitize(value: unknown): string { var text = String(value || ""); // iLink puts the bot token in URL query params (?bot_token=...) and // sometimes in JSON bodies; redact both shapes. Keep the parameter name @@ -39,9 +62,10 @@ return text; } - var originalStderrWrite = process.stderr.write.bind(process.stderr); + var stderr = process.stderr as NodeJS.WriteStream & { write: WechatStderrWrite }; + var originalStderrWrite = stderr.write.bind(stderr) as WechatStderrWrite; - function emit(line) { + function emit(line: string): void { if (inDiagnosticWrite) return; inDiagnosticWrite = true; try { @@ -51,18 +75,18 @@ } } - function describeRequest(arg1, arg2) { - var url = null; - var opts = null; + function describeRequest(arg1: unknown, arg2: unknown): WechatRequestInfo { + var url: URL | null = null; + var opts: WechatJsonObject | null = null; if (typeof arg1 === "string" || arg1 instanceof URL) { try { url = new URL(String(arg1)); } catch (_e) { url = null; } - if (arg2 && typeof arg2 === "object" && typeof arg2 !== "function") opts = arg2; + opts = asObject(arg2); } else if (arg1 && typeof arg1 === "object") { - opts = arg1; + opts = asObject(arg1); } var hostname = ""; @@ -83,7 +107,7 @@ // *.weixin.qq.com — and *.wechat.com (e.g. ilinkai.wechat.com) — so match // the suffix rather than a single host. We treat any successful 2xx hit // on a /ilink/bot/* path as "provider ready". - function isWechatHost(hostname) { + function isWechatHost(hostname: string): boolean { if (!hostname) return false; return ( hostname === "weixin.qq.com" || @@ -93,16 +117,16 @@ ); } - function accountIdFromEnv() { + function accountIdFromEnv(): string { var raw = process.env.WECHAT_ACCOUNT_ID; if (typeof raw !== "string") return "default"; var trimmed = raw.trim(); return trimmed || "default"; } - function maybeLogWechatReady(info, statusCode) { + function maybeLogWechatReady(info: WechatRequestInfo, statusCode: unknown): void { if (readyLogged) return; - if (!info || !isWechatHost(info.hostname)) return; + if (!isWechatHost(info.hostname)) return; if (info.path.indexOf("/ilink/bot/") !== 0 && info.path.indexOf("/ilink/bot") !== 0) return; if (Number(statusCode) < 200 || Number(statusCode) >= 300) return; providerStarted = true; @@ -114,23 +138,26 @@ ); } - function wrapHttp(mod, methodName) { - var original = mod[methodName]; + function wrapHttp(mod: WechatHttpModuleLike, methodName: string): void { + var original = mod[methodName] as WechatHttpRequestLike | undefined; if (typeof original !== "function") return; - mod[methodName] = function () { - var info = describeRequest(arguments[0], arguments[1]); - var req = original.apply(this, arguments); + var originalRequest: WechatHttpRequestLike = original; + mod[methodName] = function (this: unknown, ...args: unknown[]) { + var info = describeRequest(args[0], args[1]); + var req = originalRequest.apply(this, args); if (isWechatHost(info.hostname) && req && typeof req.once === "function") { req.once("response", function (res) { - maybeLogWechatReady(info, res && res.statusCode); + var response = res as WechatResponseLike | null; + maybeLogWechatReady(info, response && response.statusCode); }); } return req; }; } - process.stderr.write = function (chunk, _encoding, _cb) { - var ret = originalStderrWrite.apply(process.stderr, arguments); + stderr.write = function (...args: unknown[]): boolean { + var chunk = args[0]; + var ret = originalStderrWrite(...args); if (!inDiagnosticWrite && !inferenceLogged) { var text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || ""); if (!providerStarted && /\[wechat\]\s*\[[^\]]+\]\s*starting provider\b/i.test(text)) { @@ -142,7 +169,7 @@ ) { inferenceLogged = true; var line = - text.split(/\r?\n/).find(function (entry) { + text.split(/\r?\n/).find(function (entry: string) { return /Embedded agent failed before reply|LLM request failed|FailoverError/i.test( entry, ); From c535a6e25bd1a9dfaa40c8d57517a8f3b6efe208 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 11:24:01 +0700 Subject: [PATCH 17/23] fix(messaging): compile runtime preloads for sandbox image --- Dockerfile | 29 +++++++++++++++---- ...essaging-runtime-preload-packaging.test.ts | 24 +++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 test/messaging-runtime-preload-packaging.test.ts diff --git a/Dockerfile b/Dockerfile index e3010f908a..a2a5887fef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,7 +25,21 @@ COPY nemoclaw/src/ /opt/nemoclaw/src/ WORKDIR /opt/nemoclaw RUN npm ci && npm run build -# Stage 2: Runtime image — pull cached base from GHCR +# Stage 2: Build root TypeScript runtime preloads. +FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d AS runtime-preload-builder +ENV NPM_CONFIG_AUDIT=false \ + NPM_CONFIG_FUND=false \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + NPM_CONFIG_FETCH_RETRIES=5 \ + NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \ + NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \ + NPM_CONFIG_FETCH_TIMEOUT=300000 +WORKDIR /opt/nemoclaw-root +COPY package.json package-lock.json tsconfig.src.json /opt/nemoclaw-root/ +COPY src/ /opt/nemoclaw-root/src/ +RUN npm ci --ignore-scripts && ./node_modules/.bin/tsc -p tsconfig.src.json + +# Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 FROM ${BASE_IMAGE} ARG OPENCLAW_VERSION=2026.5.27 @@ -527,10 +541,10 @@ COPY scripts/nemoclaw-start.sh /usr/local/bin/nemoclaw-start # Copy NODE_OPTIONS preload modules to a Landlock-accessible path. OpenShell ≥0.0.36 # blocks /opt/nemoclaw-blueprint/ from non-root users, but the entrypoint # needs to read these files to install Node runtime preloads under /tmp. -# Channel runtime preloads are TypeScript source files constrained to the -# Node-executable JS subset; rename them to .js in the image for --require. +# Channel runtime preloads are authored as TypeScript and compiled in the +# runtime-preload-builder stage before being flattened by filename for --require. COPY nemoclaw-blueprint/scripts/*.js /usr/local/lib/nemoclaw/preloads/ -COPY src/lib/messaging/channels/*/runtime/*.ts /usr/local/lib/nemoclaw/preloads-ts/ +COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/ /usr/local/lib/nemoclaw/preloads-compiled-channels/ COPY scripts/codex-acp-wrapper.sh /usr/local/bin/nemoclaw-codex-acp COPY scripts/generate-openclaw-config.mts /scripts/generate-openclaw-config.mts COPY src/lib/messaging/ /src/lib/messaging/ @@ -542,8 +556,11 @@ RUN chmod 755 /usr/local/bin/nemoclaw-start /usr/local/bin/nemoclaw-codex-acp \ && chmod -R a+rX /src/lib/messaging \ && chmod 644 /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py \ /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py \ - && if [ -d /usr/local/lib/nemoclaw/preloads-ts ]; then find /usr/local/lib/nemoclaw/preloads-ts -type f -name '*.ts' -exec sh -c 'for file do cp "$file" "/usr/local/lib/nemoclaw/preloads/$(basename "$file" .ts).js"; done' sh {} +; fi \ - && rm -rf /usr/local/lib/nemoclaw/preloads-ts \ + && if [ -d /usr/local/lib/nemoclaw/preloads-compiled-channels ]; then \ + find /usr/local/lib/nemoclaw/preloads-compiled-channels -path '*/runtime/*.js' -type f \ + -exec sh -c 'for file do cp "$file" "/usr/local/lib/nemoclaw/preloads/$(basename "$file")"; done' sh {} +; \ + fi \ + && rm -rf /usr/local/lib/nemoclaw/preloads-compiled-channels \ && if [ -d /usr/local/lib/nemoclaw/preloads ]; then find /usr/local/lib/nemoclaw/preloads -type f -name '*.js' -exec chmod 644 {} +; fi \ && chmod 755 /usr/local/share/nemoclaw \ /usr/local/share/nemoclaw/openclaw-plugins \ diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts new file mode 100644 index 0000000000..bf4542d1ad --- /dev/null +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); +const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); + +describe("messaging runtime preload packaging", () => { + it("packages compiled preload JavaScript instead of raw TypeScript renamed to .js", () => { + expect(dockerfile).toContain("AS runtime-preload-builder"); + expect(dockerfile).toContain("./node_modules/.bin/tsc -p tsconfig.src.json"); + expect(dockerfile).toContain( + "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/", + ); + expect(dockerfile).toContain("-path '*/runtime/*.js'"); + expect(dockerfile).not.toContain( + "COPY src/lib/messaging/channels/*/runtime/*.ts /usr/local/lib/nemoclaw/preloads-ts/", + ); + expect(dockerfile).not.toContain('basename "$file" .ts'); + }); +}); From 9b85f894e5854150a602387d4d3a3f8f37b76c79 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 11:58:56 +0700 Subject: [PATCH 18/23] fix(messaging): compile runtime preloads narrowly --- Dockerfile | 18 ++++++----------- src/lib/sandbox/build-context.ts | 8 ++++++++ ...essaging-runtime-preload-packaging.test.ts | 13 ++++++++++-- test/sandbox-build-context.test.ts | 2 ++ tsconfig.runtime-preloads.json | 20 +++++++++++++++++++ 5 files changed, 47 insertions(+), 14 deletions(-) create mode 100644 tsconfig.runtime-preloads.json diff --git a/Dockerfile b/Dockerfile index a2a5887fef..c94f8d41a3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,19 +25,13 @@ COPY nemoclaw/src/ /opt/nemoclaw/src/ WORKDIR /opt/nemoclaw RUN npm ci && npm run build -# Stage 2: Build root TypeScript runtime preloads. -FROM node:22-trixie-slim@sha256:2d9f5c76c8f4dd36e8f253bee5d828a83a6c09f36188f0b0414325232e0b175d AS runtime-preload-builder -ENV NPM_CONFIG_AUDIT=false \ - NPM_CONFIG_FUND=false \ - NPM_CONFIG_UPDATE_NOTIFIER=false \ - NPM_CONFIG_FETCH_RETRIES=5 \ - NPM_CONFIG_FETCH_RETRY_MINTIMEOUT=20000 \ - NPM_CONFIG_FETCH_RETRY_MAXTIMEOUT=120000 \ - NPM_CONFIG_FETCH_TIMEOUT=300000 +# Stage 2: Build TypeScript messaging runtime preloads. +FROM builder AS runtime-preload-builder WORKDIR /opt/nemoclaw-root -COPY package.json package-lock.json tsconfig.src.json /opt/nemoclaw-root/ -COPY src/ /opt/nemoclaw-root/src/ -RUN npm ci --ignore-scripts && ./node_modules/.bin/tsc -p tsconfig.src.json +COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/ +COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/ +RUN ln -s /opt/nemoclaw/node_modules /opt/nemoclaw-root/node_modules \ + && /opt/nemoclaw/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json # Stage 3: Runtime image — pull cached base from GHCR # hadolint ignore=DL3006 diff --git a/src/lib/sandbox/build-context.ts b/src/lib/sandbox/build-context.ts index cea2d7ff35..e12a37a704 100644 --- a/src/lib/sandbox/build-context.ts +++ b/src/lib/sandbox/build-context.ts @@ -43,6 +43,10 @@ function stageLegacySandboxBuildContext( ): StagedBuildContext { const buildCtx = createBuildContextDir(tmpDir); fs.copyFileSync(path.join(rootDir, "Dockerfile"), path.join(buildCtx, "Dockerfile")); + fs.copyFileSync( + path.join(rootDir, "tsconfig.runtime-preloads.json"), + path.join(buildCtx, "tsconfig.runtime-preloads.json"), + ); fs.cpSync(path.join(rootDir, "nemoclaw"), path.join(buildCtx, "nemoclaw"), { recursive: true }); fs.cpSync(path.join(rootDir, "nemoclaw-blueprint"), path.join(buildCtx, "nemoclaw-blueprint"), { recursive: true, @@ -77,6 +81,10 @@ function stageOptimizedSandboxBuildContext( const stagedScriptsDir = path.join(buildCtx, "scripts"); fs.copyFileSync(path.join(rootDir, "Dockerfile"), stagedDockerfile); + fs.copyFileSync( + path.join(rootDir, "tsconfig.runtime-preloads.json"), + path.join(buildCtx, "tsconfig.runtime-preloads.json"), + ); fs.mkdirSync(stagedNemoclawDir, { recursive: true }); for (const fileName of [ diff --git a/test/messaging-runtime-preload-packaging.test.ts b/test/messaging-runtime-preload-packaging.test.ts index bf4542d1ad..9374c00007 100644 --- a/test/messaging-runtime-preload-packaging.test.ts +++ b/test/messaging-runtime-preload-packaging.test.ts @@ -9,13 +9,22 @@ const repoRoot = path.join(import.meta.dirname, ".."); const dockerfile = fs.readFileSync(path.join(repoRoot, "Dockerfile"), "utf8"); describe("messaging runtime preload packaging", () => { - it("packages compiled preload JavaScript instead of raw TypeScript renamed to .js", () => { + it("packages preload JavaScript compiled from TypeScript without requiring root npm metadata", () => { expect(dockerfile).toContain("AS runtime-preload-builder"); - expect(dockerfile).toContain("./node_modules/.bin/tsc -p tsconfig.src.json"); + expect(dockerfile).toContain("FROM builder AS runtime-preload-builder"); + expect(dockerfile).toContain("COPY tsconfig.runtime-preloads.json /opt/nemoclaw-root/"); + expect(dockerfile).toContain( + "COPY src/lib/messaging/channels/ /opt/nemoclaw-root/src/lib/messaging/channels/", + ); + expect(dockerfile).toContain( + "/opt/nemoclaw/node_modules/.bin/tsc -p tsconfig.runtime-preloads.json", + ); expect(dockerfile).toContain( "COPY --from=runtime-preload-builder /opt/nemoclaw-root/dist/lib/messaging/channels/", ); expect(dockerfile).toContain("-path '*/runtime/*.js'"); + expect(dockerfile).not.toContain("COPY package.json package-lock.json tsconfig.src.json"); + expect(dockerfile).not.toContain("npm ci --ignore-scripts"); expect(dockerfile).not.toContain( "COPY src/lib/messaging/channels/*/runtime/*.ts /usr/local/lib/nemoclaw/preloads-ts/", ); diff --git a/test/sandbox-build-context.test.ts b/test/sandbox-build-context.test.ts index a27655d17e..83c6feb6f5 100644 --- a/test/sandbox-build-context.test.ts +++ b/test/sandbox-build-context.test.ts @@ -30,6 +30,7 @@ describe("sandbox build context staging", () => { } writeFixture("Dockerfile"); + writeFixture("tsconfig.runtime-preloads.json", "{}\n"); for (const fileName of [ "package.json", "package-lock.json", @@ -229,6 +230,7 @@ describe("sandbox build context staging", () => { try { const { buildCtx, stagedDockerfile } = stageOptimizedSandboxBuildContext(repoRoot, tmpDir); expectDockerfileScriptCopiesExist(buildCtx, stagedDockerfile); + expect(fs.existsSync(path.join(buildCtx, "tsconfig.runtime-preloads.json"))).toBe(true); expect(fs.existsSync(path.join(buildCtx, "nemoclaw-blueprint", ".venv"))).toBe(false); expect(fs.existsSync(path.join(buildCtx, "nemoclaw-blueprint", "blueprint.yaml"))).toBe(true); expect( diff --git a/tsconfig.runtime-preloads.json b/tsconfig.runtime-preloads.json new file mode 100644 index 0000000000..86fa731588 --- /dev/null +++ b/tsconfig.runtime-preloads.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "declaration": false, + "declarationMap": false, + "sourceMap": false, + "outDir": "dist", + "rootDir": "src", + "noEmitOnError": true + }, + "include": ["src/lib/messaging/channels/*/runtime/*.ts"], + "exclude": [] +} From 754b5a221d15b963e1b1e072d29f9f37cea11452 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 18:43:52 +0700 Subject: [PATCH 19/23] refactor(messaging): finish channel manifest cleanup --- docs/reference/troubleshooting.mdx | 4 +- src/commands/credentials/reset.ts | 7 +-- src/lib/messaging/channels/metadata.test.ts | 62 +++++++++++++++++++++ src/lib/messaging/channels/metadata.ts | 22 ++++++++ src/lib/messaging/persistence.ts | 25 ++++----- src/lib/state/openclaw-config-merge.test.ts | 2 + src/lib/state/openclaw-config-merge.ts | 8 ++- src/lib/verify-deployment.ts | 5 +- test/credentials-cli-command.test.ts | 2 + 9 files changed, 114 insertions(+), 23 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 27367e32a7..389b6a9177 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -815,8 +815,8 @@ Run the equivalent host-side command instead: ```bash $$nemoclaw channels list -$$nemoclaw channels add -$$nemoclaw channels remove +$$nemoclaw channels add +$$nemoclaw channels remove ``` `channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. diff --git a/src/commands/credentials/reset.ts b/src/commands/credentials/reset.ts index aaccf3f4f3..8f9dde2f88 100644 --- a/src/commands/credentials/reset.ts +++ b/src/commands/credentials/reset.ts @@ -2,12 +2,11 @@ // SPDX-License-Identifier: Apache-2.0 import { Args } from "@oclif/core"; +import { runOpenshellProviderCommand } from "../../lib/actions/global"; +import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; import { CLI_NAME } from "../../lib/cli/branding"; import { yesFlag } from "../../lib/cli/common-flags"; import { NemoClawCommand } from "../../lib/cli/nemoclaw-oclif-command"; - -import { runOpenshellProviderCommand } from "../../lib/actions/global"; -import { OPENSHELL_OPERATION_TIMEOUT_MS } from "../../lib/adapters/openshell/timeouts"; import { isBridgeProviderName, recoverGatewayOrExit } from "../../lib/credentials/command-support"; import { prompt as askPrompt } from "../../lib/credentials/store"; @@ -40,7 +39,7 @@ export default class CredentialsResetCommand extends NemoClawCommand { if (isBridgeProviderName(key)) { this.failWithLines([ ` '${key}' is a per-sandbox messaging bridge, not a credential.`, - ` Use \`${CLI_NAME} channels remove \` to retire`, + ` Use \`${CLI_NAME} channels remove \` to retire`, " the integration (it tears down the bridge provider and rebuilds the sandbox),", ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, ]); diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 98a5319cb8..6a416b1a68 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -13,9 +13,11 @@ import { getMessagingPolicyPresetValidationWarnings, getMessagingProviderSuffixesByChannel, listAvailableMessagingChannelIds, + listMessagingChannelsWithoutCredentials, listMessagingConfigEnvKeys, listMessagingPackageInstallSpecs, listMessagingProviderNamesForChannel, + listOpenClawManagedChannelNames, listOpenClawRuntimeChannelMetadata, listRequiredCreateTimeMessagingPolicyPresetNames, } from "./metadata"; @@ -58,6 +60,7 @@ describe("built-in messaging channel metadata", () => { "demo-slack-bridge", "demo-slack-app", ]); + expect(listMessagingChannelsWithoutCredentials()).toEqual(["whatsapp"]); }); it("resolves config env keys and aliases from manifest inputs", () => { @@ -153,6 +156,42 @@ describe("built-in messaging channel metadata", () => { ]); }); + it("derives OpenClaw managed channel names from manifest render fragments", () => { + const manifests: ChannelManifest[] = [ + { + ...manifestWithPreset("matrix", "matrix"), + render: [ + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "channels.matrix", value: { enabled: true } }, + }, + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "channels.matrix.rooms", value: ["#ops"] }, + }, + { + kind: "json-fragment", + agent: "hermes", + target: "~/.hermes/config.yaml", + fragment: { path: "channels.hermesOnly", value: { enabled: true } }, + }, + { + kind: "json-fragment", + agent: "openclaw", + target: "openclaw.json", + fragment: { path: "plugins.entries.matrix", value: { enabled: true } }, + }, + ], + }, + ]; + + expect(listOpenClawManagedChannelNames({ manifests })).toEqual(["matrix"]); + }); + it("lists package installs from manifest agent package metadata", () => { const manifests: ChannelManifest[] = [ { @@ -171,6 +210,29 @@ describe("built-in messaging channel metadata", () => { expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual(["openclaw"]); expect(listMessagingPackageInstallSpecs({ manifests, agent: "hermes" })).toEqual([]); }); + + it("lists channels that do not declare gateway credentials", () => { + const manifests: ChannelManifest[] = [ + { + ...manifestWithPreset("matrix", "matrix"), + credentials: [ + { + id: "matrixToken", + sourceInput: "token", + providerName: "{sandboxName}-matrix-bridge", + providerEnvKey: "MATRIX_TOKEN", + placeholder: "openshell:resolve:env:MATRIX_TOKEN", + }, + ], + }, + { + ...manifestWithPreset("sessionOnly", "session-only"), + credentials: [], + }, + ]; + + expect(listMessagingChannelsWithoutCredentials({ manifests })).toEqual(["sessionOnly"]); + }); }); function manifestWithPreset(id: string, preset: ChannelPolicyPresetReference): ChannelManifest { diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 41b9494d8b..211bc3b6b3 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -143,6 +143,14 @@ export function listMessagingProviderNamesForChannel( ); } +export function listMessagingChannelsWithoutCredentials( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return selectManifests(options) + .filter((manifest) => manifest.credentials.length === 0) + .map((manifest) => manifest.id); +} + export function listMessagingConfigEnvMetadata( options: MessagingManifestMetadataOptions = {}, ): MessagingConfigEnvMetadata[] { @@ -262,6 +270,20 @@ export function getMessagingPolicyPresetValidationWarnings( return result; } +export function listOpenClawManagedChannelNames( + options: MessagingManifestMetadataOptions = {}, +): string[] { + return uniqueStrings( + selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => + manifest.render.flatMap((render) => { + if (render.agent !== "openclaw" || render.kind !== "json-fragment") return []; + const match = render.fragment.path.match(/^channels[.]([^.]+)(?:[.]|$)/); + return match?.[1] ? [match[1]] : []; + }), + ), + ); +} + export function listOpenClawRuntimeChannelMetadata( options: MessagingManifestMetadataOptions = {}, ): OpenClawRuntimeChannelMetadata[] { diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index f5a6001578..3558cea347 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -5,7 +5,6 @@ import { createBuiltInChannelManifestRegistry, createBuiltInRenderTemplateResolver, } from "./channels"; -import { buildWechatSeedOpenClawAccountOutputs } from "./channels/wechat/hooks/seed-openclaw-account"; import { planCredentialBindings } from "./compiler/engines/credential-binding-engine"; import { planHealthChecks } from "./compiler/engines/health-check-engine"; import { planNetworkPolicy } from "./compiler/engines/policy-resolver"; @@ -40,6 +39,7 @@ import type { SandboxMessagingRuntimeSetupPlan, } from "./manifest"; import type { MessagingHookInputMap, MessagingHookOutputMap } from "./hooks"; +import { BUILT_IN_MESSAGING_HOOK_REGISTRY, runMessagingHookSync } from "./hooks"; export type PersistedSandboxMessagingInputReference = Pick< SandboxMessagingInputReference, @@ -514,8 +514,7 @@ function hookBuildSteps( ["build-arg", "build-file", "package-install"].includes(output.kind), ); if (outputs.length === 0) return []; - const hookOutputs = - active && channel ? buildKnownHookOutputs(plan, manifest, hook, channel) : {}; + const hookOutputs = active && channel ? buildHookOutputs(plan, manifest, hook, channel) : {}; return outputs.map((output) => ({ channelId: manifest.id, kind: output.kind as "build-arg" | "build-file" | "package-install", @@ -532,22 +531,20 @@ function hookBuildSteps( }); } -function buildKnownHookOutputs( +function buildHookOutputs( plan: SandboxMessagingPlan, - _manifest: ChannelManifest, + manifest: ChannelManifest, hook: ChannelHookSpec, channel: SandboxMessagingChannelPlan, ): MessagingHookOutputMap { - if (hook.handler === "wechat.seedOpenClawAccount") { - try { - return buildWechatSeedOpenClawAccountOutputs( - selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), - ); - } catch { - return {}; - } + try { + return runMessagingHookSync(hook, BUILT_IN_MESSAGING_HOOK_REGISTRY, { + channelId: manifest.id, + inputs: selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), + }).outputs; + } catch { + return {}; } - return {}; } function hasFullChannelShape( diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index a9224e0146..fcc732511b 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -63,6 +63,7 @@ describe("mergeOpenClawRestoredConfig", () => { { channels: { telegram: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, + whatsapp: { accounts: { default: { session: "stale" } } }, matrix: { accounts: { default: { room: "#ops" } } }, }, }, @@ -74,6 +75,7 @@ describe("mergeOpenClawRestoredConfig", () => { channels: { matrix: { accounts: { default: { room: "#ops" } } } }, }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); + expect((merged as { channels: Record }).channels.whatsapp).toBeUndefined(); }); it("preserves backup provider and plugin entries when current entry maps are absent", () => { diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 7fb622b07b..2878daa041 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -2,6 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { isRecord } from "../core/json-types.js"; +import { listOpenClawManagedChannelNames } from "../messaging/channels/index.js"; + +const LEGACY_MANAGED_OPENCLAW_CHANNELS = ["wechat", "openclaw-weixin"] as const; +const MANAGED_OPENCLAW_CHANNEL_NAMES = [ + ...new Set([...listOpenClawManagedChannelNames(), ...LEGACY_MANAGED_OPENCLAW_CHANNELS]), +] as const; /** * Ownership contract for restoring OpenClaw's durable openclaw.json snapshot. @@ -15,7 +21,7 @@ export const OPENCLAW_CONFIG_RESTORE_OWNERSHIP = { /** Fresh rebuild output owns these whole top-level runtime sections. */ runtimeSections: ["gateway", "proxy", "diagnostics"], /** NemoClaw-managed channels reflect current add/remove/start/stop state. */ - managedChannels: ["discord", "slack", "telegram", "whatsapp", "wechat", "openclaw-weixin"], + managedChannels: MANAGED_OPENCLAW_CHANNEL_NAMES, /** Current generated entries win by id; backup-only user entries are kept. */ currentGeneratedEntryMaps: ["plugins.entries"], /** diff --git a/src/lib/verify-deployment.ts b/src/lib/verify-deployment.ts index 974a259a14..8bab1f59ee 100644 --- a/src/lib/verify-deployment.ts +++ b/src/lib/verify-deployment.ts @@ -19,6 +19,7 @@ import type { DashboardDeliveryChain } from "./dashboard/contract"; import { compareChannelSets, type RuntimeChannelStatus } from "./channel-runtime-status"; +import { listMessagingChannelsWithoutCredentials } from "./messaging/channels"; import { getMessagingProviderNamesForChannel } from "./onboard/messaging-reuse"; // ── Types ──────────────────────────────────────────────────────────── @@ -128,7 +129,7 @@ function defaultSleep(ms: number): Promise { // HTTP status codes that indicate the gateway process is alive. // 401 = device auth is enabled but the gateway is running. const GATEWAY_ALIVE_CODES = new Set([200, 401]); -const TOKENLESS_MESSAGING_CHANNELS = new Set(["whatsapp"]); +const CREDENTIALLESS_MESSAGING_CHANNELS = new Set(listMessagingChannelsWithoutCredentials()); // Gateway-failure hint: cover both layers the probe could be failing at. // The probe runs curl inside the sandbox against the in-sandbox OpenClaw @@ -321,7 +322,7 @@ function verifyMessagingBridges( const missingProviders: string[] = []; for (const channel of channels) { const providerNames = getMessagingProviderNamesForChannel(sandboxName, channel); - if (providerNames.length === 0 && TOKENLESS_MESSAGING_CHANNELS.has(channel)) { + if (providerNames.length === 0 && CREDENTIALLESS_MESSAGING_CHANNELS.has(channel)) { continue; } const expectedProviders = providerNames.length > 0 ? providerNames : [channel]; diff --git a/test/credentials-cli-command.test.ts b/test/credentials-cli-command.test.ts index 50e40f7e19..ba6c1545e5 100644 --- a/test/credentials-cli-command.test.ts +++ b/test/credentials-cli-command.test.ts @@ -245,6 +245,8 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("per-sandbox messaging bridge"); expect(output.stderr).toContain("channels remove"); + expect(output.stderr).toContain("channels remove "); + expect(output.stderr).not.toContain("channels remove { From 5e0648a23bbc5d920eaca6339cd20e3b66853640 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 21:20:34 +0700 Subject: [PATCH 20/23] docs(troubleshooting): restore channel examples --- docs/reference/troubleshooting.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/reference/troubleshooting.mdx b/docs/reference/troubleshooting.mdx index 389b6a9177..27367e32a7 100644 --- a/docs/reference/troubleshooting.mdx +++ b/docs/reference/troubleshooting.mdx @@ -815,8 +815,8 @@ Run the equivalent host-side command instead: ```bash $$nemoclaw channels list -$$nemoclaw channels add -$$nemoclaw channels remove +$$nemoclaw channels add +$$nemoclaw channels remove ``` `channels add` registers credentials with the OpenShell gateway and `channels remove` clears them. From 81356c18e58ab7dff6465c4e7a3452a01d8f2367 Mon Sep 17 00:00:00 2001 From: San Dang Date: Mon, 15 Jun 2026 21:25:24 +0700 Subject: [PATCH 21/23] fix(messaging): address manifest cleanup review --- src/commands/credentials/reset.ts | 2 +- src/lib/messaging/persistence.ts | 12 ++++-------- test/credentials-cli-command.test.ts | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/src/commands/credentials/reset.ts b/src/commands/credentials/reset.ts index 8f9dde2f88..ab8f78c6aa 100644 --- a/src/commands/credentials/reset.ts +++ b/src/commands/credentials/reset.ts @@ -39,7 +39,7 @@ export default class CredentialsResetCommand extends NemoClawCommand { if (isBridgeProviderName(key)) { this.failWithLines([ ` '${key}' is a per-sandbox messaging bridge, not a credential.`, - ` Use \`${CLI_NAME} channels remove \` to retire`, + ` Use \`${CLI_NAME} channels remove \` to retire`, " the integration (it tears down the bridge provider and rebuilds the sandbox),", ` or \`${CLI_NAME} channels stop <…>\` to pause it without clearing tokens.`, ]); diff --git a/src/lib/messaging/persistence.ts b/src/lib/messaging/persistence.ts index 3558cea347..9e5ca17128 100644 --- a/src/lib/messaging/persistence.ts +++ b/src/lib/messaging/persistence.ts @@ -537,14 +537,10 @@ function buildHookOutputs( hook: ChannelHookSpec, channel: SandboxMessagingChannelPlan, ): MessagingHookOutputMap { - try { - return runMessagingHookSync(hook, BUILT_IN_MESSAGING_HOOK_REGISTRY, { - channelId: manifest.id, - inputs: selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), - }).outputs; - } catch { - return {}; - } + return runMessagingHookSync(hook, BUILT_IN_MESSAGING_HOOK_REGISTRY, { + channelId: manifest.id, + inputs: selectHookInputs(buildHookInputMap(channel, plan.credentialBindings), hook.inputs), + }).outputs; } function hasFullChannelShape( diff --git a/test/credentials-cli-command.test.ts b/test/credentials-cli-command.test.ts index ba6c1545e5..c1fba100d0 100644 --- a/test/credentials-cli-command.test.ts +++ b/test/credentials-cli-command.test.ts @@ -245,7 +245,7 @@ describe("credentials oclif commands", () => { expect(output.stderr).toContain("per-sandbox messaging bridge"); expect(output.stderr).toContain("channels remove"); - expect(output.stderr).toContain("channels remove "); + expect(output.stderr).toContain("channels remove "); expect(output.stderr).not.toContain("channels remove Date: Tue, 16 Jun 2026 00:00:26 +0700 Subject: [PATCH 22/23] refactor(messaging): drop legacy OpenClaw channel restore list --- src/lib/state/openclaw-config-merge.test.ts | 6 +++++- src/lib/state/openclaw-config-merge.ts | 5 +---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index fcc732511b..1333585239 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -64,6 +64,7 @@ describe("mergeOpenClawRestoredConfig", () => { channels: { telegram: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, whatsapp: { accounts: { default: { session: "stale" } } }, + wechat: { accounts: { default: { accountId: "legacy" } } }, matrix: { accounts: { default: { room: "#ops" } } }, }, }, @@ -72,7 +73,10 @@ describe("mergeOpenClawRestoredConfig", () => { expect(merged).toMatchObject({ gateway: { auth: { token: "fresh-token" } }, - channels: { matrix: { accounts: { default: { room: "#ops" } } } }, + channels: { + wechat: { accounts: { default: { accountId: "legacy" } } }, + matrix: { accounts: { default: { room: "#ops" } } }, + }, }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); expect((merged as { channels: Record }).channels.whatsapp).toBeUndefined(); diff --git a/src/lib/state/openclaw-config-merge.ts b/src/lib/state/openclaw-config-merge.ts index 2878daa041..bab09e701a 100644 --- a/src/lib/state/openclaw-config-merge.ts +++ b/src/lib/state/openclaw-config-merge.ts @@ -4,10 +4,7 @@ import { isRecord } from "../core/json-types.js"; import { listOpenClawManagedChannelNames } from "../messaging/channels/index.js"; -const LEGACY_MANAGED_OPENCLAW_CHANNELS = ["wechat", "openclaw-weixin"] as const; -const MANAGED_OPENCLAW_CHANNEL_NAMES = [ - ...new Set([...listOpenClawManagedChannelNames(), ...LEGACY_MANAGED_OPENCLAW_CHANNELS]), -] as const; +const MANAGED_OPENCLAW_CHANNEL_NAMES = listOpenClawManagedChannelNames(); /** * Ownership contract for restoring OpenClaw's durable openclaw.json snapshot. From 6b76e17808ef649a92a44027c42b5be607bfe0de Mon Sep 17 00:00:00 2001 From: San Dang Date: Tue, 16 Jun 2026 00:57:07 +0700 Subject: [PATCH 23/23] fix(messaging): declare OpenClaw channel ownership in manifests --- .../messaging/channels/discord/manifest.ts | 1 + src/lib/messaging/channels/manifests.test.ts | 2 ++ src/lib/messaging/channels/metadata.test.ts | 20 +++++++++++++++++-- src/lib/messaging/channels/metadata.ts | 6 +----- src/lib/messaging/channels/slack/manifest.ts | 1 + .../messaging/channels/telegram/manifest.ts | 1 + src/lib/messaging/channels/wechat/manifest.ts | 1 + .../messaging/channels/whatsapp/manifest.ts | 1 + src/lib/messaging/manifest/types.ts | 15 +++++++++++++- src/lib/state/openclaw-config-merge.test.ts | 4 ++++ 10 files changed, 44 insertions(+), 8 deletions(-) diff --git a/src/lib/messaging/channels/discord/manifest.ts b/src/lib/messaging/channels/discord/manifest.ts index 5ed60b46d1..1913f5b27c 100644 --- a/src/lib/messaging/channels/discord/manifest.ts +++ b/src/lib/messaging/channels/discord/manifest.ts @@ -181,6 +181,7 @@ export const discordManifest = { ], runtime: { openclaw: { + channelName: "discord", visibility: { configKeys: ["discord"], logPatterns: ["discord"], diff --git a/src/lib/messaging/channels/manifests.test.ts b/src/lib/messaging/channels/manifests.test.ts index cb772a4488..d12299f297 100644 --- a/src/lib/messaging/channels/manifests.test.ts +++ b/src/lib/messaging/channels/manifests.test.ts @@ -175,7 +175,9 @@ function expectOpenClawRuntimeVisibility( manifest: ChannelManifest, configKeys: readonly string[], logPatterns: readonly string[], + channelName = configKeys[0], ): void { + expect(manifest.runtime?.openclaw?.channelName).toBe(channelName); expect(manifest.runtime?.openclaw?.visibility).toEqual({ configKeys, logPatterns, diff --git a/src/lib/messaging/channels/metadata.test.ts b/src/lib/messaging/channels/metadata.test.ts index 6a416b1a68..7eaf2f8d58 100644 --- a/src/lib/messaging/channels/metadata.test.ts +++ b/src/lib/messaging/channels/metadata.test.ts @@ -103,6 +103,13 @@ describe("built-in messaging channel metadata", () => { expect(getMessagingPolicyPresetValidationWarnings().discord).toContain( "https://discord.com/api/v10/gateway or validate the configured", ); + expect(listOpenClawManagedChannelNames()).toEqual([ + "telegram", + "discord", + "openclaw-weixin", + "slack", + "whatsapp", + ]); expect( Object.fromEntries( listOpenClawRuntimeChannelMetadata().map((entry) => [entry.channelId, entry.configKeys]), @@ -156,7 +163,7 @@ describe("built-in messaging channel metadata", () => { ]); }); - it("derives OpenClaw managed channel names from manifest render fragments", () => { + it("derives OpenClaw managed channel names from explicit runtime metadata", () => { const manifests: ChannelManifest[] = [ { ...manifestWithPreset("matrix", "matrix"), @@ -186,10 +193,19 @@ describe("built-in messaging channel metadata", () => { fragment: { path: "plugins.entries.matrix", value: { enabled: true } }, }, ], + runtime: { + openclaw: { + channelName: "matrix-runtime", + visibility: { + configKeys: ["matrix-runtime"], + logPatterns: ["matrix"], + }, + }, + }, }, ]; - expect(listOpenClawManagedChannelNames({ manifests })).toEqual(["matrix"]); + expect(listOpenClawManagedChannelNames({ manifests })).toEqual(["matrix-runtime"]); }); it("lists package installs from manifest agent package metadata", () => { diff --git a/src/lib/messaging/channels/metadata.ts b/src/lib/messaging/channels/metadata.ts index 211bc3b6b3..62cdb3d28d 100644 --- a/src/lib/messaging/channels/metadata.ts +++ b/src/lib/messaging/channels/metadata.ts @@ -275,11 +275,7 @@ export function listOpenClawManagedChannelNames( ): string[] { return uniqueStrings( selectManifests({ ...options, agent: "openclaw" }).flatMap((manifest) => - manifest.render.flatMap((render) => { - if (render.agent !== "openclaw" || render.kind !== "json-fragment") return []; - const match = render.fragment.path.match(/^channels[.]([^.]+)(?:[.]|$)/); - return match?.[1] ? [match[1]] : []; - }), + manifest.runtime?.openclaw?.channelName ? [manifest.runtime.openclaw.channelName] : [], ), ); } diff --git a/src/lib/messaging/channels/slack/manifest.ts b/src/lib/messaging/channels/slack/manifest.ts index 2403792f29..dd16c109d2 100644 --- a/src/lib/messaging/channels/slack/manifest.ts +++ b/src/lib/messaging/channels/slack/manifest.ts @@ -149,6 +149,7 @@ export const slackManifest = { ], runtime: { openclaw: { + channelName: "slack", visibility: { configKeys: ["slack"], logPatterns: ["slack"], diff --git a/src/lib/messaging/channels/telegram/manifest.ts b/src/lib/messaging/channels/telegram/manifest.ts index 8b9a1a66cf..3981612c2e 100644 --- a/src/lib/messaging/channels/telegram/manifest.ts +++ b/src/lib/messaging/channels/telegram/manifest.ts @@ -160,6 +160,7 @@ export const telegramManifest = { ], runtime: { openclaw: { + channelName: "telegram", visibility: { configKeys: ["telegram"], logPatterns: ["telegram"], diff --git a/src/lib/messaging/channels/wechat/manifest.ts b/src/lib/messaging/channels/wechat/manifest.ts index 62ebeed145..2f8145bdec 100644 --- a/src/lib/messaging/channels/wechat/manifest.ts +++ b/src/lib/messaging/channels/wechat/manifest.ts @@ -109,6 +109,7 @@ export const wechatManifest = { ], runtime: { openclaw: { + channelName: "openclaw-weixin", visibility: { configKeys: ["openclaw-weixin"], logPatterns: ["wechat", "openclaw-weixin"], diff --git a/src/lib/messaging/channels/whatsapp/manifest.ts b/src/lib/messaging/channels/whatsapp/manifest.ts index 85d069dabf..c8bc08cc55 100644 --- a/src/lib/messaging/channels/whatsapp/manifest.ts +++ b/src/lib/messaging/channels/whatsapp/manifest.ts @@ -87,6 +87,7 @@ export const whatsappManifest = { ], runtime: { openclaw: { + channelName: "whatsapp", visibility: { configKeys: ["whatsapp"], logPatterns: ["whatsapp"], diff --git a/src/lib/messaging/manifest/types.ts b/src/lib/messaging/manifest/types.ts index 780b536056..1dcac50034 100644 --- a/src/lib/messaging/manifest/types.ts +++ b/src/lib/messaging/manifest/types.ts @@ -42,7 +42,7 @@ export interface ChannelManifest { /** Policy presets needed when this channel is active. */ readonly policyPresets?: readonly ChannelPolicyPresetReference[]; readonly render: readonly ChannelRenderSpec[]; - readonly runtime?: Partial>; + readonly runtime?: ChannelRuntimeByAgentSpec; readonly agentPackages?: readonly ChannelAgentPackageSpec[]; readonly state: ChannelStateSpec; readonly hooks: readonly ChannelHookSpec[]; @@ -143,6 +143,19 @@ export interface ChannelRenderFragmentSpec { readonly value: MessagingSerializableValue; } +/** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ +export interface ChannelRuntimeByAgentSpec + extends Partial> { + readonly openclaw?: ChannelOpenClawRuntimeSpec; + readonly hermes?: ChannelRuntimeSpec; +} + +/** OpenClaw-specific runtime metadata. */ +export interface ChannelOpenClawRuntimeSpec extends ChannelRuntimeSpec { + /** Key owned under openclaw.json `channels`, when this manifest manages one. */ + readonly channelName?: string; +} + /** Agent-runtime metadata consumed by shared runtime setup and diagnostics. */ export interface ChannelRuntimeSpec { readonly visibility?: ChannelRuntimeVisibilitySpec; diff --git a/src/lib/state/openclaw-config-merge.test.ts b/src/lib/state/openclaw-config-merge.test.ts index 1333585239..7652019284 100644 --- a/src/lib/state/openclaw-config-merge.test.ts +++ b/src/lib/state/openclaw-config-merge.test.ts @@ -65,6 +65,7 @@ describe("mergeOpenClawRestoredConfig", () => { telegram: { accounts: { default: { token: "openshell:resolve:env:v111_TOKEN" } } }, whatsapp: { accounts: { default: { session: "stale" } } }, wechat: { accounts: { default: { accountId: "legacy" } } }, + "openclaw-weixin": { accounts: { default: { accountId: "stale-current" } } }, matrix: { accounts: { default: { room: "#ops" } } }, }, }, @@ -80,6 +81,9 @@ describe("mergeOpenClawRestoredConfig", () => { }); expect((merged as { channels: Record }).channels.telegram).toBeUndefined(); expect((merged as { channels: Record }).channels.whatsapp).toBeUndefined(); + expect( + (merged as { channels: Record }).channels["openclaw-weixin"], + ).toBeUndefined(); }); it("preserves backup provider and plugin entries when current entry maps are absent", () => {