refactor(messaging): finish manifest channel migration (#5338)

## Summary
This PR completes the messaging channel decoupling by routing
channel-specific lifecycle behavior through manifests, hooks, and shared
messaging helpers. It removes remaining core assumptions about concrete
Telegram, WeChat, Slack, Discord, and WhatsApp implementations so core
flows can operate from the manifest registry.

## Close
Closes #4397 

## Changes
- Adds manifest/applier hook phase coverage plus channel metadata and
status helpers under `src/lib/messaging/`.
- Moves runtime preload, health/status, policy preset, credential,
rebuild, snapshot, and diagnostics call sites toward manifest-backed
channel registries.
- Migrates Slack socket-mode gateway conflict handling and channel
runtime details into channel hooks/manifests without hard-coding Slack
in core conflict code.
- Replaces legacy WeChat-specific onboard config plumbing with
manifest-backed messaging config and shared validation paths.
- Updates migration notes and expands tests for hook phases, manifest
metadata, plan validation, channel status, startup/runtime envs, and CLI
fixture stability.

## Verification
### OpenClaw
#### Telegram
<img width="1486" height="272" alt="image"
src="https://github.com/user-attachments/assets/36c29f2f-9c1c-4a6b-b7ff-1bf897a4fecb"
/>
#### Discord 
<img width="1422" height="374" alt="image"
src="https://github.com/user-attachments/assets/c8a0cc7a-d080-41c2-bdc8-ad2b858871b1"
/>
#### WeChat
<img width="3006" height="928" alt="image"
src="https://github.com/user-attachments/assets/f9d5f825-c8b1-4549-8d07-32f358859ee6"
/>

#### Slack
<img width="1014" height="626" alt="image"
src="https://github.com/user-attachments/assets/91cd738e-01d1-48f5-b74f-0d6babd84f85"
/>

### Hermes
#### Telegram
<img width="1010" height="210" alt="image"
src="https://github.com/user-attachments/assets/04057e62-77ae-4af4-9ed4-577739d50c18"
/>
#### Discord
<img width="1944" height="752" alt="image"
src="https://github.com/user-attachments/assets/0b8411dc-9514-4da0-a68d-f743122567de"
/>
#### Slack
<img width="1018" height="648" alt="image"
src="https://github.com/user-attachments/assets/afb942a4-7928-4be7-8efb-d0cedfb2eefa"
/>
#### WeChat
<img width="2990" height="1024" alt="image"
src="https://github.com/user-attachments/assets/5f45e0f8-f72c-4b6d-9ef9-ac8ee0ced788"
/>

## Type of Change
- [x] Code change (feature, bug fix, or refactor)
- [ ] Code change with doc updates
- [ ] Doc only (prose changes, no code sample modifications)
- [ ] Doc only (includes code sample changes)

## Verification
- [x] `npx prek run --all-files` passes
- [x] `npm test` passes
- [x] Tests added or updated for new or changed behavior
- [x] No secrets, API keys, or credentials committed
- [ ] Docs updated for user-facing behavior changes
- [ ] `npm run docs` builds without warnings (doc changes only)
- [ ] Doc pages follow the [style
guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md)
(doc changes only)
- [ ] New doc pages include SPDX header and frontmatter (new pages only)

---
Signed-off-by: San Dang <sdang@nvidia.com>

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

# Release Notes

* **New Features**
* Expanded messaging diagnostics/health checks with spec-driven,
per-channel reporting and deeper probe support.
* Added manifest-driven messaging runtime setup (env aliases, secret
scans, and runtime preloads).
* Introduced new Slack and Telegram messaging status hooks (gateway
conflict/status and bridge health).

* **Improvements**
* Generalized channel status/doctor output to follow built-in
diagnostics.
* Refactored channel enablement conflict detection to use pre-enable
hook checks.
* Improved messaging plan persistence/rehydration and runtime visibility
metadata.

* **Bug Fixes**
* Strengthened bridge startup detection, gateway log probing, and
conflict handling behavior.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
San Dang 2026-06-15 22:28:48 +07:00 committed by GitHub
parent f4f3c586bd
commit ff6cfde1fa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
162 changed files with 9071 additions and 3490 deletions

View file

@ -60,7 +60,7 @@ jobs:
allowed_jobs=""
free_standing_scenarios_csv=""
free_standing_scenario_jobs_csv=""
declare -A seen_inventory_keys=()
seen_inventory_keys=","
while IFS= read -r line || [ -n "${line}" ]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
@ -73,11 +73,11 @@ jobs:
fi
inventory_key="${BASH_REMATCH[1]}"
inventory_value="${BASH_REMATCH[2]}"
if [[ -n "${seen_inventory_keys[${inventory_key}]:-}" ]]; then
if [[ "${seen_inventory_keys}" == *",${inventory_key},"* ]]; then
echo "::error::free-standing workflow inventory must not redefine ${inventory_key}" >&2
exit 1
fi
seen_inventory_keys["${inventory_key}"]=1
seen_inventory_keys="${seen_inventory_keys}${inventory_key},"
case "${inventory_key}" in
allowed_jobs) allowed_jobs="${inventory_value}" ;;
free_standing_scenarios_csv) free_standing_scenarios_csv="${inventory_value}" ;;
@ -90,34 +90,34 @@ jobs:
exit 1
fi
done
declare -A seen_allowed_jobs=()
seen_allowed_jobs=","
IFS=',' read -r -a allowed_job_entries <<< "${allowed_jobs}"
for job in "${allowed_job_entries[@]}"; do
if [[ ! "${job}" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "::error::free-standing workflow inventory contains invalid job id" >&2
exit 1
fi
if [[ -n "${seen_allowed_jobs[${job}]:-}" ]]; then
if [[ "${seen_allowed_jobs}" == *",${job},"* ]]; then
echo "::error::free-standing workflow inventory repeats job id" >&2
exit 1
fi
seen_allowed_jobs["${job}"]=1
seen_allowed_jobs="${seen_allowed_jobs}${job},"
done
declare -A seen_free_standing_scenarios=()
seen_free_standing_scenarios=","
IFS=',' read -r -a free_standing_scenario_entries <<< "${free_standing_scenarios_csv}"
for scenario in "${free_standing_scenario_entries[@]}"; do
if [[ ! "${scenario}" =~ ^[A-Za-z0-9_-]+$ ]]; then
echo "::error::free-standing workflow inventory contains invalid scenario id" >&2
exit 1
fi
if [[ -n "${seen_free_standing_scenarios[${scenario}]:-}" ]]; then
if [[ "${seen_free_standing_scenarios}" == *",${scenario},"* ]]; then
echo "::error::free-standing workflow inventory repeats scenario id" >&2
exit 1
fi
seen_free_standing_scenarios["${scenario}"]=1
seen_free_standing_scenarios="${seen_free_standing_scenarios}${scenario},"
done
IFS=',' read -r -a scenario_job_entries <<< "${free_standing_scenario_jobs_csv}"
declare -A seen_scenario_mappings=()
seen_scenario_mappings=","
derived_free_standing_scenarios=()
for entry in "${scenario_job_entries[@]}"; do
if [[ ! "${entry}" =~ ^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$ ]]; then
@ -126,12 +126,12 @@ jobs:
fi
scenario="${entry%%:*}"
job="${entry#*:}"
if [[ -n "${seen_scenario_mappings[${scenario}]:-}" ]]; then
if [[ "${seen_scenario_mappings}" == *",${scenario},"* ]]; then
echo "::error::free-standing workflow inventory repeats scenario mapping" >&2
exit 1
fi
seen_scenario_mappings["${scenario}"]=1
if [[ -z "${seen_allowed_jobs[${job}]:-}" ]]; then
seen_scenario_mappings="${seen_scenario_mappings}${scenario},"
if [[ "${seen_allowed_jobs}" != *",${job},"* ]]; then
echo "::error::Free-standing scenario maps to unknown job" >&2
exit 1
fi

View file

@ -25,7 +25,15 @@ 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 TypeScript messaging runtime preloads.
FROM builder AS runtime-preload-builder
WORKDIR /opt/nemoclaw-root
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
FROM ${BASE_IMAGE}
ARG OPENCLAW_VERSION=2026.5.27
@ -92,10 +100,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.
#
@ -528,8 +534,11 @@ 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.
# 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 --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/
@ -541,6 +550,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-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 \
@ -644,6 +658,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
@ -687,21 +708,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

View file

@ -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."

View file

@ -8,9 +8,9 @@
"test/channels-add-preset.test.ts": 1871,
"test/generate-openclaw-config.test.ts": 1989,
"test/install-preflight.test.ts": 4207,
"test/nemoclaw-start.test.ts": 5230,
"test/nemoclaw-start.test.ts": 5162,
"test/onboard-messaging.test.ts": 2063,
"test/onboard-selection.test.ts": 6891,
"test/onboard-selection.test.ts": 6888,
"test/onboard.test.ts": 4774,
"test/policies.test.ts": 2753
}

View file

@ -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

View file

@ -1,116 +0,0 @@
// 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
// 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
// inference, chat, and TUI alongside the failed Slack channel.
//
// This preload wraps process.emit for Slack-specific process-level failures
// and consumes those events before later OpenClaw handlers can treat the
// provider startup failure as fatal. Non-Slack failures pass through to the
// original event machinery unchanged.
//
// Ref: https://github.com/NVIDIA/NemoClaw/issues/2340
(function () {
'use strict';
// 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-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',
];
function mentionsSlackHost(value) {
var tokens = String(value || '').split(/\s+/);
for (var i = 0; i < tokens.length; i++) {
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;
} catch (_e) {
if (/^(?:[a-z0-9-]+\.)*slack\.com(?::\d+)?$/i.test(candidate)) return true;
}
}
return false;
}
function isSlackRejection(reason) {
if (!reason) return false;
// Check error code (Slack SDK sets .code on its errors)
var code = reason.code || '';
for (var i = 0; i < SLACK_AUTH_ERRORS.length; i++) {
if (code === SLACK_AUTH_ERRORS[i]) return true;
}
// Check error message
var msg = String(reason.message || reason);
for (var j = 0; j < SLACK_AUTH_MESSAGES.length; j++) {
if (msg.indexOf(SLACK_AUTH_MESSAGES[j]) !== -1) return true;
}
// Check stack trace for @slack/ packages
var stack = reason.stack || '';
if (stack.indexOf('@slack/') !== -1 || stack.indexOf('slack-') !== -1) {
return true;
}
// Check for proxy/network errors targeting Slack domains.
// When the network policy blocks or rejects connections to Slack
// servers, the error comes from the HTTP client (CONNECT tunnel
// failure), not from @slack/ code. The stack won't contain @slack/
// but the error message or URL may reference the Slack hostname.
if (mentionsSlackHost(msg)) {
return true;
}
return false;
}
function handleSlackError(reason, source) {
if (isSlackRejection(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'
);
return true; // handled
}
return false;
}
if (process.__nemoclawSlackChannelGuardInstalled) return;
try {
Object.defineProperty(process, '__nemoclawSlackChannelGuardInstalled', { value: true });
} catch (_e) {
process.__nemoclawSlackChannelGuardInstalled = true;
}
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;
}
return origEmit.apply(this, arguments);
};
})();

View file

