Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions .agents/skills/nemoclaw-contributor-create-pr/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,10 +127,6 @@ Use the exact template structure below. Fill in each section based on the diff (
- [ ] 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)

## AI Disclosure
<!-- If an AI agent authored or co-authored this PR, check the box and name the tool. Remove this section for fully human-authored PRs. -->
- [ ] AI-assisted — tool: <!-- e.g., Claude Code, Cursor, GitHub Copilot -->

---
<!-- DCO sign-off required by CI. Run: git config user.name && git config user.email -->
Signed-off-by: {name} <{email}>
Expand All @@ -145,7 +141,6 @@ Follow these rules when filling in the template:
- **Changes:** Bullet list of key changes. Be specific — reference file names, commands, or behaviors that changed.
- **Type of Change:** Check exactly one box. Use `[x]` for checked, `[ ]` for unchecked.
- **Verification:** Check only the boxes for steps you actually ran and confirmed passing. Do not check boxes for steps you skipped or did not verify.
- **AI Disclosure:** As an AI agent, always check this box and name yourself (e.g., "Claude Code", "Cursor"). Do not remove this section. (Note: Human contributors may remove this section if the PR is fully human-authored.)
- **DCO Sign-Off:** Replace `{name}` and `{email}` with values from `git config user.name` and `git config user.email`.