@ -1,345 +0,0 @@
// 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
// 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';
if (process.__nemoclawTelegramDiagnosticsInstalled) return;
try {
Object.defineProperty(process, '__nemoclawTelegramDiagnosticsInstalled', { value: true });
} catch (_e) {
process.__nemoclawTelegramDiagnosticsInstalled = true;
}
var providerStarted = false;
var readyLogged = false;
var startupProbeLogged = false;
var inferenceLogged = false;
var credentialLogged = false;
var runtimeConfigLogged = false;
var sendMessageLogged = false;
var inboundUpdateLogged = false;
var inDiagnosticWrite = false;
function sanitize(value) {
var text = String(value || '');
text = text.replace(/\/bot[^/\s"']+/g, '/bot<redacted>');
text = text.replace(/\/file\/bot[^/\s"']+/g, '/file/bot<redacted>');
text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, 'Bearer <redacted>');
text = text.replace(
/\b(api[_-]?key|token|authorization)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi,
'$1$2<redacted>'
);
return text;
}
var originalStderrWrite = process.stderr.write.bind(process.stderr);
function emit(line) {
if (inDiagnosticWrite) return;
inDiagnosticWrite = true;
try {
originalStderrWrite(line + '\n');
} finally {
inDiagnosticWrite = false;
}
}
function describeRequest(arg1, arg2) {
var url = null;
var opts = 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;
} else if (arg1 && typeof arg1 === 'object') {
opts = arg1;
}
var hostname = '';
var path = '';
if (url) {
hostname = url.hostname || '';
path = (url.pathname || '') + (url.search || '');
}
if (opts) {
hostname = String(opts.hostname || opts.host || hostname || '');
path = String(opts.path || path || '');
}
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] : '';
}
function isTelegramStartupProbe(info) {
var method = telegramApiMethod(info);
return method === 'getUpdates' || method === 'getMe' || method === 'getWebhookInfo';
}
function maybeLogTelegramStartupProbe(info, statusCode) {
if (!isTelegramStartupProbe(info)) return;
providerStarted = true;
var status = Number(statusCode);
if (status >= 200 && status < 300) {
if (readyLogged) return;
readyLogged = true;
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');
return;
}
if (status >= 300) {
emit('[telegram] [default] Bot API startup probe returned HTTP ' + status);
}
}
function maybeLogTelegramStartupError(info, error) {
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));
}
function maybeLogTelegramSendMessage(info, statusCode) {
if (sendMessageLogged || telegramApiMethod(info) !== 'sendMessage') return;
sendMessageLogged = true;
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';
try {
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';
} catch (_e) {
return 'unknown';
}
}
function maybeLogTelegramInboundUpdate(info, body) {
if (inboundUpdateLogged || telegramApiMethod(info) !== 'getUpdates') return;
var payload = null;
try {
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;
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';
emit(
'[telegram] [default] inbound update received (update_id=' +
updateIdState +
'; message_id=' +
messageIdState +
'; chat_type=' +
chatType +
'; sender_allowlisted=' +
senderAllowlistState(from.id) +
')'
);
return;
}
}
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') {
var keys = Object.keys(accounts);
account = keys.length ? accounts[keys[0]] : null;
}
return account && typeof account === 'object' ? account : null;
}
function readTelegramBotToken(config) {
var account = readTelegramAccount(config);
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 account = null;
try {
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 (allowFrom.length > 0) {
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');
}
}
}
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 = '';
try {
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');
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');
}
}
function wrapHttp(mod, methodName) {
var original = mod[methodName];
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) {
maybeLogTelegramStartupProbe(info, res && res.statusCode);
maybeLogTelegramSendMessage(info, res && res.statusCode);
if (!inboundUpdateLogged && telegramApiMethod(info) === 'getUpdates' && res && typeof res.on === 'function') {
var responseChunks = [];
var responseBytes = 0;
res.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 () {
maybeLogTelegramInboundUpdate(info, responseChunks.join(''));
});
}
});
req.once('error', function (error) {
maybeLogTelegramStartupError(info, error);
});
}
return req;
};
}
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 || '');
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)) {
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));
}
}
return ret;
};
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
// bridge module never logs "starting provider" and never hits the Bot
// API within the startup window, surface a single actionable breadcrumb
// so the channel is observably broken instead of silently invisible.
//
// Gate to the OpenClaw gateway process flavors only. The preload is
// exported via NODE_OPTIONS, so every short-lived Node child the user
// spawns inside the sandbox (CLI tools, shells, npm scripts) also requires
// 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 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 (!gatewayProcessFlavor()) return;
process.nextTick(maybeLogRuntimeConfigDiagnostics);
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';
try {
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 || {};
if (!Object.keys(accounts).length) return;
} catch (_e) {
return;
}
emit(
'[telegram] [default] bridge did not start within ' +
Math.round(STARTUP_GRACE_MS / 1000) +
's; check channels.telegram.enabled, plugin entries, and gateway log'
);
}, STARTUP_GRACE_MS);
if (typeof noStartupTimer.unref === 'function') noStartupTimer.unref();
})();

View file

@ -1,151 +0,0 @@
// 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
// @tencent-weixin/openclaw-weixin channel without changing channel behavior.
// Mirrors telegram-diagnostics.js: 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';
if (process.__nemoclawWechatDiagnosticsInstalled) return;
try {
Object.defineProperty(process, '__nemoclawWechatDiagnosticsInstalled', { value: true });
} catch (_e) {
process.__nemoclawWechatDiagnosticsInstalled = true;
}
var providerStarted = false;
var readyLogged = false;
var inferenceLogged = false;
var inDiagnosticWrite = false;
function sanitize(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<redacted>');
text = text.replace(/("bot_token"\s*:\s*")[^"]+/gi, '$1<redacted>');
text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, 'Bearer <redacted>');
text = text.replace(
/\b(api[_-]?key|token|authorization|wechat[_-]?bot[_-]?token)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi,
'$1$2<redacted>'
);
return text;
}
var originalStderrWrite = process.stderr.write.bind(process.stderr);
function emit(line) {
if (inDiagnosticWrite) return;
inDiagnosticWrite = true;
try {
originalStderrWrite(line + '\n');
} finally {
inDiagnosticWrite = false;
}
}
function describeRequest(arg1, arg2) {
var url = null;
var opts = 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;
} else if (arg1 && typeof arg1 === 'object') {
opts = arg1;
}
var hostname = '';
var pathStr = '';
if (url) {
hostname = url.hostname || '';
pathStr = (url.pathname || '') + (url.search || '');
}
if (opts) {
hostname = String(opts.hostname || opts.host || hostname || '');
pathStr = String(opts.path || pathStr || '');
}
if (hostname.indexOf(':') !== -1) hostname = hostname.split(':')[0];
return { hostname: hostname, path: pathStr };
}
// The iLink gateway uses dynamic per-account subdomains under
// *.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) {
if (!hostname) return false;
return (
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';
var trimmed = raw.trim();
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 (Number(statusCode) < 200 || Number(statusCode) >= 300) return;
providerStarted = true;
readyLogged = true;
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;
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) {
maybeLogWechatReady(info, res && res.statusCode);
});
}
return req;
};
}
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 || '');
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)) {
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));
}
}
return ret;
};
var http = require('http');
var https = require('https');
wrapHttp(http, 'request');
wrapHttp(http, 'get');
wrapHttp(https, 'request');
wrapHttp(https, 'get');
})();

View file

@ -600,9 +600,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"
}

View file

@ -768,20 +768,71 @@ 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
# 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 <<EOF
$channels
EOF
return 0
}
read_messaging_plan_channels() {
python3 - <<'PY'
import base64
import json
import os
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 and channel not in disabled:
seen.add(channel)
print(channel)
PY
}

View file

@ -1139,7 +1139,7 @@ PYCORS
}
# OpenShell provider snapshots can expose revision-scoped placeholders such as
# openshell:resolve:env:v11_DISCORD_BOT_TOKEN in the child environment. Refresh
# openshell:resolve:env:v11_<ENV_KEY> 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.
@ -1148,20 +1148,102 @@ 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()
MESSAGING_RUNTIME_PLAN_DEFAULT_PATH = "/usr/local/share/nemoclaw/messaging-runtime-plan.json"
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
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:
with open(artifact_path, encoding="utf-8") as f:
return json.load(f)
except Exception:
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
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_<ENV_KEY>_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 `_<suffix>`; 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,
@ -1170,29 +1252,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_<suffix>\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_<suffix>\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
@ -1200,6 +1296,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
@ -1208,9 +1305,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
@ -1232,6 +1328,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 = []
@ -1241,11 +1338,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)
@ -1253,8 +1345,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
@ -1281,63 +1373,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)}$"
)
@ -1347,8 +1430,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:
@ -1381,150 +1476,361 @@ 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<rev>_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 setup from manifest metadata ───────────────
# 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"
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
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
write_messaging_runtime_setup_plan() {
python3 - "$_MESSAGING_RUNTIME_PLAN_ARTIFACT" <<'PYMESSAGINGRUNTIME' | emit_sandbox_sourced_file "$_MESSAGING_RUNTIME_SETUP_PLAN"
import base64
import json
import os
import re
import sys
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
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 setup plan: {message}", file=sys.stderr)
raise SystemExit(1)
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
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
def clean_node_preload(entry, index):
if not isinstance(entry, dict):
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"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"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"nodePreloads[{index}].optional must be a boolean")
return {
"source": source,
"target": target,
"injectInto": normalized_scopes,
"optional": optional,
"installMessage": clean_message(entry.get("installMessage"), f"nodePreloads[{index}].installMessage"),
"installedMessage": clean_message(entry.get("installedMessage"), f"nodePreloads[{index}].installedMessage"),
}
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"),
}
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,
}
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)
if not isinstance(plan, dict):
fail("decoded plan must be an object")
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
channel_id = channel.get("channelId")
if not isinstance(channel_id, str):
continue
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
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_SETUP_PLAN" ] || return 0
local _rows
_rows="$(
python3 - "$_MESSAGING_RUNTIME_SETUP_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["value"],
alias.get("message", ""),
]))
PYMESSAGINGALIASES
)" || return $?
[ -n "$_rows" ] || return 0
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"
}
install_messaging_runtime_preloads() {
[ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0
local _rows
_rows="$(
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("nodePreloads", []):
print("\t".join([
preload["source"],
preload["target"],
",".join(preload.get("injectInto", [])),
"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 _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
return 1
fi
[ -n "$_install_message" ] && printf '%s\n' "$_install_message" >&2
emit_sandbox_sourced_file "$_target" <"$_source"
case ",$_inject_into," in
*,boot,*)
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_target"
;;
esac
case ",$_inject_into," in
*,connect,*)
_connect_preloads+=("$_target")
;;
esac
[ -n "$_installed_message" ] && printf '%s\n' "$_installed_message" >&2
done <<<"$_rows"
fi
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
}
# ── 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"
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
printf '[channels] Installing Slack channel guard (unhandled-rejection safety net)\n' >&2
emit_sandbox_sourced_file "$_SLACK_GUARD_SCRIPT" <"$_SLACK_GUARD_SOURCE"
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_SLACK_GUARD_SCRIPT"
printf '[channels] Slack channel guard installed (NODE_OPTIONS updated)\n' >&2
emit_messaging_connect_runtime_preload_exports() {
cat <<CONNECTPRELOADSEOF
if [ -f "$_MESSAGING_CONNECT_PRELOADS_FILE" ]; then
while IFS= read -r _nemoclaw_preload; do
[ -n "\$_nemoclaw_preload" ] || continue
[ -f "\$_nemoclaw_preload" ] || continue
export NODE_OPTIONS="\${NODE_OPTIONS:+\$NODE_OPTIONS }--require \$_nemoclaw_preload"
done < "$_MESSAGING_CONNECT_PRELOADS_FILE"
fi
CONNECTPRELOADSEOF
}
# ── 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"
messaging_runtime_preload_targets() {
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
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
printf '[channels] Installing Telegram diagnostics (provider readiness + inference errors)\n' >&2
emit_sandbox_sourced_file "$_TELEGRAM_DIAGNOSTICS_SCRIPT" <"$_TELEGRAM_DIAGNOSTICS_SOURCE"
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT"
printf '[channels] Telegram diagnostics installed (NODE_OPTIONS updated)\n' >&2
with open(sys.argv[1], encoding="utf-8") as handle:
plan = json.load(handle)
for preload in plan.get("nodePreloads", []):
target = preload.get("target")
if target:
print(target)
PYMESSAGINGTARGETS
}
# ── 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"
validate_nemoclaw_tmp_permissions() {
local _dynamic_targets=()
local _target
while IFS= read -r _target; do
[ -n "$_target" ] && _dynamic_targets+=("$_target")
done < <(messaging_runtime_preload_targets)
install_whatsapp_qr_compact() {
local config_file="/sandbox/.openclaw/openclaw.json"
validate_tmp_permissions "$_SANDBOX_SAFETY_NET" "$_PROXY_FIX_SCRIPT" "$_NEMOTRON_FIX_SCRIPT" "$_WS_FIX_SCRIPT" "$_SECCOMP_GUARD_SCRIPT" "$_CIAO_GUARD_SCRIPT" "${_dynamic_targets[@]}"
}
# Only install when WhatsApp is configured in the baked OpenClaw config.
if ! grep -q '"whatsapp"' "$config_file" 2>/dev/null; then
return 0
fi
verify_messaging_runtime_secret_scans() {
[ -f "$_MESSAGING_RUNTIME_SETUP_PLAN" ] || return 0
python3 - "$_MESSAGING_RUNTIME_SETUP_PLAN" <<'PYMESSAGINGSECRETS'
import json
import re
import sys
# Source file is absent on older base images; skip rather than fail the boot.
if [ ! -f "$_WHATSAPP_QR_COMPACT_SOURCE" ]; then
return 0
fi
with open(sys.argv[1], encoding="utf-8") as handle:
plan = json.load(handle)
printf '[channels] Installing WhatsApp compact-QR renderer (scan-friendly pairing)\n' >&2
emit_sandbox_sourced_file "$_WHATSAPP_QR_COMPACT_SCRIPT" <"$_WHATSAPP_QR_COMPACT_SOURCE"
for scan in plan.get("secretScans", []):
path = scan["path"]
try:
with open(path, "r", encoding="utf-8", errors="ignore") as handle:
content = handle.read()
except FileNotFoundError:
continue
if re.search(scan["pattern"], content):
print(scan["message"].replace("{path}", path), file=sys.stderr)
raise SystemExit(scan["exitCode"])
PYMESSAGINGSECRETS
}
_read_gateway_token() {
@ -2126,7 +2432,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 ──────────────────────────────────
@ -2332,6 +2638,16 @@ _nemoclaw_restore_mutable_config_perms() {
# rootless mode, where the root-only re-lock is skipped (#4538).
chmod g-w "$_nemoclaw_oc_dir/openclaw.json.nemoclaw-baseline" 2>/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
@ -2568,8 +2884,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 <sandbox> channels add <telegram|discord|slack|wechat|whatsapp>" >&2
echo " nemoclaw <sandbox> channels remove <telegram|discord|slack|wechat|whatsapp>" >&2
echo " nemoclaw <sandbox> channels add <channel>" >&2
echo " nemoclaw <sandbox> channels remove <channel>" >&2
echo "" >&2
echo "WhatsApp pairs entirely inside the sandbox; complete pairing via:" >&2
echo " openclaw channels login --channel whatsapp" >&2
@ -2609,16 +2925,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
@ -2639,8 +2952,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 <sandbox> channels add <telegram|discord|slack|wechat|whatsapp>" >&2
echo " nemoclaw <sandbox> channels remove <telegram|discord|slack|wechat|whatsapp>" >&2
echo " nemoclaw <sandbox> channels add <channel>" >&2
echo " nemoclaw <sandbox> channels remove <channel>" >&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
@ -2706,21 +3019,10 @@ 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.
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
@ -3404,14 +3706,17 @@ if [ "$(id -u)" -ne 0 ]; then
# actually runs with.
write_openclaw_config_baseline
export_gateway_token
write_messaging_runtime_setup_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
install_messaging_runtime_preloads
verify_messaging_runtime_secret_scans
exec "${NEMOCLAW_CMD[@]}"
fi
@ -3419,10 +3724,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
@ -3466,7 +3769,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
@ -3560,21 +3863,20 @@ prepare_gateway_token_for_current_command
# actually runs with.
write_openclaw_config_baseline
export_gateway_token
write_messaging_runtime_setup_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 Node 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
@ -3691,7 +3993,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

View file

@ -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 <search-root> [<search-root>...]
* Each <search-root> 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 <search-root> [<search-root>...]");
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<string>();
const visited = new Set<string>();
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(", ")}`,
);

View file

@ -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 * as policies from "../../policy";
import { KNOWN_CHANNELS, knownChannelNames } from "../../sandbox/channels";
import {
collectBuiltInMessagingChannelDiagnostics,
type MessagingChannelDiagnosticSpec,
} from "../../messaging/diagnostics";
import * as policies from "../../policy";
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<StatusDeps> {
};
}
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<StatusDeps>,
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)) {

View file

@ -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 { resolveGatewayName, resolveSandboxGatewayName } from "../../onboard/gateway-binding";
import { executeSandboxCommandForVerification } from "../../onboard/sandbox-verification-exec";
@ -38,6 +42,8 @@ import { captureHostCommand } from "./doctor-host-command";
import { buildToolScopeChecks } from "./doctor-tool-scope";
import { probeSandboxInferenceGatewayHealth } from "./process-recovery";
const CHANNEL_STATUS_DIAGNOSTICS = collectBuiltInMessagingChannelDiagnostics();
type DoctorStatus = "ok" | "warn" | "fail" | "info";
export type DoctorCheck = {
@ -447,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, sb.agent) || [];
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 {
@ -478,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<Record<string, string>>,
): 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-<name>`). That container only exists for the legacy

View file

@ -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, 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 exit spy. The bridge verification is downstream and not under test here.
// 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);
@ -689,11 +689,8 @@ describe("addSandboxChannel cross-sandbox conflict check (#4305)", () => {
it("slack: a second sandbox on the SAME non-default gateway is blocked", async () => {
// Both sandboxes are bound to `nemoclaw-8090`. The credential axis would
// not flag distinct tokens, but the gateway axis must — without this case,
// `checkSlackSocketModeGatewayConflict` previously checked the default
// `nemoclaw` while the provider mutation ran against `nemoclaw-8090` and
// left a false negative for two Slack sandboxes sharing the same non-
// default gateway.
// not flag distinct tokens, but the channel-owned pre-enable gateway axis
// must check the same target gateway the provider mutation uses.
const slackBot = "xoxb-alpha-bot-token";
const slackApp = "xapp-alpha-app-token";
const alpha = { name: "alpha", gatewayName: "nemoclaw-8090", gatewayPort: 8090 } as never;
@ -810,8 +807,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 bridge verification through the channel hook", 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 the channel hook", 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;
},
);
}

View file

@ -14,20 +14,25 @@ import {
createBuiltInChannelManifestRegistry,
createBuiltInMessagingHookRegistry,
createBuiltInRenderTemplateResolver,
createMessagingPreEnableHookInputs,
getMessagingManifestAvailabilityContext,
isMessagingHookConflictError,
MessagingHostStateApplier,
MessagingSetupApplier,
MessagingWorkflowPlanner,
runMessagingHook,
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 };
const onboardProviders = require("../../onboard/providers");
import { filterSetupPolicyPresetsForAgent } from "../../onboard/agent-policy-presets";
import { getStoredMessagingChannelConfig } from "../../onboard/messaging-config";
import * as policies from "../../policy";
const onboardSession =
@ -55,7 +60,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;
@ -439,61 +443,79 @@ 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<boolean> {
if (channelName !== "slack") return true;
let conflictMessages: string[] = [];
const requests = MessagingSetupApplier.listPreEnableChecks(plan);
if (requests.length === 0) return true;
let registryEntries: ReturnType<typeof registry.listSandboxes>["sandboxes"];
try {
const applier = require("../../messaging/applier") as typeof import("../../messaging/applier");
// `channels add` registers the Slack provider on the sandbox's target
// gateway — applyChannelAddToGatewayAndRegistry → recoverNamedGatewayRuntime
// selects that same name. Detect conflicts on the gateway the add actually
// mutates so the check matches the provider registration and cannot leave a
// false negative for a sibling sandbox on the same non-default gateway.
const gatewayName = getSandboxTargetGatewayName(sandboxName);
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}`);
const hookRegistry = createBuiltInMessagingHookRegistry();
const currentGatewayName = getSandboxTargetGatewayName(sandboxName);
const additionalInputs = createMessagingPreEnableHookInputs({
currentSandbox: sandboxName,
currentGatewayName,
registryEntries,
});
try {
await MessagingSetupApplier.applyPreEnableChecks(plan, {
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) {
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}`);
}
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} <sandbox> 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;
}
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} <sandbox> 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 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
@ -653,117 +675,50 @@ async function promptAndRebuild(sandboxName: string, actionDesc: string): Promis
return true;
}
// Channels that share the canonical OpenClaw `channels.<name>.enabled` shape
// and emit `[<name>] [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"]);
// Run manifest-owned post-rebuild health hooks.
// Failures remain best-effort warnings because the rebuild has already
// succeeded; this phase surfaces likely channel startup issues without making
// channel ownership leak back into this action.
async function runMessagingHealthChecksAfterRebuild(
sandboxName: string,
plan: SandboxMessagingPlan,
): Promise<void> {
if (MessagingSetupApplier.listHealthChecks(plan).length === 0) return;
// Probe OpenClaw runtime state for a freshly added messaging channel. Runs
// after `channels add <channel>` 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;
const configProbe = executeSandboxExecCommand(
sandboxName,
"cat /sandbox/.openclaw/openclaw.json 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.`,
);
console.log(
` Run '${CLI_NAME} ${sandboxName} status' to inspect the sandbox once it is fully running.`,
);
return;
}
let channelEnabled = false;
let channelBlock: any = null;
const hookRegistry = createBuiltInMessagingHookRegistry({
openclawBridgeHealth: {
sandboxName,
executeSandboxCommand: (command, timeoutMs) =>
executeSandboxExecCommand(sandboxName, command, timeoutMs),
},
});
try {
const cfg = JSON.parse(configProbe.stdout);
channelBlock = cfg?.channels?.[channelName];
channelEnabled = Boolean(channelBlock?.enabled);
} catch {
// Malformed config — fall through to the log probe to capture context.
await MessagingSetupApplier.applyHealthChecks(plan, {
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)}`);
}
if (!channelEnabled) {
console.log(
` ${YW}${R} '${channelName}' channel was not marked enabled in baked openclaw.json 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 (`[<channel>] [default]`)
// and the channel-guard preloads' aggregated form (`[channels] [<channel>]`).
// 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 logProbe = executeSandboxExecCommand(
sandboxName,
`tail -n 400 /tmp/gateway.log 2>/dev/null | grep -E "^\\[${channelName}\\] |^\\[channels\\] \\[${channelName}\\]" || true`,
10000,
);
const lines = (logProbe?.stdout || "")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length === 0) {
console.log(
` ${YW}${R} '${channelName}' bridge did not log a startup breadcrumb in /tmp/gateway.log yet.`,
);
console.log(
` Tail it with 'openshell sandbox exec --name ${sandboxName} -- tail -f /tmp/gateway.log' 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,
),
);
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;
}
// 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) {
console.log(` ${G}${R} '${channelName}' bridge startup detected in sandbox runtime log.`);
if (channelName === "telegram") {
printTelegramDirectMessageAllowlistWarning(channelBlock, console.log, `${YW}${R}`);
}
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.`,
);
}
function formatErrorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
async function planSandboxChannelAdd(
@ -779,7 +734,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({
@ -910,50 +865,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<typeof onboardSession.loadSession> {
@ -964,11 +878,6 @@ function safeLoadOnboardSession(): ReturnType<typeof onboardSession.loadSession>
}
}
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 = {},
@ -1024,9 +933,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);
@ -1039,7 +948,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);
@ -1057,7 +965,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;
}
@ -1093,7 +1001,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(
@ -1103,7 +1010,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(

View file

@ -56,6 +56,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,
@ -387,31 +389,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(",")}`,
);
}
}

View file

@ -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 { resolveSandboxGatewayName } from "../../onboard/gateway-binding";
import * as policies from "../../policy";
import { ROOT, run, shellQuote, validateName } from "../../runner";
@ -276,22 +277,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-<name>: PID dir for this sandbox's services
// - OpenShell providers named <name>-{telegram,discord,slack,wechat}-bridge
// and <name>-slack-app: per-sandbox messaging bridges
// - OpenShell per-sandbox messaging bridge providers declared by channel
// manifests.
// - shields-<name>.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"],
});

View file

@ -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();
});
});

View file

@ -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<string, unknown> {
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;
}

View file

@ -132,7 +132,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,
@ -180,7 +182,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,
@ -459,7 +463,7 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () =
} finally {
removeTempDir(harness.tmp);
}
});
}, 20_000);
it("does not import a raw secret from a metadata-safe proxy-env during runtime validation", () => {
const harness = prepareRecoveryHarness("runtime-env-real");
@ -512,5 +516,5 @@ describe("Hermes secret-boundary guard — full recovery script behaviour", () =
} finally {
removeTempDir(harness.tmp);
}
});
}, 20_000);
});

View file

@ -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'");
});

View file

@ -23,10 +23,9 @@ import { shellQuote } from "./core/shell-quote";
*
* 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
@ -40,20 +39,12 @@ import { shellQuote } from "./core/shell-quote";
* 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<string, string> = {
telegram: "telegram",
discord: "discord",
slack: "slack",
whatsapp: "whatsapp",
"openclaw-weixin": "wechat",
};
import {
listOpenClawRuntimeChannelMetadata,
type OpenClawRuntimeChannelMetadata,
} from "./messaging/channels/metadata";
const DEFAULT_RUNTIME_VISIBILITY_METADATA = listOpenClawRuntimeChannelMetadata();
export type RuntimeChannelStatus = {
/**
@ -100,10 +91,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. */
@ -115,16 +105,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<string, unknown>).channels;
if (!channels || typeof channels !== "object") return [];
const channelKeyToName = runtimeConfigKeyToChannelName(DEFAULT_RUNTIME_VISIBILITY_METADATA);
const visible = new Set<string>();
for (const [key, value] of Object.entries(channels as Record<string, unknown>)) {
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<string, unknown>).accounts;
@ -181,7 +172,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_VISIBILITY_METADATA)
.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,
@ -210,34 +203,53 @@ export function buildGatewayLogScanScript(gatewayLogPath: string): string {
*/
export function parseGatewayLogScanOutput(stdout: string): Set<string> {
const found = new Set<string>();
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;
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 OpenClawRuntimeChannelMetadata[],
): ReadonlyMap<string, string> {
const aliases = new Map<string, string>();
for (const output of outputs) {
for (const key of output.configKeys) {
aliases.set(key, output.channelId);
}
}
return aliases;
}
function runtimeLogPatterns(outputs: readonly OpenClawRuntimeChannelMetadata[]): string[] {
return [
...new Set(outputs.flatMap((output) => output.logPatterns).filter((entry) => entry.length > 0)),
];
}
function runtimeLogPatternToChannelName(
outputs: readonly OpenClawRuntimeChannelMetadata[],
): ReadonlyMap<string, string> {
const aliases = new Map<string, string>();
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:

View file

@ -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));

View file

@ -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", "/"]);
@ -42,12 +43,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),
];
const LEGACY_CREDENTIAL_ENV_ALIASES: Partial<Record<string, readonly string[]>> = {

View file

@ -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/<channel>/ 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<string, string>;
/** 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<HostQrLoginResult>;
export const HOST_QR_LOGIN_HANDLERS: Record<string, HostQrLoginHandler> = {
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),
};
}
},
};

View file

@ -12,6 +12,22 @@ const require = createRequire(import.meta.url);
const NIM_DIST_PATH = require.resolve("../../../dist/lib/inference/nim");
const RUNNER_PATH = require.resolve("../../../dist/lib/runner");
const fs = require("fs");
const NIM_API_KEY_ENV_KEYS = ["NGC_API_KEY", "NVIDIA_INFERENCE_API_KEY", "NVIDIA_API_KEY"];
function clearNimApiKeyEnv(): Array<[string, string | undefined]> {
const snapshot: Array<[string, string | undefined]> = NIM_API_KEY_ENV_KEYS.map((key) => [
key,
process.env[key],
]);
for (const key of NIM_API_KEY_ENV_KEYS) delete process.env[key];
return [...snapshot];
}
function restoreNimApiKeyEnv(snapshot: Array<[string, string | undefined]>): void {
for (const [key, value] of snapshot) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
function withFirmwareModel(model: string, fn: () => void): void {
const origReadFileSync = fs.readFileSync;
@ -1648,12 +1664,6 @@ describe("nim", () => {
});
describe("startNimContainerByName", () => {
// Regression #3333 (and original fix in #219 that was lost in the
// string→argv refactor): the NIM container must receive NGC_API_KEY and
// NIM_NGC_API_KEY so it can download model manifests from NGC. Without
// these the container exits 0 a few seconds in with "Authentication Error".
// The value is passed through the spawn env (not argv) so it does not
// leak via `ps`/audit logs.
type RunCall = [string[], { env?: Record<string, string> } | undefined];
function dockerRunCall(run: Mock): RunCall | undefined {
@ -1693,7 +1703,6 @@ describe("nim", () => {
const [argv, opts] = call!;
expect(hasEnvFlag(argv, "NGC_API_KEY")).toBe(true);
expect(hasEnvFlag(argv, "NIM_NGC_API_KEY")).toBe(true);
// Secret must not appear in argv (visible via ps/audit logs).
expect(argvContainsValue(argv, "nvapi-abc123")).toBe(false);
expect(opts?.env).toMatchObject({
NGC_API_KEY: "nvapi-abc123",
@ -1705,9 +1714,8 @@ describe("nim", () => {
});
it("falls back to process.env.NGC_API_KEY when no opts key is supplied", () => {
const prev = { ngc: process.env.NGC_API_KEY, nv: process.env.NVIDIA_INFERENCE_API_KEY };
const envSnapshot = clearNimApiKeyEnv();
process.env.NGC_API_KEY = "nvapi-env-ngc";
delete process.env.NVIDIA_INFERENCE_API_KEY;
const run = vi.fn();
const { nimModule, restore } = loadNimWithMockedRunner(
vi.fn(() => ""),
@ -1726,15 +1734,12 @@ describe("nim", () => {
});
} finally {
restore();
if (prev.ngc === undefined) delete process.env.NGC_API_KEY;
else process.env.NGC_API_KEY = prev.ngc;
if (prev.nv !== undefined) process.env.NVIDIA_INFERENCE_API_KEY = prev.nv;
restoreNimApiKeyEnv(envSnapshot);
}
});
it("falls back to process.env.NVIDIA_INFERENCE_API_KEY when NGC_API_KEY is unset", () => {
const prev = { ngc: process.env.NGC_API_KEY, nv: process.env.NVIDIA_INFERENCE_API_KEY };
delete process.env.NGC_API_KEY;
const envSnapshot = clearNimApiKeyEnv();
process.env.NVIDIA_INFERENCE_API_KEY = "nvapi-env-nvidia";
const run = vi.fn();
const { nimModule, restore } = loadNimWithMockedRunner(
@ -1751,16 +1756,12 @@ describe("nim", () => {
expect(call?.[1]?.env?.NGC_API_KEY).toBe("nvapi-env-nvidia");
} finally {
restore();
if (prev.ngc !== undefined) process.env.NGC_API_KEY = prev.ngc;
if (prev.nv === undefined) delete process.env.NVIDIA_INFERENCE_API_KEY;
else process.env.NVIDIA_INFERENCE_API_KEY = prev.nv;
restoreNimApiKeyEnv(envSnapshot);
}
});
it("omits env flags when no key is available", () => {
const prev = { ngc: process.env.NGC_API_KEY, nv: process.env.NVIDIA_INFERENCE_API_KEY };
delete process.env.NGC_API_KEY;
delete process.env.NVIDIA_INFERENCE_API_KEY;
const envSnapshot = clearNimApiKeyEnv();
const run = vi.fn();
const { nimModule, restore } = loadNimWithMockedRunner(
vi.fn(() => ""),
@ -1778,8 +1779,7 @@ describe("nim", () => {
expect(call?.[1]?.env).toBeUndefined();
} finally {
restore();
if (prev.ngc !== undefined) process.env.NGC_API_KEY = prev.ngc;
if (prev.nv !== undefined) process.env.NVIDIA_INFERENCE_API_KEY = prev.nv;
restoreNimApiKeyEnv(envSnapshot);
}
});
});

View file

@ -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)",
);
@ -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: [

View file

@ -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 {
@ -119,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;
@ -478,11 +480,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 =
@ -504,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) {
@ -529,3 +533,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]);
}

View file

@ -21,6 +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",
]);
});

View file

@ -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<string, string>;
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<Record<string, readonly string[]>> = {
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;

View file

@ -12,6 +12,7 @@ type Env = Record<string, string | undefined>;
type JsonObject = Record<string, any>;
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<Record<MessagingRuntimeSetupKey, readonly JsonObject[]>>;
};
export type BuildFileOutput = {
@ -92,22 +96,21 @@ export type BuildFileOutput = {
export type BuildCommandResult = {
readonly channels: readonly string[];
readonly runtimePlanPath: string;
readonly doctorEnv: Record<string, string>;
readonly installSpecs: readonly string[];
readonly openclawVersion: string;
};
type OpenClawPluginInstall = {
readonly spec: string;
readonly pin: boolean;
};
export class MessagingBuildApplierError extends Error {}
const OPENCLAW_VERSIONED_MESSAGING_PLUGIN_PACKAGES: Readonly<Record<string, string>> = {
discord: "@openclaw/discord",
slack: "@openclaw/slack",
whatsapp: "@openclaw/whatsapp",
};
const OPENCLAW_FIXED_MESSAGING_PLUGIN_INSTALL_SPECS: Readonly<Record<string, string>> = {
wechat: "npm:@tencent-weixin/openclaw-weixin@2.4.3",
};
export const DEFAULT_MESSAGING_RUNTIME_PLAN_PATH =
"/usr/local/share/nemoclaw/messaging-runtime-plan.json";
export function readMessagingBuildPlanFromEnv(
env: Env,
@ -234,11 +237,176 @@ 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<MessagingRuntimeSetupKey, readonly JsonObject[]> {
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<string>();
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,
): 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<string>();
for (const step of enabledBuildStepsForPhase(plan, "agent-install")) {
if (step.kind !== "package-install") continue;
if (step.value === undefined) {
@ -251,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(
@ -277,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,
);
}
}
@ -677,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), {
@ -1121,13 +1266,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 +1322,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 +1393,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 {

View file

@ -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<Record<string, readonly string[]>> =
Object.fromEntries(
BUILT_IN_CHANNEL_MANIFESTS.map((m) => [m.id, m.credentials.map((c) => c.providerEnvKey)]),
);
getMessagingCredentialEnvKeysByChannel();
export const PROVIDER_SUFFIXES: Record<string, string[]> = 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<Record<string, readonly string[]>> =
getMessagingProviderSuffixesByChannel();

View file

@ -0,0 +1,299 @@
// 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 {
MessagingChannelId,
SandboxMessagingChannelPlan,
SandboxMessagingPlan,
} from "../manifest";
import { applyDiagnostics, applyPreEnableChecks, 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 () => {
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("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.applyPreEnableChecks(
makePlan({
telegramOnFailure: "skip-channel",
includeDiscordPreEnable: true,
}),
{ 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: {},
},
]);
});
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 {
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,
},
...(options.includeTelegramSecondPreEnable
? [
{
channelId: "telegram" as MessagingChannelId,
id: "telegram-second-pre-enable",
phase: "pre-enable" as const,
handler: "telegram.secondPreEnable",
onFailure: "abort" as const,
},
]
: []),
],
}),
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 ?? [],
};
}

View file

@ -0,0 +1,167 @@
// 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<string, MessagingSerializableValue> = {};
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[] = [];
const skippedChannelIds = new Set<string>();
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);
appliedHooks.push(formatHookKey(requestWithInputs));
hookResults.push(normalizeHookRunResult(requestWithInputs, result));
} catch (error) {
if (requestWithInputs.onFailure === "skip-channel") {
skippedChannelIds.add(requestWithInputs.channelId);
skippedHooks.push(formatHookKey(requestWithInputs));
continue;
}
throw error;
}
}
return {
phase,
hookRequests,
hookResults,
appliedHooks,
skippedHooks,
};
}
export function applyPreEnableChecks(
plan: SandboxMessagingPlan,
options?: MessagingHookPhaseOptions,
): ReturnType<typeof applyMessagingHooksForPhase> {
return applyMessagingHooksForPhase(plan, "pre-enable", options);
}
export function applyHealthChecks(
plan: SandboxMessagingPlan,
options?: MessagingHookPhaseOptions,
): ReturnType<typeof applyMessagingHooksForPhase> {
return applyMessagingHooksForPhase(plan, "health-check", options);
}
export function applyStatusChecks(
plan: SandboxMessagingPlan,
options?: MessagingHookPhaseOptions,
): ReturnType<typeof applyMessagingHooksForPhase> {
return applyMessagingHooksForPhase(plan, "status", options);
}
export function applyDiagnostics(
plan: SandboxMessagingPlan,
options?: MessagingHookPhaseOptions,
): ReturnType<typeof applyMessagingHooksForPhase> {
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,
};
}

View file

@ -1,10 +1,10 @@
// 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 { hydrateDerivedSandboxMessagingPlanFields } from "../persistence";
import { parseSandboxMessagingPlan } from "../plan-validation";
import * as registry from "../../state/registry";
import { MessagingSetupApplier } from "./setup-applier";
import type { MessagingSetupEnvOptions } from "./types";
@ -107,11 +107,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<T extends { readonly channelId: string }>(
existing: readonly T[],
incoming: readonly T[],

View file

@ -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";

View file

@ -193,6 +193,36 @@ 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(slackPlan.runtimeSetup?.nodePreloads).toEqual([
expect.objectContaining({
channelId: "slack",
module: "slack-channel-guard",
source: "/usr/local/lib/nemoclaw/preloads/slack-channel-guard.js",
}),
]);
expect(MessagingSetupApplier.listHealthChecks(slackPlan)).toEqual([
expect.objectContaining({
channelId: "slack",
hookId: "slack-openclaw-bridge-health",
phase: "health-check",
handler: "slack.openclawBridgeHealth",
}),
]);
});
it("upserts OpenShell generic providers from plan credential bindings", async () => {
@ -425,7 +455,9 @@ describe("MessagingSetupApplier", () => {
(request) => `${request.channelId}:${request.hookId}`,
),
).toEqual([
"slack:slack-openclaw-package-install",
"slack:slack-socket-mode-gateway-conflict",
"slack:slack-openclaw-bridge-health",
"slack:slack-socket-mode-gateway-status",
"slack:slack-token-paste",
"slack:slack-config-prompt",
"slack:slack-credential-validation",

View file

@ -8,6 +8,12 @@ import {
applyAgentConfigAtOpenShell as applyAgentConfigPlanAtOpenShell,
listHookRequests as listPlanHookRequests,
} from "./agent-config";
import {
applyHealthChecks as applyPlanHealthChecks,
applyMessagingHooksForPhase as applyPlanHooksForPhase,
applyPreEnableChecks as applyPlanPreEnableChecks,
type MessagingHookPhaseOptions,
} from "./hook-phases";
import { applyCredentialsAtOpenShell as applyCredentialsPlanAtOpenShell } from "./openshell-provider";
import { applyPolicyAtOpenShell as applyPolicyPlanAtOpenShell } from "./policy";
import {
@ -68,6 +74,41 @@ export class MessagingSetupApplier {
return listPlanHookRequests(plan, phase);
}
static listPreEnableChecks(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] {
assertSandboxMessagingPlan(plan);
return listPlanHookRequests(plan, "pre-enable");
}
static listHealthChecks(plan: SandboxMessagingPlan): MessagingHookApplyRequest[] {
assertSandboxMessagingPlan(plan);
return listPlanHookRequests(plan, "health-check");
}
static applyHooksForPhase(
plan: SandboxMessagingPlan,
phase: ChannelHookPhase,
options: MessagingHookPhaseOptions = {},
): ReturnType<typeof applyPlanHooksForPhase> {
assertSandboxMessagingPlan(plan);
return applyPlanHooksForPhase(plan, phase, options);
}
static applyPreEnableChecks(
plan: SandboxMessagingPlan,
options: MessagingHookPhaseOptions = {},
): ReturnType<typeof applyPlanPreEnableChecks> {
assertSandboxMessagingPlan(plan);
return applyPlanPreEnableChecks(plan, options);
}
static applyHealthChecks(
plan: SandboxMessagingPlan,
options: MessagingHookPhaseOptions = {},
): ReturnType<typeof applyPlanHealthChecks> {
assertSandboxMessagingPlan(plan);
return applyPlanHealthChecks(plan, options);
}
static async applyAgentConfigAtOpenShell(
plan: SandboxMessagingPlan,
options: {
@ -113,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)
) {
@ -124,6 +166,16 @@ function isObject(value: unknown): value is Record<string, unknown> {
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 = "$",

View file

@ -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);
}

View file

@ -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;
}

View file

@ -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,
);
}

View file

@ -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",
@ -165,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"],
@ -186,22 +218,10 @@ export const discordManifest = {
},
hooks: [
{
id: "discord-openclaw-package-install",
phase: "agent-install",
handler: "common.staticOutputs",
id: "discord-openclaw-bridge-health",
phase: "health-check",
handler: "discord.openclawBridgeHealth",
agents: ["openclaw"],
outputs: [
{
id: "openclawPluginPackage",
kind: "package-install",
required: true,
value: {
manager: "openclaw-plugin",
spec: "npm:@openclaw/discord@{{openclaw.version}}",
pin: true,
},
},
],
onFailure: "abort",
},
{

View file

@ -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);
}

View file

@ -10,7 +10,12 @@ import {
COMMON_CONFIG_PROMPT_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,8 +25,15 @@ import {
wechatManifest,
whatsappManifest,
} from "./index";
import { SLACK_VALIDATE_CREDENTIALS_HOOK_HANDLER_ID } from "./slack/hooks";
import { TELEGRAM_ALLOWLIST_ALIASES_HOOK_ID } from "./telegram/hooks";
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,
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);
@ -35,6 +47,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 +127,67 @@ 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 expectOpenClawBridgeHealthHook(
manifest: ChannelManifest,
hookId: string,
handler: string,
): void {
const hook = findHook(manifest, hookId);
expect(hook).toMatchObject({
id: hookId,
phase: "health-check",
handler,
agents: ["openclaw"],
onFailure: "abort",
});
expect(hook.outputs).toBeUndefined();
}
function expectConcreteStatusHook(
manifest: ChannelManifest,
hookId: string,
handler: string,
outputId: string,
): void {
expect(findHook(manifest, hookId)).toMatchObject({
id: hookId,
phase: "status",
handler,
outputs: [
{
id: outputId,
kind: "status",
},
],
});
}
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();
@ -140,13 +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",
@ -157,7 +244,7 @@ describe("built-in channel manifests", () => {
"state/registry",
"adapters/openshell",
"host-qr-handlers",
"ext/wechat",
"../ext/",
"node:fs",
"node:child_process",
];
@ -253,6 +340,20 @@ describe("built-in channel manifests", () => {
});
expectConfigPromptEnrollHook(telegramManifest, ["requireMention", "allowedIds"]);
expectReachabilityHook(telegramManifest, ["botToken"]);
expectOpenClawNodePreload(telegramManifest, "telegram-diagnostics");
expect(JSON.stringify(telegramManifest.runtime?.openclaw)).toContain("telegram-diagnostics");
expectOpenClawBridgeHealthHook(
telegramManifest,
"telegram-openclaw-bridge-health",
"telegram.openclawBridgeHealth",
);
expectOpenClawRuntimeVisibility(telegramManifest, ["telegram"], ["telegram"]);
expectConcreteStatusHook(
telegramManifest,
"telegram-gateway-conflict-status",
TELEGRAM_GATEWAY_CONFLICT_STATUS_HOOK_HANDLER_ID,
"bridgeHealth",
);
});
it("declares Discord guild and allowlist render intent for both agents", () => {
@ -291,6 +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",
"discord.openclawBridgeHealth",
);
expectOpenClawRuntimeVisibility(discordManifest, ["discord"], ["discord"]);
});
it("declares Slack Bolt-compatible placeholders and allowlist render intent", () => {
@ -320,6 +427,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",
@ -338,9 +446,25 @@ 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();
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",
"slack.openclawBridgeHealth",
);
expectOpenClawRuntimeVisibility(slackManifest, ["slack"], ["slack"]);
expectConcreteStatusHook(
slackManifest,
"slack-socket-mode-gateway-status",
SLACK_SOCKET_MODE_GATEWAY_STATUS_HOOK_HANDLER_ID,
"gatewayOverlaps",
);
expect(slackManifest.state).toEqual({
persist: {
allowedIds: ["allowedUsers"],
@ -413,13 +537,22 @@ 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([
"common.staticOutputs",
"wechat.ilinkLogin",
"common.configPrompt",
"wechat.seedOpenClawAccount",
"wechat.healthCheck",
]);
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",
@ -443,6 +576,11 @@ describe("built-in channel manifests", () => {
inputs: ["wechatConfig.accountId"],
onFailure: "abort",
});
expectOpenClawRuntimeVisibility(
wechatManifest,
["openclaw-weixin"],
["wechat", "openclaw-weixin"],
);
});
it("declares WhatsApp as in-sandbox QR with optional allowlist config", () => {
@ -481,5 +619,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");
expectOpenClawNodePreload(whatsappManifest, "whatsapp-qr-compact");
expect(JSON.stringify(whatsappManifest.runtime?.openclaw)).toContain("whatsapp-qr-compact");
expectOpenClawRuntimeVisibility(whatsappManifest, ["whatsapp"], ["whatsapp"]);
});
});

View file

@ -0,0 +1,190 @@
// 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, ChannelPolicyPresetReference } from "../manifest";
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([]);
});
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("lists package installs from manifest agent package metadata", () => {
const manifests: ChannelManifest[] = [
{
...manifestWithPreset("alpha", "alpha"),
agentPackages: [
{
id: "alphaPackage",
agent: "openclaw",
manager: "openclaw-plugin",
spec: "npm:@openclaw/alpha@{{openclaw.version}}",
},
],
},
];
expect(listMessagingPackageInstallSpecs({ manifests })[0]?.agents).toEqual(["openclaw"]);
expect(listMessagingPackageInstallSpecs({ manifests, agent: "hermes" })).toEqual([]);
});
});
function manifestWithPreset(id: string, preset: ChannelPolicyPresetReference): ChannelManifest {
return {
schemaVersion: 1,
id,
displayName: id,
supportedAgents: ["openclaw", "hermes"],
auth: { mode: "none" },
inputs: [],
credentials: [],
policyPresets: [preset],
render: [],
state: {},
hooks: [],
};
}

View file

@ -0,0 +1,329 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import type {
ChannelAgentPackageSpec,
ChannelManifest,
ChannelPolicyPresetReference,
ChannelPolicyPresetSpec,
MessagingAgentId,
} 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;
readonly primary: boolean;
}
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<Record<MessagingAgentId, readonly string[]>>;
readonly requiredAtCreate: boolean;
readonly validationWarningLines: readonly string[];
}
export interface OpenClawRuntimeChannelMetadata {
readonly channelId: string;
readonly configKeys: readonly string[];
readonly logPatterns: readonly string[];
}
export interface MessagingPackageInstallMetadata {
readonly channelId: string;
readonly packageId: 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,
primary: credential.primary === true,
})),
);
}
export function getMessagingCredentialEnvKeysByChannel(
options: MessagingManifestMetadataOptions = {},
): Readonly<Record<string, readonly string[]>> {
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<Record<string, readonly string[]>> {
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<Record<string, readonly string[]>> {
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<Record<string, readonly string[]>> {
const result: Record<string, string[]> = {};
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<Record<string, readonly string[]>> {
const result: Record<string, string[]> = {};
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<Record<string, readonly string[]>> {
const result: Record<string, string[]> = {};
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<Record<string, readonly string[]>> {
const result: Record<string, string[]> = {};
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(
options: MessagingManifestMetadataOptions = {},
): OpenClawRuntimeChannelMetadata[] {
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.agentPackages ?? []).flatMap((agentPackage) => {
if (options.agent && agentPackage.agent !== options.agent) return [];
return [
{
channelId: manifest.id,
packageId: agentPackage.id,
agents: [agentPackage.agent],
...packageInstallValue(agentPackage),
},
];
}),
);
}
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 packageInstallValue(
value: ChannelAgentPackageSpec,
): Pick<MessagingPackageInstallMetadata, "manager" | "spec" | "pin"> {
return {
manager: value.manager,
spec: value.spec,
...(typeof value.pin === "boolean" ? { pin: value.pin } : {}),
};
}
function uniqueStrings(values: readonly string[]): string[] {
return [...new Set(values)];
}

View file

@ -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");
});
});

View file

@ -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 : "<sandbox>";
}
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -2,32 +2,55 @@
// 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(
options: SlackHookOptions = {},
): readonly MessagingHookRegistration[] {
return [
createSlackSocketModeGatewayConflictHookRegistration(
withoutUndefinedValues(options.socketModeGatewayConflict),
),
createSlackSocketModeGatewayStatusHookRegistration(
withoutUndefinedValues(options.socketModeGatewayStatus),
),
createSlackOpenClawBridgeHealthHookRegistration(options.openclawBridgeHealth),
createSlackValidateCredentialsHookRegistration(
withoutUndefinedValues(options.validateCredentials),
),
] as const;
}
function withoutUndefinedValues(
options: SlackValidateCredentialsHookOptions | undefined,
): SlackValidateCredentialsHookOptions {
function withoutUndefinedValues<T extends object>(options: T | undefined): T {
return Object.fromEntries(
Object.entries(options ?? {}).filter(([, value]) => value !== undefined),
) as SlackValidateCredentialsHookOptions;
) as T;
}

View file

@ -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,
);
}

View file

@ -0,0 +1,129 @@
// 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 type { ConflictRegistryEntry } from "../../../applier/conflict-detection/types";
import {
MESSAGING_HOOK_CONFLICT_CODE,
MessagingHookRegistry,
runMessagingHook,
} from "../../../hooks";
import type { ChannelHookSpec, MessagingSerializableValue } from "../../../manifest";
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.",
);
await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).rejects.toMatchObject({
code: MESSAGING_HOOK_CONFLICT_CODE,
});
});
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("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(),
]);
await expect(runMessagingHook(HOOK, registry, { channelId: "slack" })).rejects.toThrow(
"Slack Socket Mode gateway conflict hook requires currentGatewayName and registryEntries.",
);
});
});

View file

@ -0,0 +1,140 @@
// 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 { MessagingHookConflictError } from "../../../hooks/errors";
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";
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 MessagingHookConflictError(
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;
}
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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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;
}

View file

@ -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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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",
@ -81,7 +82,7 @@ export const slackManifest = {
placeholder: "xapp-OPENSHELL-RESOLVE-ENV-SLACK_APP_TOKEN",
},
],
policyPresets: ["slack"],
policyPresets: [{ name: "slack", requiredAtCreate: true }],
render: [
{
id: "slack-openclaw-channel",
@ -146,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"],
@ -164,23 +217,28 @@ export const slackManifest = {
},
hooks: [
{
id: "slack-openclaw-package-install",
phase: "agent-install",
handler: "common.staticOutputs",
id: "slack-socket-mode-gateway-conflict",
phase: "pre-enable",
handler: "slack.socketModeGatewayConflict",
onFailure: "abort",
},
{
id: "slack-openclaw-bridge-health",
phase: "health-check",
handler: "slack.openclawBridgeHealth",
agents: ["openclaw"],
onFailure: "abort",
},
{
id: "slack-socket-mode-gateway-status",
phase: "status",
handler: "slack.socketModeGatewayStatus",
outputs: [
{
id: "openclawPluginPackage",
kind: "package-install",
required: true,
value: {
manager: "openclaw-plugin",
spec: "npm:@openclaw/slack@{{openclaw.version}}",
pin: true,
},
id: "gatewayOverlaps",
kind: "status",
},
],
onFailure: "abort",
},
{
id: "slack-token-paste",

View file

@ -0,0 +1,300 @@
// @ts-nocheck
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// 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
// inference, chat, and TUI alongside the failed Slack channel.
//
// This preload wraps process.emit for Slack-specific process-level failures
// and consumes those events before later OpenClaw handlers can treat the
// 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 = [
"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",
];
function mentionsSlackHost(value) {
var tokens = String(value || "").split(/\s+/);
for (var i = 0; i < tokens.length; i++) {
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;
} catch (_e) {
if (/^(?:[a-z0-9-]+\.)*slack\.com(?::\d+)?$/i.test(candidate)) return true;
}
}
return false;
}
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 || "";
for (var i = 0; i < SLACK_AUTH_ERRORS.length; i++) {
if (code === SLACK_AUTH_ERRORS[i]) return true;
}
// Check error message
var msg = String(reason.message || reason);
for (var j = 0; j < SLACK_AUTH_MESSAGES.length; j++) {
if (msg.indexOf(SLACK_AUTH_MESSAGES[j]) !== -1) return true;
}
// Check stack trace for @slack/ packages
var stack = reason.stack || "";
if (stack.indexOf("@slack/") !== -1 || stack.indexOf("slack-") !== -1) {
return true;
}
// Check for proxy/network errors targeting Slack domains.
// When the network policy blocks or rejects connections to Slack
// servers, the error comes from the HTTP client (CONNECT tunnel
// failure), not from @slack/ code. The stack won't contain @slack/
// but the error message or URL may reference the Slack hostname.
if (mentionsSlackHost(msg)) {
return true;
}
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);
process.stderr.write(
"[channels] [slack] provider failed to start: " +
msg +
" \u2014 " +
source +
" caught by safety net, gateway continues\n",
);
return true; // handled
}
return false;
}
if (process.__nemoclawSlackChannelGuardInstalled) return;
try {
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;",
'\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 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(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") {
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);
};
}
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();
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;
}
return origEmit.apply(this, arguments);
};
})();

View file

@ -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({});
});
});

View file

@ -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);
}

View file

@ -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),

View file

@ -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";

View file

@ -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<string, unknown>): Record<string, unknown> | 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}

View file

@ -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",
@ -150,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"],
@ -213,5 +239,23 @@ export const telegramManifest = {
inputs: ["botToken"],
onFailure: "skip-channel",
},
{
id: "telegram-openclaw-bridge-health",
phase: "health-check",
handler: "telegram.openclawBridgeHealth",
agents: ["openclaw"],
onFailure: "abort",
},
{
id: "telegram-gateway-conflict-status",
phase: "status",
handler: "telegram.gatewayConflictStatus",
outputs: [
{
id: "bridgeHealth",
kind: "status",
},
],
},
],
} as const satisfies ChannelManifest;

View file

@ -0,0 +1,434 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// 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.
type TelegramDiagnosticsProcess = NodeJS.Process & {
__nemoclawTelegramDiagnosticsInstalled?: boolean;
};
type TelegramJsonObject = Record<string, unknown>;
type TelegramRequestInfo = { hostname: string; path: string };
type TelegramStderrWrite = (...args: unknown[]) => boolean;
type TelegramHttpModuleLike = Record<string, unknown>;
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";
var diagnosticsProcess = process as TelegramDiagnosticsProcess;
if (diagnosticsProcess.__nemoclawTelegramDiagnosticsInstalled) return;
try {
Object.defineProperty(diagnosticsProcess, "__nemoclawTelegramDiagnosticsInstalled", {
value: true,
});
} catch (_e) {
diagnosticsProcess.__nemoclawTelegramDiagnosticsInstalled = true;
}
var providerStarted = false;
var readyLogged = false;
var startupProbeLogged = false;
var inferenceLogged = false;
var credentialLogged = false;
var runtimeConfigLogged = false;
var sendMessageLogged = false;
var inboundUpdateLogged = false;
var inDiagnosticWrite = false;
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<redacted>");
text = text.replace(/\/file\/bot[^/\s"']+/g, "/file/bot<redacted>");
text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, "Bearer <redacted>");
text = text.replace(
/\b(api[_-]?key|token|authorization)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi,
"$1$2<redacted>",
);
return text;
}
var stderr = process.stderr as NodeJS.WriteStream & { write: TelegramStderrWrite };
var originalStderrWrite = stderr.write.bind(stderr) as TelegramStderrWrite;
function emit(line: string): void {
if (inDiagnosticWrite) return;
inDiagnosticWrite = true;
try {
originalStderrWrite(line + "\n");
} finally {
inDiagnosticWrite = false;
}
}
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;
}
opts = asObject(arg2);
} else if (arg1 && typeof arg1 === "object") {
opts = asObject(arg1);
}
var hostname = "";
var path = "";
if (url) {
hostname = url.hostname || "";
path = (url.pathname || "") + (url.search || "");
}
if (opts) {
hostname = String(opts.hostname || opts.host || hostname || "");
path = String(opts.path || path || "");
}
if (hostname.indexOf(":") !== -1) hostname = hostname.split(":")[0];
return { hostname: hostname, path: path };
}
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: TelegramRequestInfo): boolean {
var method = telegramApiMethod(info);
return method === "getUpdates" || method === "getMe" || method === "getWebhookInfo";
}
function maybeLogTelegramStartupProbe(info: TelegramRequestInfo, statusCode: unknown): void {
if (!isTelegramStartupProbe(info)) return;
providerStarted = true;
var status = Number(statusCode);
if (status >= 200 && status < 300) {
if (readyLogged) return;
readyLogged = true;
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",
);
return;
}
if (status >= 300) {
emit("[telegram] [default] Bot API startup probe returned HTTP " + status);
}
}
function maybeLogTelegramStartupError(info: TelegramRequestInfo, error: unknown): void {
if (!isTelegramStartupProbe(info) || startupProbeLogged) return;
providerStarted = true;
startupProbeLogged = true;
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: TelegramRequestInfo, statusCode: unknown): void {
if (sendMessageLogged || telegramApiMethod(info) !== "sendMessage") return;
sendMessageLogged = true;
emit(
"[telegram] [default] outbound sendMessage attempted; Bot API returned HTTP " +
Number(statusCode || 0),
);
}
function senderAllowlistState(senderId: unknown): string {
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 allowFrom = Array.isArray(account.allowFrom) ? account.allowFrom.map(String) : [];
return allowFrom.indexOf(String(senderId)) === -1 ? "false" : "true";
} catch (_e) {
return "unknown";
}
}
function maybeLogTelegramInboundUpdate(info: TelegramRequestInfo, body: unknown): void {
if (inboundUpdateLogged || telegramApiMethod(info) !== "getUpdates") return;
var payload: TelegramJsonObject | null = null;
try {
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 = asObject(payload.result[i]);
if (!update) continue;
var message =
asObject(update.message) ||
asObject(update.edited_message) ||
asObject(update.channel_post) ||
asObject(update.edited_channel_post);
if (!message) continue;
inboundUpdateLogged = true;
var chat = asObject(message.chat) || {};
var from = asObject(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=" +
updateIdState +
"; message_id=" +
messageIdState +
"; chat_type=" +
chatType +
"; sender_allowlisted=" +
senderAllowlistState(from.id) +
")",
);
return;
}
}
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 ? asObject(accounts[keys[0]]) : null;
}
return account;
}
function readTelegramBotToken(config: unknown): string {
var account = readTelegramAccount(config);
return account && typeof account.botToken === "string" ? account.botToken : "";
}
function maybeLogRuntimeConfigDiagnostics(): void {
if (runtimeConfigLogged) return;
runtimeConfigLogged = true;
var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json";
var account: TelegramJsonObject | null = null;
try {
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 (allowFrom.length > 0) {
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",
);
}
}
}
function maybeLogCredentialPlaceholderDiagnostics(): void {
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 = "";
try {
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",
);
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",
);
}
}
function wrapHttp(mod: TelegramHttpModuleLike, methodName: string): void {
var original = mod[methodName] as TelegramHttpRequestLike | undefined;
if (typeof original !== "function") return;
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) {
var response = res as TelegramResponseLike | null;
maybeLogTelegramStartupProbe(info, response && response.statusCode);
maybeLogTelegramSendMessage(info, response && response.statusCode);
if (
!inboundUpdateLogged &&
telegramApiMethod(info) === "getUpdates" &&
response &&
typeof response.on === "function"
) {
var responseChunks: string[] = [];
var responseBytes = 0;
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);
});
response.on("end", function () {
maybeLogTelegramInboundUpdate(info, responseChunks.join(""));
});
}
});
req.once("error", function (error) {
maybeLogTelegramStartupError(info, error);
});
}
return req;
};
}
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)) {
providerStarted = true;
}
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: string) {
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");
process.nextTick(maybeLogCredentialPlaceholderDiagnostics);
// Defense in depth for #4314/#4390: if Telegram is configured but the
// bridge module never logs "starting provider" and never hits the Bot
// API within the startup window, surface a single actionable breadcrumb
// so the channel is observably broken instead of silently invisible.
//
// Gate to the OpenClaw gateway process flavors only. The preload is
// exported via NODE_OPTIONS, so every short-lived Node child the user
// spawns inside the sandbox (CLI tools, shells, npm scripts) also requires
// 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: unknown): string {
return (
String(value || "")
.split(/[\\/]/)
.pop() || ""
);
}
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";
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 noStartupTimer = setTimeout(function () {
if (providerStarted || startupProbeLogged) return;
var configPath = process.env.OPENCLAW_CONFIG_PATH || "/sandbox/.openclaw/openclaw.json";
try {
var fs = require("fs");
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 = asObject(telegram.accounts) || {};
if (!Object.keys(accounts).length) return;
} catch (_e) {
return;
}
emit(
"[telegram] [default] bridge did not start within " +
Math.round(STARTUP_GRACE_MS / 1000) +
"s; check channels.telegram.enabled, plugin entries, and gateway log",
);
}, STARTUP_GRACE_MS);
if (typeof noStartupTimer.unref === "function") noStartupTimer.unref();
})();

View file

@ -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,17 @@ export function createDefaultWechatHostQrLoginOptions(): WechatIlinkLoginHookOpt
function createWechatHostQrLoginRunner(): () => Promise<WechatLoginResult> {
return async () => {
logEnrollmentHelp();
const handler = HOST_QR_LOGIN_HANDLERS.wechat;
if (!handler) return { kind: "error", message: "no host-qr handler registered" };
let result: HostQrLoginResult;
try {
result = await handler();
} catch (error) {
result = { kind: "error", message: error instanceof Error ? error.message : String(error) };
}
const result: WechatHostQrLoginResult = await runWechatHostQrLogin();
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,
};
};
}

View file

@ -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",
},
},
},

View file

@ -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: {

View file

@ -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;

View file

@ -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,
@ -108,6 +107,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: "npm:@tencent-weixin/openclaw-weixin@2.4.3",
pin: true,
required: true,
},
],
state: {
persist: {
wechatConfig: ["accountId", "baseUrl", "userId"],
@ -133,25 +160,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",

View file

@ -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<string, string> } };

View file

@ -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_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

View file

@ -0,0 +1,194 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
//
// wechat-diagnostics.ts — adds runtime breadcrumbs for the
// @tencent-weixin/openclaw-weixin channel without changing channel behavior.
// 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".
type WechatDiagnosticsProcess = NodeJS.Process & {
__nemoclawWechatDiagnosticsInstalled?: boolean;
};
type WechatJsonObject = Record<string, unknown>;
type WechatRequestInfo = { hostname: string; path: string };
type WechatStderrWrite = (...args: unknown[]) => boolean;
type WechatHttpModuleLike = Record<string, unknown>;
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";
var diagnosticsProcess = process as WechatDiagnosticsProcess;
if (diagnosticsProcess.__nemoclawWechatDiagnosticsInstalled) return;
try {
Object.defineProperty(diagnosticsProcess, "__nemoclawWechatDiagnosticsInstalled", {
value: true,
});
} catch (_e) {
diagnosticsProcess.__nemoclawWechatDiagnosticsInstalled = true;
}
var providerStarted = false;
var readyLogged = false;
var inferenceLogged = false;
var inDiagnosticWrite = false;
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
// visible so an operator can still see the request shape.
text = text.replace(/(bot_token=)[^&\s"']+/gi, "$1<redacted>");
text = text.replace(/("bot_token"\s*:\s*")[^"]+/gi, "$1<redacted>");
text = text.replace(/Bearer\s+[A-Za-z0-9._~+\/=-]+/g, "Bearer <redacted>");
text = text.replace(
/\b(api[_-]?key|token|authorization|wechat[_-]?bot[_-]?token)\b(["']?\s*[:=]\s*["']?)[^"'\s,)]+/gi,
"$1$2<redacted>",
);
return text;
}
var stderr = process.stderr as NodeJS.WriteStream & { write: WechatStderrWrite };
var originalStderrWrite = stderr.write.bind(stderr) as WechatStderrWrite;
function emit(line: string): void {
if (inDiagnosticWrite) return;
inDiagnosticWrite = true;
try {
originalStderrWrite(line + "\n");
} finally {
inDiagnosticWrite = false;
}
}
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;
}
opts = asObject(arg2);
} else if (arg1 && typeof arg1 === "object") {
opts = asObject(arg1);
}
var hostname = "";
var pathStr = "";
if (url) {
hostname = url.hostname || "";
pathStr = (url.pathname || "") + (url.search || "");
}
if (opts) {
hostname = String(opts.hostname || opts.host || hostname || "");
pathStr = String(opts.path || pathStr || "");
}
if (hostname.indexOf(":") !== -1) hostname = hostname.split(":")[0];
return { hostname: hostname, path: pathStr };
}
// The iLink gateway uses dynamic per-account subdomains under
// *.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: string): boolean {
if (!hostname) return false;
return (
hostname === "weixin.qq.com" ||
hostname.endsWith(".weixin.qq.com") ||
hostname === "wechat.com" ||
hostname.endsWith(".wechat.com")
);
}
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: WechatRequestInfo, statusCode: unknown): void {
if (readyLogged) 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;
readyLogged = true;
emit(
"[wechat] [" +
accountIdFromEnv() +
"] provider ready (iLink reachable; agent replies use inference.local)",
);
}
function wrapHttp(mod: WechatHttpModuleLike, methodName: string): void {
var original = mod[methodName] as WechatHttpRequestLike | undefined;
if (typeof original !== "function") return;
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) {
var response = res as WechatResponseLike | null;
maybeLogWechatReady(info, response && response.statusCode);
});
}
return req;
};
}
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)) {
providerStarted = true;
}
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: string) {
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");
})();

View file

@ -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,25 +123,5 @@ export const whatsappManifest = {
},
],
},
hooks: [
{
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;

View file

@ -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);

View file

@ -7,6 +7,7 @@ import type {
ChannelHookOutputSpec,
ChannelManifest,
MessagingAgentId,
MessagingSerializableObject,
MessagingSerializableValue,
SandboxMessagingBuildStepPlan,
SandboxMessagingChannelPlan,
@ -21,6 +22,22 @@ export async function planBuildSteps(
hooks: MessagingHookRegistry,
): Promise<SandboxMessagingBuildStepPlan[]> {
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);

View file

@ -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,
},
];
}

View file

@ -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`,
};
}