## Step 6: Create the PR
Expand Down
20 changes: 19 additions & 1 deletion .coderabbit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -183,10 +183,12 @@ reviews:
- `cloud-e2e` — full onboard + cloud inference
- `sandbox-operations-e2e` — multi-sandbox lifecycle
- `rebuild-openclaw-e2e` — workspace state survives rebuild
- `messaging-compatible-endpoint-e2e` — Telegram + compatible endpoint
inference.local regression

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=cloud-e2e,sandbox-operations-e2e,rebuild-openclaw-e2e
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=cloud-e2e,sandbox-operations-e2e,rebuild-openclaw-e2e,messaging-compatible-endpoint-e2e
```

- path: "src/nemoclaw.ts"
Expand Down Expand Up @@ -367,6 +369,22 @@ reviews:
- path: "docs/reference/commands.md"
instructions: *e2e-docs-validation

- path: "test/e2e/test-messaging-compatible-endpoint.sh"
instructions: |
This script validates the Telegram + OpenAI-compatible endpoint
regression path with a local mock endpoint. Changes here affect
the hermetic coverage for managed inference.local routing from
messaging-triggered agent turns.

**E2E test recommendation:**
- `messaging-compatible-endpoint-e2e` — Telegram + compatible
endpoint inference.local routing

To run selectively:
```
gh workflow run nightly-e2e.yaml --ref <branch> -f jobs=messaging-compatible-endpoint-e2e
```

- path: ".github/workflows/nightly-e2e.yaml"
instructions: |
This is the nightly E2E workflow definition. Changes here affect
Expand Down
4 changes: 0 additions & 4 deletions .github/PULL_REQUEST_TEMPLATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@
- [ ] 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)

## AI Disclosure
<!-- If an AI agent authored or co-authored this PR, check the box and name the tool. Remove this section for fully human-authored PRs. -->
- [ ] AI-assisted — tool: <!-- e.g., Claude Code, Cursor, GitHub Copilot -->

---
<!-- DCO sign-off required by CI. Run: git config user.name && git config user.email -->
Signed-off-by: Your Name <your-email@example.com>
41 changes: 41 additions & 0 deletions .github/workflows/nightly-e2e.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# for Telegram + Discord + Slack. Uses fake tokens. Slack additionally
# exercises the slack-token-rewriter Bolt-shape → canonical placeholder
# translation (#2085). See PR #1081.
# messaging-compatible-endpoint-e2e
# Validates Telegram + OpenAI-compatible endpoint inference routing
# through inference.local with a hermetic local mock (#2766).
# token-rotation-e2e Validates that rotating a messaging token and re-running onboard
# propagates the new credential to the sandbox. Combined Telegram +
# Discord + Slack coverage with cross-talk assertions. See issue #1903.
Expand Down Expand Up @@ -45,6 +48,7 @@ on:
Comma-separated job names to run (empty = all).
Valid: cloud-e2e, cloud-onboard-e2e, cloud-inference-e2e,
skill-agent-e2e, docs-validation-e2e, messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
token-rotation-e2e, sandbox-survival-e2e,
issue-2478-crash-loop-recovery-e2e, hermes-e2e,
sandbox-operations-e2e, inference-routing-e2e,
Expand Down Expand Up @@ -270,6 +274,40 @@ jobs:
path: /tmp/nemoclaw-e2e-install.log
if-no-files-found: ignore

# ── Messaging + compatible endpoint regression (#2766) ───────
# Hermetic Telegram + OpenAI-compatible endpoint path. Uses a local mock
# endpoint and fake Telegram token, then asserts sandbox inference.local
# reaches the mock through the gateway provider route.
messaging-compatible-endpoint-e2e:
if: >-
github.repository == 'NVIDIA/NemoClaw' &&
(github.event_name != 'workflow_dispatch' ||
inputs.jobs == '' ||
contains(format(',{0},', inputs.jobs), ',messaging-compatible-endpoint-e2e,'))
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Run messaging compatible endpoint E2E test
env:
NEMOCLAW_NON_INTERACTIVE: "1"
NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1"
NEMOCLAW_SANDBOX_NAME: "e2e-msg-compat"
GITHUB_TOKEN: ${{ github.token }}
TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-e2e"
TELEGRAM_ALLOWED_IDS: "123456789"
run: bash test/e2e/test-messaging-compatible-endpoint.sh

- name: Upload install log on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: install-log-messaging-compatible-endpoint
path: /tmp/nemoclaw-e2e-messaging-compatible-endpoint-install.log
if-no-files-found: ignore

# ── Token rotation (credential propagation to L7 proxy) ─────
# Validates that rotating a messaging token and re-running onboard
# propagates the new credential to the sandbox. Uses two fake tokens
Expand Down Expand Up @@ -1427,6 +1465,7 @@ jobs:
skill-agent-e2e,
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down Expand Up @@ -1507,6 +1546,7 @@ jobs:
skill-agent-e2e,
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down Expand Up @@ -1602,6 +1642,7 @@ jobs:
skill-agent-e2e,
docs-validation-e2e,
messaging-providers-e2e,
messaging-compatible-endpoint-e2e,
token-rotation-e2e,
sandbox-survival-e2e,
hermes-e2e,
Expand Down
146 changes: 144 additions & 2 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -826,6 +826,144 @@ SLACK_GUARD_EOF
printf '[channels] Slack channel guard installed (NODE_OPTIONS updated)\n' >&2
}

# ── Telegram diagnostics (provider-ready + inference-failure clarity) ─
_TELEGRAM_DIAGNOSTICS_SCRIPT="/tmp/nemoclaw-telegram-diagnostics.js"

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_EOF'
// 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 readyLogged = false;
var inferenceLogged = 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(/(api[_-]?key|token|authorization)["'=:\s]+[A-Za-z0-9._~+\/=-]+/gi, '$1=<redacted>');
return text;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}

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 maybeLogTelegramReady(info, statusCode) {
if (readyLogged) return;
if (!info || info.hostname !== 'api.telegram.org') return;
if (!/\/(?:getUpdates|getMe|getWebhookInfo)(?:\?|$)/.test(info.path)) return;
if (Number(statusCode) < 200 || Number(statusCode) >= 300) return;
readyLogged = true;
emit('[telegram] [default] provider ready (Bot API 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 (info && info.hostname === 'api.telegram.org' && req && typeof req.once === 'function') {
req.once('response', function (res) {
maybeLogTelegramReady(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 (/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));
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
}
return ret;
};

var http = require('http');
var https = require('https');
wrapHttp(http, 'request');
wrapHttp(http, 'get');
wrapHttp(https, 'request');
wrapHttp(https, 'get');
})();
TELEGRAM_DIAGNOSTICS_EOF

export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT"
printf '[channels] Telegram diagnostics installed (NODE_OPTIONS updated)\n' >&2
}

_read_gateway_token() {
python3 - <<'PYTOKEN'
import json
Expand Down Expand Up @@ -1871,6 +2009,8 @@ GUARDENVEOF
echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_SECCOMP_GUARD_SCRIPT\""
# ciao network guard for connect sessions.
echo "export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_CIAO_GUARD_SCRIPT\""
# Telegram diagnostics for connect sessions — same conditional pattern.
echo "[ -f \"$_TELEGRAM_DIAGNOSTICS_SCRIPT\" ] && export NODE_OPTIONS=\"\${NODE_OPTIONS:+\$NODE_OPTIONS }--require $_TELEGRAM_DIAGNOSTICS_SCRIPT\""
# Slack channel guard for connect sessions. The guard file is installed later
# by install_slack_channel_guard() — conditional on the file existing at
# source-time so connect sessions started before Slack is configured are safe.
Expand Down Expand Up @@ -2209,6 +2349,7 @@ if [ "$(id -u)" -ne 0 ]; then
ensure_runtime_shell_env_shim
lock_rc_files "$_SANDBOX_HOME"
configure_messaging_channels
install_telegram_diagnostics
install_slack_token_rewriter
install_slack_channel_guard
verify_no_slack_secrets_on_disk
Expand Down Expand Up @@ -2255,7 +2396,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" "$_SLACK_GUARD_SCRIPT" "$_SLACK_REWRITER_SCRIPT"
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" "$_SLACK_REWRITER_SCRIPT"

# Start gateway in background, auto-pair, then wait
nohup "$OPENCLAW" gateway run --port "${_DASHBOARD_PORT}" >/tmp/gateway.log 2>&1 &
Expand Down Expand Up @@ -2300,6 +2441,7 @@ lock_rc_files "$_SANDBOX_HOME"
# Must run AFTER integrity check (to detect build-time tampering) and
# BEFORE chattr +i (which locks the config permanently).
configure_messaging_channels
install_telegram_diagnostics
install_slack_token_rewriter
install_slack_channel_guard
verify_no_slack_secrets_on_disk
Expand Down Expand Up @@ -2410,7 +2552,7 @@ provision_agent_workspaces
# 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" "$_SLACK_GUARD_SCRIPT" "$_SLACK_REWRITER_SCRIPT"
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" "$_SLACK_REWRITER_SCRIPT"

# Start the gateway as the 'gateway' user.
# SECURITY: The sandbox user cannot kill this process because it runs
Expand Down
16 changes: 10 additions & 6 deletions src/lib/inference-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,12 +90,16 @@ describe("inference selection config", () => {
expect(getProviderSelectionConfig("gemini-api", "gemini-2.5-pro")).toEqual(
expect.objectContaining({ model: "gemini-2.5-pro", providerLabel: "Google Gemini" }),
);
expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual(
expect.objectContaining({
model: "openrouter/auto",
providerLabel: "Other OpenAI-compatible endpoint",
}),
);
expect(getProviderSelectionConfig("compatible-endpoint", "openrouter/auto")).toEqual({
endpointType: "custom",
endpointUrl: INFERENCE_ROUTE_URL,
ncpPartner: null,
model: "openrouter/auto",
profile: DEFAULT_ROUTE_PROFILE,
credentialEnv: "COMPATIBLE_API_KEY",
provider: "compatible-endpoint",
providerLabel: "Other OpenAI-compatible endpoint",
});
// Full-object assertion for one local provider — uses dedicated
// credential env, not OPENAI_API_KEY (GH #2519).
expect(getProviderSelectionConfig("vllm-local", "meta-llama")).toEqual({
Expand Down
Loading
Loading