View file

@ -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,12 +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 === "wechat")?.hookIds).toEqual([
"wechat-health-check",
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(
@ -540,7 +551,10 @@ describe("ManifestCompiler", () => {
"telegram-allowlist-aliases",
"telegram-config-prompt",
"telegram-get-me-reachability",
"telegram-openclaw-bridge-health",
"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"]);
@ -733,6 +747,7 @@ describe("ManifestCompiler", () => {
"networkPolicy",
"agentRender",
"buildSteps",
"runtimeSetup",
"stateUpdates",
"healthChecks",
] satisfies Array<keyof SandboxMessagingPlan>);
@ -772,7 +787,10 @@ describe("ManifestCompiler", () => {
"telegram-allowlist-aliases",
"telegram-config-prompt",
"telegram-get-me-reachability",
"telegram-openclaw-bridge-health",
"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 () => {

View file

@ -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,
};

View file

@ -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 () => {

View file

@ -11,7 +11,9 @@ import type {
MessagingCompilerWorkflow,
SandboxMessagingChannelPlan,
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";
@ -81,14 +83,24 @@ export class MessagingWorkflowPlanner {
context: MessagingWorkflowPlannerChannelMutationContext,
): Promise<SandboxMessagingPlan | null> {
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<SandboxMessagingPlan | null> {
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(
@ -103,10 +115,13 @@ export class MessagingWorkflowPlanner {
): Promise<SandboxMessagingPlan | null> {
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;
@ -289,6 +304,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),
});
@ -377,11 +393,54 @@ 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: <T extends { readonly channelId: MessagingChannelId }>(entry: T) => boolean,
): SandboxMessagingRuntimeSetupPlan {
return {
nodePreloads: (setup?.nodePreloads ?? []).filter(keepEntry),
envAliases: (setup?.envAliases ?? []).filter(keepEntry),
secretScans: (setup?.secretScans ?? []).filter(keepEntry),
};
}
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) => {

View file

@ -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",
}),
});
});
});

View file

@ -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));
}

View file

@ -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;
}

View file

@ -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)
);
}

View file

@ -8,6 +8,7 @@ import {
createBuiltInMessagingHookRegistry,
MessagingHookRegistry,
runMessagingHook,
runMessagingHookSync,
} from "./index";
const HOST_QR_HOOK = {
@ -36,8 +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",
@ -45,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,
},
},
},
},
},

View file

@ -22,14 +22,7 @@ export async function runMessagingHook(
context: MessagingHookRunContext,
): Promise<MessagingHookRunResult> {
const handler = registry.require(hook.handler);
const result = await handler({
channelId: context.channelId,
hookId: hook.id,
phase: hook.phase,
...(typeof context.isInteractive === "boolean" ? { isInteractive: context.isInteractive } : {}),
inputs: context.inputs,
outputDeclarations: hook.outputs,
});
const result = await handler(buildHandlerContext(hook, context));
const outputs = result.outputs ?? EMPTY_OUTPUTS;
assertHookOutputsMatchDeclaration(hook, outputs);
@ -42,6 +35,48 @@ export async function runMessagingHook(
};
}
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);
return {
hookId: hook.id,
handlerId: hook.handler,
phase: hook.phase,
outputs,
};
}
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<unknown> {
return (
typeof value === "object" &&
value !== null &&
"then" in value &&
typeof (value as { then?: unknown }).then === "function"
);
}
function assertHookOutputsMatchDeclaration(
hook: ChannelHookSpec,
outputs: MessagingHookOutputMap,

View file

@ -5,4 +5,5 @@ export * from "./hook-runner";
export * from "./registry";
export * from "./common";
export * from "./builtins";
export * from "./errors";
export type * from "./types";

View file

@ -1,10 +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 "./persistence";
export * from "./utils";

View file

@ -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<Record<MessagingAgentId, ChannelRuntimeSpec>>;
readonly agentPackages?: readonly ChannelAgentPackageSpec[];
readonly state: ChannelStateSpec;
readonly hooks: readonly ChannelHookSpec[];
}
@ -54,6 +56,8 @@ export interface ChannelPolicyPresetSpec {
readonly name: string;
readonly policyKeys?: readonly string[];
readonly agentPolicyKeys?: Partial<Record<MessagingAgentId, readonly string[]>>;
readonly requiredAtCreate?: boolean;
readonly validationWarningLines?: readonly string[];
}
/** How a channel obtains credential or session material. */
@ -81,6 +85,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. */
@ -106,6 +111,7 @@ export interface ChannelCredentialSpec {
readonly providerName: MessagingTemplateString;
readonly providerEnvKey: string;
readonly placeholder: MessagingTemplateString;
readonly primary?: boolean;
}
/** Manifest render declaration for supported output formats. */
@ -137,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<Record<string, readonly string[]>>;
@ -153,6 +210,7 @@ export interface ChannelRebuildHydrationSpec {
export type ChannelHookPhase =
| "enroll"
| "reachability-check"
| "pre-enable"
| "agent-install"
| "render"
| "apply"
@ -184,7 +242,9 @@ export interface ChannelHookOutputSpec {
| "build-arg"
| "build-file"
| "package-install"
| "agent-render";
| "agent-render"
| "health-check"
| "status";
readonly required?: boolean;
readonly value?: MessagingSerializableValue;
}
@ -201,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[];
}
@ -340,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;

View file

@ -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,124 @@ 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<SandboxMessagingChannelPlan, "hooks"> & {
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<SandboxMessagingChannelPlan, "displayName" | "authMode" | "active" | "selected">
> & {
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,
agentRender: _agentRender,
buildSteps: _buildSteps,
runtimeSetup: _runtimeSetup,
stateUpdates: _stateUpdates,
healthChecks: _healthChecks,
...rest
} = clonePlan(plan);
return {
...rest,
channels: channels.map(({ hooks: _hooks, ...channel }) => channel),
networkPolicy,
channels: channels.map((channel) => ({
channelId: channel.channelId,
active: channel.active,
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 +145,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<SandboxMessagingChannelPlan> & {
readonly channelId: string;
readonly inputs?: readonly Partial<SandboxMessagingInputReference>[];
};
export type MaybeCompactMessagingPlan = Omit<
Partial<SandboxMessagingPlan>,
"channels" | "credentialBindings"
> &
Pick<SandboxMessagingPlan, "schemaVersion" | "sandboxName" | "agent" | "workflow"> & {
readonly channels: readonly MaybeCompactMessagingChannelPlan[];
readonly disabledChannels: readonly string[];
readonly credentialBindings?: readonly Partial<SandboxMessagingCredentialBindingPlan>[];
};
function normalizePersistedChannel(
channel: MaybeCompactMessagingChannelPlan,
disabledSet: ReadonlySet<string>,
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>[],
): 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<SandboxMessagingInputReference> | 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>,
): 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<typeof createBuiltInChannelManifestRegistry>,
): 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<string, readonly SandboxMessagingInputReference[]>,
): 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>[],
): 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<SandboxMessagingCredentialBindingPlan>,
): 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<SandboxMessagingCredentialBindingPlan>,
): 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<string, MessagingSerializableValue> = {};
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<string, boolean> {
const availability: Record<string, boolean> = {};
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,

View file

@ -9,6 +9,7 @@ import {
getConfiguredChannelIdsFromPlan,
getDisabledChannelIdsFromPlan,
getMessagingChannelConfigFromPlan,
getMessagingPlanStateValues,
parseSandboxMessagingPlan,
} from "./plan-validation";
import { compactSandboxMessagingPlanForPersistence } from "./persistence";
@ -65,15 +66,84 @@ 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.networkPolicy).toEqual(source.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",
active: true,
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",
}),
],
});
});
@ -94,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<string, unknown>;
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<string, unknown>;
runtimeSetupPlan.runtimeSetup = {
nodePreloads: [null],
envAliases: [],
secretScans: [],
};
expect(parseSandboxMessagingPlan(runtimeSetupPlan), "runtimeSetup.nodePreloads").toBeNull();
});
});
describe("plan channel derivation", () => {
@ -108,4 +205,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",
});
});
});

View file

@ -2,7 +2,16 @@
// 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";
import {
type MaybeCompactMessagingPlan,
normalizePersistedSandboxMessagingPlanShape,
} from "./persistence";
export interface SandboxMessagingPlanParseOptions {
sandboxName?: string | null;
@ -22,12 +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, "agentRender") && !Array.isArray(value.agentRender)) ||
!Array.isArray(value.buildSteps) ||
!Array.isArray(value.stateUpdates) ||
!Array.isArray(value.healthChecks)
!isOptionalObjectArray(value, "credentialBindings") ||
(Object.hasOwn(value, "networkPolicy") && !isObject(value.networkPolicy)) ||
!isOptionalObjectArray(value, "agentRender") ||
!isOptionalObjectArray(value, "buildSteps") ||
!isRuntimeSetup(value.runtimeSetup) ||
!isOptionalObjectArray(value, "stateUpdates") ||
!isOptionalObjectArray(value, "healthChecks")
) {
return null;
}
@ -41,11 +51,22 @@ 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 (Array.isArray(channel.hooks) && channel.hooks.some((hook) => !isObject(hook))) {
return null;
}
if (supported && !supported.has(channel.channelId)) return null;
if (
value.channels.findIndex(
@ -58,7 +79,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),
);
}
@ -94,37 +115,71 @@ 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;
}
type MaybeCompactMessagingChannelPlan = Omit<SandboxMessagingPlan["channels"][number], "hooks"> & {
readonly hooks?: SandboxMessagingPlan["channels"][number]["hooks"];
};
export function getMessagingPlanStateValues(
plan: SandboxMessagingPlan | null | undefined,
): Record<string, MessagingSerializableValue> {
if (!plan) return {};
const values: Record<string, MessagingSerializableValue> = {};
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;
}
type MaybeCompactMessagingPlan = Omit<SandboxMessagingPlan, "agentRender" | "channels"> & {
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 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isOptionalObjectArray(value: Record<string, unknown>, 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) &&
value.nodePreloads.every(isObject) &&
value.envAliases.every(isObject) &&
value.secretScans.every(isObject)
);
}

View file

@ -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,
@ -405,11 +403,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;
@ -5180,9 +5174,7 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
hydrateMessagingChannelConfig,
messagingChannelConfigsEqual,
getSandboxReuseState,
computeTelegramRequireMention,
hasSandboxGpuDrift,
hasWechatConfigDrift,
getSandboxHermesToolGateways: (name) => registry.getSandbox(name)?.hermesToolGateways,
normalizeHermesToolGatewaySelections,
stringSetsEqual,

View file

@ -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);
});
});

View file

@ -528,12 +528,16 @@ export function shouldApplyDockerGpuPatch(
): boolean {
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;
const dockerDriverGateway =
options.dockerDriverGateway ?? (platform === "linux" || dockerDesktopWsl);
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.",

View file

@ -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/);

View file

@ -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) {

View file

@ -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<string, string[]> = {
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"];

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