diff --git a/.github/workflows/e2e-brev.yaml b/.github/workflows/e2e-brev.yaml index 5f8e36502..046e24e67 100644 --- a/.github/workflows/e2e-brev.yaml +++ b/.github/workflows/e2e-brev.yaml @@ -19,6 +19,10 @@ name: e2e-brev # through $(cmd), backticks, quote breakout, ${VAR} expansion, # process table leak checks, and SANDBOX_NAME validation. # Requires running sandbox. +# messaging-providers — 20+ tests validating PR #1081: provider creation, credential +# isolation, openclaw.json config patching, network reachability, +# and L7 proxy token rewriting for Telegram + Discord. Creates +# its own sandbox (e2e-msg-provider). (~15 min) # all — Runs credential-sanitization + telegram-injection (NOT full, # which destroys the sandbox the security tests need). # @@ -41,6 +45,7 @@ on: - full - credential-sanitization - telegram-injection + - messaging-providers - all use_launchable: description: "Use CI launchable (true) or bare brev create + brev-setup.sh (false)" diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index 07ec1f320..41abd4343 100644 --- a/.github/workflows/nightly-e2e.yaml +++ b/.github/workflows/nightly-e2e.yaml @@ -7,6 +7,8 @@ # cloud-experimental-e2e Experimental cloud inference test (main script skips embedded # check-docs + final cleanup; follow-up steps run check-docs, # skip/05-network-policy.sh, then cleanup.sh --verify with if: always()). +# messaging-providers-e2e Validates messaging credential provider/placeholder/L7-proxy chain +# for Telegram + Discord. Uses fake tokens. See PR #1081. # sandbox-survival-e2e Sandbox survival across gateway restarts (onboard, inference, # gateway stop/start, verify sandbox + workspace + inference). # gpu-e2e Local Ollama inference on a GPU self-hosted runner. @@ -163,6 +165,39 @@ jobs: path: /tmp/nemoclaw-e2e-cloud-experimental-install.log if-no-files-found: ignore + # ── Messaging Providers E2E ────────────────────────────────── + # Validates the full provider/placeholder/L7-proxy chain for messaging + # credentials (Telegram, Discord). Uses fake tokens by default — the L7 + # proxy rewrites placeholders and the real API returns 401, proving the + # chain works. See: PR #1081 + messaging-providers-e2e: + if: github.repository == 'NVIDIA/NemoClaw' + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Run messaging providers E2E test + env: + NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }} + NEMOCLAW_NON_INTERACTIVE: "1" + NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE: "1" + NEMOCLAW_SANDBOX_NAME: "e2e-msg-provider" + NEMOCLAW_RECREATE_SANDBOX: "1" + GITHUB_TOKEN: ${{ github.token }} + TELEGRAM_BOT_TOKEN: "test-fake-telegram-token-e2e" + DISCORD_BOT_TOKEN: "test-fake-discord-token-e2e" + run: bash test/e2e/test-messaging-providers.sh + + - name: Upload install log on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: install-log-messaging-providers + path: /tmp/nemoclaw-e2e-install.log + if-no-files-found: ignore + # ── Sandbox survival (gateway restart recovery) ────────────── sandbox-survival-e2e: if: github.repository == 'NVIDIA/NemoClaw' @@ -242,8 +277,8 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, sandbox-survival-e2e, gpu-e2e] - if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, sandbox-survival-e2e, gpu-e2e] + if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.sandbox-survival-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} permissions: issues: write steps: diff --git a/CLAUDE.md b/CLAUDE.md index 1ed3ff8d8..56a4cbfdd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -137,3 +137,7 @@ All hooks managed by [prek](https://prek.j178.dev/) (installed via `npm install` - Update docs for any user-facing behavior changes - No secrets, API keys, or credentials committed - Limit open PRs to fewer than 10 + +## Claude Behavior Rules + +- **"cloude2e" = trigger `nightly-e2e.yaml` on the current branch.** No exploring workflows, no asking which one. One command: `gh api repos/NVIDIA/NemoClaw/actions/workflows/nightly-e2e.yaml/dispatches -f ref=`, then give the run link. diff --git a/Dockerfile b/Dockerfile index adef7f1d4..8b44f708c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,14 @@ ARG NEMOCLAW_INFERENCE_BASE_URL=https://inference.local/v1 ARG NEMOCLAW_INFERENCE_API=openai-completions ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30= ARG NEMOCLAW_WEB_CONFIG_B64=e30= +# Base64-encoded JSON list of messaging channel names to pre-configure +# (e.g. ["discord","telegram"]). Channels are added with placeholder tokens +# so the L7 proxy can rewrite them at egress. Default: empty list. +ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= +# Base64-encoded JSON map of channel→allowed sender IDs for DM allowlisting +# (e.g. {"telegram":["123456789"]}). Channels with IDs get dmPolicy=allowlist; +# channels without IDs keep the OpenClaw default (pairing). Default: empty map. +ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30= # Set to "1" to disable device-pairing auth (development/headless only). # Default: "0" (device auth enabled — secure by default). ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0 @@ -73,6 +81,8 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ NEMOCLAW_INFERENCE_API=${NEMOCLAW_INFERENCE_API} \ NEMOCLAW_INFERENCE_COMPAT_B64=${NEMOCLAW_INFERENCE_COMPAT_B64} \ NEMOCLAW_WEB_CONFIG_B64=${NEMOCLAW_WEB_CONFIG_B64} \ + NEMOCLAW_MESSAGING_CHANNELS_B64=${NEMOCLAW_MESSAGING_CHANNELS_B64} \ + NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${NEMOCLAW_MESSAGING_ALLOWED_IDS_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} WORKDIR /sandbox @@ -94,6 +104,11 @@ inference_base_url = os.environ['NEMOCLAW_INFERENCE_BASE_URL']; \ inference_api = os.environ['NEMOCLAW_INFERENCE_API']; \ inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_COMPAT_B64']).decode('utf-8')); \ web_config = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_WEB_CONFIG_B64', 'e30=') or 'e30=').decode('utf-8')); \ +msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CHANNELS_B64', 'W10=') or 'W10=').decode('utf-8')); \ +_allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ +_token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \ +_env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \ +_ch_cfg = {ch: {'accounts': {'main': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ parsed = urlparse(chat_ui_url); \ chat_origin = f'{parsed.scheme}://{parsed.netloc}' if parsed.scheme and parsed.netloc else 'http://127.0.0.1:18789'; \ origins = ['http://127.0.0.1:18789']; \ @@ -111,7 +126,7 @@ providers = { \ config = { \ 'agents': {'defaults': {'model': {'primary': primary_model_ref}}}, \ 'models': {'mode': 'merge', 'providers': providers}, \ - 'channels': {'defaults': {'configWrites': False}}, \ + 'channels': dict({'defaults': {'configWrites': False}}, **_ch_cfg), \ 'gateway': { \ 'mode': 'local', \ 'controlUi': { \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 40a8c0855..71263d020 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -877,6 +877,15 @@ async function promptValidationRecovery(label, recovery, credentialEnv = null, h return "selection"; } +/** + * Build the argument array for an `openshell provider create` or `update` command. + * @param {"create"|"update"} action - Whether to create or update. + * @param {string} name - Provider name. + * @param {string} type - Provider type (e.g. "openai", "anthropic", "generic"). + * @param {string} credentialEnv - Credential environment variable name. + * @param {string|null} baseUrl - Optional base URL for API-compatible endpoints. + * @returns {string[]} Argument array for runOpenshell(). + */ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { const args = action === "create" @@ -890,6 +899,18 @@ function buildProviderArgs(action, name, type, credentialEnv, baseUrl) { return args; } +/** + * Create or update an OpenShell provider in the gateway. + * + * Attempts `openshell provider create`; if that fails (provider already exists), + * falls back to `openshell provider update` with the same credential. + * @param {string} name - Provider name (e.g. "discord-bridge", "inference"). + * @param {string} type - Provider type ("openai", "anthropic", "generic"). + * @param {string} credentialEnv - Environment variable name for the credential. + * @param {string|null} baseUrl - Optional base URL for the provider endpoint. + * @param {Record} [env={}] - Environment variables for the openshell command. + * @returns {{ ok: boolean, status?: number, message?: string }} + */ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { const createArgs = buildProviderArgs("create", name, type, credentialEnv, baseUrl); const runOpts = { ignoreError: true, env, stdio: ["ignore", "pipe", "pipe"] }; @@ -916,6 +937,44 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { return { ok: true }; } +/** + * Upsert all messaging providers that have tokens configured. + * Returns the list of provider names that were successfully created/updated. + * Exits the process if any upsert fails. + * @param {Array<{name: string, envKey: string, token: string|null}>} tokenDefs + * @returns {string[]} Provider names that were upserted. + */ +function upsertMessagingProviders(tokenDefs) { + const providers = []; + for (const { name, envKey, token } of tokenDefs) { + if (!token) continue; + const result = upsertProvider(name, "generic", envKey, null, { [envKey]: token }); + if (!result.ok) { + console.error(`\n ✗ Failed to create messaging provider '${name}': ${result.message}`); + process.exit(1); + } + providers.push(name); + } + return providers; +} + +/** + * Check whether an OpenShell provider exists in the gateway. + * + * Queries the gateway-level provider registry via `openshell provider get`. + * Does NOT verify that the provider is attached to a specific sandbox — + * OpenShell CLI does not currently expose a sandbox-scoped provider query. + * @param {string} name - Provider name to look up (e.g. "discord-bridge"). + * @returns {boolean} True if the provider exists in the gateway. + */ +function providerExistsInGateway(name) { + const result = runOpenshell(["provider", "get", name], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); + return result.status === 0; +} + function verifyInferenceRoute(_provider, _model) { const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { @@ -1175,6 +1234,8 @@ function patchStagedDockerfile( provider = null, preferredInferenceApi = null, webSearchConfig = null, + messagingChannels = [], + messagingAllowedIds = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1218,6 +1279,18 @@ function patchStagedDockerfile( /^ARG NEMOCLAW_DISABLE_DEVICE_AUTH=.*$/m, `ARG NEMOCLAW_DISABLE_DEVICE_AUTH=1`, ); + if (messagingChannels.length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_CHANNELS_B64=${encodeDockerJsonArg(messagingChannels)}`, + ); + } + if (Object.keys(messagingAllowedIds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=.*$/m, + `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -2031,7 +2104,7 @@ function getNonInteractiveModel(providerKey) { // eslint-disable-next-line complexity async function preflight() { - step(1, 7, "Preflight checks"); + step(1, 8, "Preflight checks"); const host = assessHost(); @@ -2211,7 +2284,7 @@ async function preflight() { // ── Step 2: Gateway ────────────────────────────────────────────── async function startGatewayWithOptions(_gpu, { exitOnFailure = true } = {}) { - step(2, 7, "Starting OpenShell gateway"); + step(2, 8, "Starting OpenShell gateway"); const gatewayStatus = runCaptureOpenshell(["status"], { ignoreError: true }); const gwInfo = runCaptureOpenshell(["gateway", "info", "-g", GATEWAY_NAME], { @@ -2443,29 +2516,73 @@ async function createSandbox( sandboxNameOverride = null, webSearchConfig = null, ) { - step(5, 7, "Creating sandbox"); + step(6, 8, "Creating sandbox"); const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; + // Check whether messaging providers will be needed — this must happen before + // the sandbox reuse decision so we can detect stale sandboxes that were created + // without provider attachments (security: prevents legacy raw-env-var leaks). + const getMessagingToken = (envKey) => + getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + + const messagingTokenDefs = [ + { + name: `${sandboxName}-discord-bridge`, + envKey: "DISCORD_BOT_TOKEN", + token: getMessagingToken("DISCORD_BOT_TOKEN"), + }, + { + name: `${sandboxName}-slack-bridge`, + envKey: "SLACK_BOT_TOKEN", + token: getMessagingToken("SLACK_BOT_TOKEN"), + }, + { + name: `${sandboxName}-telegram-bridge`, + envKey: "TELEGRAM_BOT_TOKEN", + token: getMessagingToken("TELEGRAM_BOT_TOKEN"), + }, + ]; + const hasMessagingTokens = messagingTokenDefs.some(({ token }) => !!token); + // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); + + // Check whether messaging providers are missing from the gateway. Only + // force recreation when at least one required provider doesn't exist yet — + // this avoids destroying sandboxes already created with provider attachments. + const needsProviderMigration = + hasMessagingTokens && + messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); + if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { - ensureDashboardForward(sandboxName, chatUiUrl); - if (isNonInteractive()) { - note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); + if (needsProviderMigration) { + console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); + console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else { - console.log(` Sandbox '${sandboxName}' already exists and is ready.`); - console.log(" Reusing existing sandbox."); - console.log(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it instead."); + // Upsert messaging providers even on reuse so credential changes take + // effect without requiring a full sandbox recreation. Only the + // --provider attachment flags need to be on the create path. + upsertMessagingProviders(messagingTokenDefs); + ensureDashboardForward(sandboxName, chatUiUrl); + if (isNonInteractive()) { + note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); + } else { + console.log(` Sandbox '${sandboxName}' already exists and is ready.`); + console.log(" Reusing existing sandbox."); + console.log(" Set NEMOCLAW_RECREATE_SANDBOX=1 to recreate it instead."); + } + return sandboxName; } - return sandboxName; } - if (existingSandboxState === "ready") { + if (existingSandboxState === "ready" && needsProviderMigration) { + note(` Sandbox '${sandboxName}' exists — recreating to attach messaging providers.`); + } else if (existingSandboxState === "ready") { note(` Sandbox '${sandboxName}' exists and is ready — recreating by explicit request.`); } else { note(` Sandbox '${sandboxName}' exists but is not ready — recreating it.`); @@ -2499,6 +2616,15 @@ async function createSandbox( ]; // --gpu is intentionally omitted. See comment in startGateway(). + // Create OpenShell providers for messaging credentials so they flow through + // the provider/placeholder system instead of raw env vars. The L7 proxy + // rewrites Authorization headers (Bearer/Bot) and URL-path segments + // (/bot{TOKEN}/) with real secrets at egress (OpenShell ≥ 0.0.20). + const messagingProviders = upsertMessagingProviders(messagingTokenDefs); + for (const p of messagingProviders) { + createArgs.push("--provider", p); + } + console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); if (webSearchConfig && !getCredential(webSearch.BRAVE_API_KEY_ENV)) { console.error(" Brave Search is enabled, but BRAVE_API_KEY is not available in this process."); @@ -2507,6 +2633,28 @@ async function createSandbox( ); process.exit(1); } + const activeMessagingChannels = messagingTokenDefs + .filter(({ token }) => !!token) + .map(({ envKey }) => { + if (envKey === "DISCORD_BOT_TOKEN") return "discord"; + if (envKey === "SLACK_BOT_TOKEN") return "slack"; + if (envKey === "TELEGRAM_BOT_TOKEN") return "telegram"; + return null; + }) + .filter(Boolean); + // Build allowed sender IDs map from env vars set during the messaging prompt. + // Each channel with a userIdEnvKey in MESSAGING_CHANNELS may have a + // comma-separated list of IDs (e.g. TELEGRAM_ALLOWED_IDS="123,456"). + const messagingAllowedIds = {}; + for (const ch of MESSAGING_CHANNELS) { + if (ch.userIdEnvKey && process.env[ch.userIdEnvKey]) { + const ids = process.env[ch.userIdEnvKey] + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + if (ids.length > 0) messagingAllowedIds[ch.name] = ids; + } + } patchStagedDockerfile( stagedDockerfile, model, @@ -2515,24 +2663,29 @@ async function createSandbox( provider, preferredInferenceApi, webSearchConfig, + activeMessagingChannels, + messagingAllowedIds, ); - // Only pass non-sensitive env vars to the sandbox. NVIDIA_API_KEY is NOT - // needed inside the sandbox — inference is proxied through the OpenShell - // gateway which injects the stored credential server-side. The gateway - // also strips any Authorization headers sent by the sandbox client. - // See: crates/openshell-sandbox/src/proxy.rs (header stripping), - // crates/openshell-router/src/backend.rs (server-side auth injection). + // Only pass non-sensitive env vars to the sandbox. Credentials flow through + // OpenShell providers — the gateway injects them as placeholders and the L7 + // proxy rewrites Authorization headers with real secrets at egress. + // See: crates/openshell-sandbox/src/secrets.rs (placeholder rewriting), + // crates/openshell-router/src/backend.rs (inference auth injection). const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; - const sandboxEnv = { ...process.env }; - delete sandboxEnv.NVIDIA_API_KEY; - const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; - if (discordToken) { - sandboxEnv.DISCORD_BOT_TOKEN = discordToken; - } - const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; - if (slackToken) { - sandboxEnv.SLACK_BOT_TOKEN = slackToken; - } + const blockedSandboxEnvNames = new Set([ + // Derived from REMOTE_PROVIDER_CONFIG to prevent drift + ...Object.values(REMOTE_PROVIDER_CONFIG) + .map((cfg) => cfg.credentialEnv) + .filter(Boolean), + // Additional credentials not in REMOTE_PROVIDER_CONFIG + "BEDROCK_API_KEY", + "DISCORD_BOT_TOKEN", + "SLACK_BOT_TOKEN", + "TELEGRAM_BOT_TOKEN", + ]); + const sandboxEnv = Object.fromEntries( + Object.entries(process.env).filter(([name]) => !blockedSandboxEnvNames.has(name)), + ); // Run without piping through awk — the pipe masked non-zero exit codes // from openshell because bash returns the status of the last pipeline // command (awk, always 0) unless pipefail is set. Removing the pipe @@ -2638,6 +2791,18 @@ async function createSandbox( { ignoreError: true }, ); + // Check that messaging providers exist in the gateway (sandbox attachment + // cannot be verified via CLI yet — only gateway-level existence is checked). + for (const p of messagingProviders) { + if (!providerExistsInGateway(p)) { + console.error(` ⚠ Messaging provider '${p}' was not found in the gateway.`); + console.error(` The credential may not be available inside the sandbox.`); + console.error( + ` To fix: openshell provider create --name ${p} --type generic --credential `, + ); + } + } + console.log(` ✓ Sandbox '${sandboxName}' created`); return sandboxName; } @@ -2646,7 +2811,7 @@ async function createSandbox( // eslint-disable-next-line complexity async function setupNim(gpu) { - step(3, 7, "Configuring inference (NIM)"); + step(3, 8, "Configuring inference (NIM)"); let model = null; let provider = REMOTE_PROVIDER_CONFIG.build.providerName; @@ -3228,7 +3393,7 @@ async function setupInference( endpointUrl = null, credentialEnv = null, ) { - step(4, 7, "Setting up inference provider"); + step(4, 8, "Setting up inference provider"); runOpenshell(["gateway", "select", GATEWAY_NAME], { ignoreError: true }); if ( @@ -3362,10 +3527,191 @@ async function setupInference( return { ok: true }; } -// ── Step 6: OpenClaw ───────────────────────────────────────────── +// ── Step 6: Messaging channels ─────────────────────────────────── + +const MESSAGING_CHANNELS = [ + { + name: "telegram", + envKey: "TELEGRAM_BOT_TOKEN", + description: "Telegram bot messaging", + help: "Create a bot via @BotFather on Telegram, then copy the token.", + label: "Telegram Bot Token", + userIdEnvKey: "TELEGRAM_ALLOWED_IDS", + userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", + userIdLabel: "Telegram User ID (for DM access)", + }, + { + name: "discord", + envKey: "DISCORD_BOT_TOKEN", + description: "Discord bot messaging", + help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", + label: "Discord Bot Token", + }, + { + name: "slack", + envKey: "SLACK_BOT_TOKEN", + description: "Slack bot messaging", + help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", + label: "Slack Bot Token", + }, +]; + +async function setupMessagingChannels() { + step(5, 8, "Messaging channels"); + + const getMessagingToken = (envKey) => + getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + + // Non-interactive: skip prompt, tokens come from env/credentials + if (isNonInteractive() || process.env.NEMOCLAW_NON_INTERACTIVE === "1") { + const found = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map((c) => c.name); + if (found.length > 0) { + note(` [non-interactive] Messaging tokens detected: ${found.join(", ")}`); + } else { + note(" [non-interactive] No messaging tokens configured. Skipping."); + } + return; + } + + // Single-keypress toggle selector — pre-select channels that already have tokens. + // Press 1/2/3 to instantly toggle a channel; press Enter to continue. + const enabled = new Set( + MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map((c) => c.name), + ); + + const output = process.stderr; + // Lines above the prompt: 1 blank + 1 header + N channels + 1 blank = N + 3 + const linesAbovePrompt = MESSAGING_CHANNELS.length + 3; + let firstDraw = true; + const showList = () => { + if (!firstDraw) { + // Cursor is at end of prompt line. Move to column 0, go up, clear to end of screen. + output.write(`\r\x1b[${linesAbovePrompt}A\x1b[J`); + } + firstDraw = false; + output.write("\n"); + output.write(" Available messaging channels:\n"); + MESSAGING_CHANNELS.forEach((ch, i) => { + const marker = enabled.has(ch.name) ? "●" : "○"; + const status = getMessagingToken(ch.envKey) ? " (configured)" : ""; + output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); + }); + output.write("\n"); + output.write(" Press 1-3 to toggle, Enter when done: "); + }; + + showList(); + + await new Promise((resolve, reject) => { + const input = process.stdin; + let rawModeEnabled = false; + let finished = false; + + function cleanup() { + input.removeListener("data", onData); + if (rawModeEnabled && typeof input.setRawMode === "function") { + input.setRawMode(false); + } + } + + function finish() { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + resolve(); + } + + function onData(chunk) { + const text = chunk.toString("utf8"); + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]; + if (ch === "\u0003") { + cleanup(); + reject(Object.assign(new Error("Prompt interrupted"), { code: "SIGINT" })); + process.kill(process.pid, "SIGINT"); + return; + } + if (ch === "\r" || ch === "\n") { + finish(); + return; + } + const num = parseInt(ch, 10); + if (num >= 1 && num <= MESSAGING_CHANNELS.length) { + const channel = MESSAGING_CHANNELS[num - 1]; + if (enabled.has(channel.name)) { + enabled.delete(channel.name); + } else { + enabled.add(channel.name); + } + showList(); + } + } + } + + input.setEncoding("utf8"); + if (typeof input.resume === "function") { + input.resume(); + } + if (typeof input.setRawMode === "function") { + input.setRawMode(true); + rawModeEnabled = true; + } + input.on("data", onData); + }); + + const selected = Array.from(enabled); + if (selected.length === 0) { + console.log(" Skipping messaging channels."); + return; + } + + // For each selected channel, prompt for token if not already set + for (const name of selected) { + const ch = MESSAGING_CHANNELS.find((c) => c.name === name); + if (!ch) { + console.log(` Unknown channel: ${name}`); + continue; + } + if (getMessagingToken(ch.envKey)) { + console.log(` ✓ ${ch.name} — already configured`); + } else { + console.log(""); + console.log(` ${ch.help}`); + const token = normalizeCredentialValue(await prompt(` ${ch.label}: `, { secret: true })); + if (token) { + saveCredential(ch.envKey, token); + process.env[ch.envKey] = token; + console.log(` ✓ ${ch.name} token saved`); + } else { + console.log(` Skipped ${ch.name} (no token entered)`); + continue; + } + } + // Prompt for user/sender ID if the channel supports DM allowlisting + if (ch.userIdEnvKey) { + const existingIds = process.env[ch.userIdEnvKey] || ""; + if (existingIds) { + console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); + } else { + console.log(` ${ch.userIdHelp}`); + const userId = (await prompt(` ${ch.userIdLabel}: `)).trim(); + if (userId) { + process.env[ch.userIdEnvKey] = userId; + console.log(` ✓ ${ch.name} user ID saved`); + } else { + console.log(` Skipped ${ch.name} user ID (bot will require manual pairing)`); + } + } + } + } + console.log(""); +} + +// ── Step 7: OpenClaw ───────────────────────────────────────────── async function setupOpenclaw(sandboxName, model, provider) { - step(6, 7, "Setting up OpenClaw inside sandbox"); + step(7, 8, "Setting up OpenClaw inside sandbox"); const selectionConfig = getProviderSelectionConfig(provider, model); if (selectionConfig) { @@ -3392,7 +3738,7 @@ async function setupOpenclaw(sandboxName, model, provider) { // eslint-disable-next-line complexity async function _setupPolicies(sandboxName) { - step(7, 7, "Policy presets"); + step(8, 8, "Policy presets"); const suggestions = ["pypi", "npm"]; @@ -3545,7 +3891,7 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; - step(7, 7, "Policy presets"); + step(8, 8, "Policy presets"); const suggestions = ["pypi", "npm"]; if (getCredential("TELEGRAM_BOT_TOKEN")) suggestions.push("telegram"); @@ -3821,15 +4167,16 @@ const ONBOARD_STEP_INDEX = { gateway: { number: 2, title: "Starting OpenShell gateway" }, provider_selection: { number: 3, title: "Configuring inference (NIM)" }, inference: { number: 4, title: "Setting up inference provider" }, - sandbox: { number: 5, title: "Creating sandbox" }, - openclaw: { number: 6, title: "Setting up OpenClaw inside sandbox" }, - policies: { number: 7, title: "Policy presets" }, + messaging: { number: 5, title: "Messaging channels" }, + sandbox: { number: 6, title: "Creating sandbox" }, + openclaw: { number: 7, title: "Setting up OpenClaw inside sandbox" }, + policies: { number: 8, title: "Policy presets" }, }; function skippedStepMessage(stepName, detail, reason = "resume") { const stepInfo = ONBOARD_STEP_INDEX[stepName]; if (stepInfo) { - step(stepInfo.number, 7, stepInfo.title); + step(stepInfo.number, 8, stepInfo.title); } const prefix = reason === "reuse" ? "[reuse]" : "[resume]"; console.log(` ${prefix} Skipping ${stepName}${detail ? ` (${detail})` : ""}`); @@ -4101,6 +4448,8 @@ async function onboard(opts = {}) { } } } + await setupMessagingChannels(); + startRecordedStep("sandbox", { sandboxName, provider, model }); sandboxName = await createSandbox( gpu, @@ -4176,13 +4525,17 @@ async function onboard(opts = {}) { } module.exports = { + buildProviderArgs, buildSandboxConfigSyncScript, + compactText, copyBuildContextDir, classifySandboxCreateFailure, createSandbox, + formatEnvAssignment, getFutureShellPathHint, getGatewayStartEnv, getGatewayReuseState, + getNavigationChoice, getSandboxInferenceConfig, getInstalledOpenshellVersion, getRequestedModelHint, @@ -4203,6 +4556,8 @@ module.exports = { onboard, onboardSession, printSandboxCreateRecoveryHints, + providerExistsInGateway, + parsePolicyPresetEnv, pruneStaleSandboxEntry, repairRecordedSandbox, recoverGatewayRuntime, @@ -4215,6 +4570,9 @@ module.exports = { isOpenclawReady, arePolicyPresetsApplied, setupPoliciesWithSelection, + summarizeCurlFailure, + summarizeProbeFailure, + upsertProvider, hydrateCredentialEnv, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f80c4a272..823cc5119 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -137,6 +137,25 @@ os.chmod(path, 0o600) PYAUTH } +configure_messaging_channels() { + # Channel entries are baked into openclaw.json at image build time via + # NEMOCLAW_MESSAGING_CHANNELS_B64 (see Dockerfile). Placeholder tokens + # (openshell:resolve:env:*) flow through to API calls where the L7 proxy + # rewrites them with real secrets at egress. Real tokens are never visible + # inside the sandbox. + # + # Runtime patching of /sandbox/.openclaw/openclaw.json is not possible: + # Landlock enforces read-only on /sandbox/.openclaw/ at the kernel level, + # regardless of DAC (file ownership/chmod). Writes fail with EPERM. + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 + + echo "[channels] Messaging channels active (baked at build time):" >&2 + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && echo "[channels] telegram (native)" >&2 + [ -n "${DISCORD_BOT_TOKEN:-}" ] && echo "[channels] discord (native)" >&2 + [ -n "${SLACK_BOT_TOKEN:-}" ] && echo "[channels] slack (native)" >&2 + return 0 +} + print_dashboard_urls() { local token chat_ui_base local_url remote_url @@ -190,7 +209,7 @@ HANDLED = set() # Track rejected/approved requestIds to avoid reprocessing # is defense-in-depth, not a trust boundary. PR #690 adds one-shot exit, # timeout reduction, and token cleanup for a more comprehensive fix. ALLOWED_CLIENTS = {'openclaw-control-ui'} -ALLOWED_MODES = {'webchat'} +ALLOWED_MODES = {'webchat', 'cli'} def run(*args): proc = subprocess.run(args, capture_output=True, text=True) @@ -345,6 +364,7 @@ if [ "$(id -u)" -ne 0 ]; then echo "[SECURITY] Config integrity check failed — refusing to start (non-root mode)" >&2 exit 1 fi + configure_messaging_channels write_auth_profile if [ ${#NEMOCLAW_CMD[@]} -gt 0 ]; then @@ -375,6 +395,11 @@ fi # Verify config integrity before starting anything verify_config_integrity +# Inject messaging channel config if provider tokens are present. +# Must run AFTER integrity check (to detect build-time tampering) and +# BEFORE chattr +i (which locks the config permanently). +configure_messaging_channels + # Write auth profile as sandbox user (needs writable .openclaw-data) gosu sandbox bash -c "$(declare -f write_auth_profile); write_auth_profile" diff --git a/scripts/start-services.sh b/scripts/start-services.sh index ede47bee2..6aeeb0389 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -2,20 +2,20 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 # -# Start NemoClaw auxiliary services: Telegram bridge -# and cloudflared tunnel for public access. +# Start NemoClaw auxiliary services: cloudflared tunnel for public access. +# +# Messaging channels (Telegram, Discord, Slack) are now handled natively +# by OpenClaw inside the sandbox — no host-side bridges needed. +# See: nemoclaw-start.sh configure_messaging_channels() # # Usage: -# TELEGRAM_BOT_TOKEN=... ./scripts/start-services.sh # start all -# ./scripts/start-services.sh --status # check status -# ./scripts/start-services.sh --stop # stop all -# ./scripts/start-services.sh --sandbox mybox # start for specific sandbox -# ./scripts/start-services.sh --sandbox mybox --stop # stop for specific sandbox +# ./scripts/start-services.sh # start all +# ./scripts/start-services.sh --status # check status +# ./scripts/start-services.sh --stop # stop all +# ./scripts/start-services.sh --sandbox mybox # start for specific sandbox set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" DASHBOARD_PORT="${DASHBOARD_PORT:-18789}" # ── Parse flags ────────────────────────────────────────────────── @@ -97,13 +97,11 @@ stop_service() { show_status() { mkdir -p "$PIDDIR" echo "" - for svc in telegram-bridge cloudflared; do - if is_running "$svc"; then - echo -e " ${GREEN}●${NC} $svc (PID $(cat "$PIDDIR/$svc.pid"))" - else - echo -e " ${RED}●${NC} $svc (stopped)" - fi - done + if is_running cloudflared; then + echo -e " ${GREEN}●${NC} cloudflared (PID $(cat "$PIDDIR/cloudflared.pid"))" + else + echo -e " ${RED}●${NC} cloudflared (stopped)" + fi echo "" if [ -f "$PIDDIR/cloudflared.log" ]; then @@ -118,46 +116,13 @@ show_status() { do_stop() { mkdir -p "$PIDDIR" stop_service cloudflared - stop_service telegram-bridge info "All services stopped." } do_start() { - if [ -z "${TELEGRAM_BOT_TOKEN:-}" ]; then - warn "TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start." - warn "Create a bot via @BotFather on Telegram and set the token." - elif [ -z "${NVIDIA_API_KEY:-}" ]; then - warn "NVIDIA_API_KEY not set — Telegram bridge will not start." - warn "Set NVIDIA_API_KEY if you want Telegram requests to reach inference." - fi - - command -v node >/dev/null || fail "node not found. Install Node.js first." - - # WSL2 ships with broken IPv6 routing. Node.js resolves dual-stack DNS results - # and tries IPv6 first (ENETUNREACH) then IPv4 (ETIMEDOUT), causing bridge - # connections to api.telegram.org and gateway.discord.gg to fail from the host. - # Force IPv4-first DNS result ordering for all bridge Node.js processes. - if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -n "${WSL_INTEROP:-}" ] || grep -qi microsoft /proc/version 2>/dev/null; then - export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--dns-result-order=ipv4first" - info "WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes" - fi - - # Verify sandbox is running - if command -v openshell >/dev/null 2>&1; then - if ! openshell sandbox list 2>&1 | grep -q "Ready"; then - warn "No sandbox in Ready state. Telegram bridge may not work until sandbox is running." - fi - fi - mkdir -p "$PIDDIR" - # Telegram bridge (only if token provided) - if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${NVIDIA_API_KEY:-}" ]; then - SANDBOX_NAME="$SANDBOX_NAME" start_service telegram-bridge \ - node "$REPO_DIR/scripts/telegram-bridge.js" - fi - - # 3. cloudflared tunnel + # cloudflared tunnel if command -v cloudflared >/dev/null 2>&1; then start_service cloudflared \ cloudflared tunnel --url "http://localhost:$DASHBOARD_PORT" @@ -193,12 +158,7 @@ do_start() { printf " │ Public URL: %-40s│\n" "$tunnel_url" fi - if is_running telegram-bridge; then - echo " │ Telegram: bridge running │" - else - echo " │ Telegram: not started (no token) │" - fi - + echo " │ Messaging: via OpenClaw native channels (if configured) │" echo " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘" diff --git a/scripts/telegram-bridge.js b/scripts/telegram-bridge.js deleted file mode 100755 index 96a29fd88..000000000 --- a/scripts/telegram-bridge.js +++ /dev/null @@ -1,275 +0,0 @@ -#!/usr/bin/env node -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -/** - * Telegram → NemoClaw bridge. - * - * Messages from Telegram are forwarded to the OpenClaw agent running - * inside the sandbox. When the agent needs external access, the - * OpenShell TUI lights up for approval. Responses go back to Telegram. - * - * Env: - * TELEGRAM_BOT_TOKEN — from @BotFather - * NVIDIA_API_KEY — for inference - * SANDBOX_NAME — sandbox name (default: nemoclaw) - * ALLOWED_CHAT_IDS — comma-separated Telegram chat IDs to accept (optional, accepts all if unset) - */ - -const https = require("https"); -const { execFileSync, spawn } = require("child_process"); -const { resolveOpenshell } = require("../bin/lib/resolve-openshell"); -const { shellQuote, validateName } = require("../bin/lib/runner"); -const { parseAllowedChatIds, isChatAllowed } = require("../bin/lib/chat-filter"); - -const OPENSHELL = resolveOpenshell(); -if (!OPENSHELL) { - console.error("openshell not found on PATH or in common locations"); - process.exit(1); -} - -const TOKEN = process.env.TELEGRAM_BOT_TOKEN; -const API_KEY = process.env.NVIDIA_API_KEY; -const SANDBOX = process.env.SANDBOX_NAME || "nemoclaw"; -try { validateName(SANDBOX, "SANDBOX_NAME"); } catch (e) { console.error(e.message); process.exit(1); } -const ALLOWED_CHATS = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS); - -if (!TOKEN) { console.error("TELEGRAM_BOT_TOKEN required"); process.exit(1); } -if (!API_KEY) { console.error("NVIDIA_API_KEY required"); process.exit(1); } - -let offset = 0; -const activeSessions = new Map(); // chatId → message history - -const COOLDOWN_MS = 5000; -const lastMessageTime = new Map(); -const busyChats = new Set(); - -// ── Telegram API helpers ────────────────────────────────────────── - -function tgApi(method, body) { - return new Promise((resolve, reject) => { - const data = JSON.stringify(body); - const req = https.request( - { - hostname: "api.telegram.org", - path: `/bot${TOKEN}/${method}`, - method: "POST", - headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(data) }, - }, - (res) => { - let buf = ""; - res.on("data", (c) => (buf += c)); - res.on("end", () => { - try { resolve(JSON.parse(buf)); } catch { resolve({ ok: false, error: buf }); } - }); - }, - ); - req.on("error", reject); - req.write(data); - req.end(); - }); -} - -async function sendMessage(chatId, text, replyTo) { - // Telegram max message length is 4096 - const chunks = []; - for (let i = 0; i < text.length; i += 4000) { - chunks.push(text.slice(i, i + 4000)); - } - for (const chunk of chunks) { - await tgApi("sendMessage", { - chat_id: chatId, - text: chunk, - reply_to_message_id: replyTo, - parse_mode: "Markdown", - }).catch(() => - // Retry without markdown if it fails (unbalanced formatting) - tgApi("sendMessage", { chat_id: chatId, text: chunk, reply_to_message_id: replyTo }), - ); - } -} - -async function sendTyping(chatId) { - await tgApi("sendChatAction", { chat_id: chatId, action: "typing" }).catch(() => {}); -} - -// ── Run agent inside sandbox ────────────────────────────────────── - -function runAgentInSandbox(message, sessionId) { - return new Promise((resolve) => { - const sshConfig = execFileSync(OPENSHELL, ["sandbox", "ssh-config", SANDBOX], { encoding: "utf-8" }); - - // Write temp ssh config with unpredictable name - const confDir = require("fs").mkdtempSync("/tmp/nemoclaw-tg-ssh-"); - const confPath = `${confDir}/config`; - require("fs").writeFileSync(confPath, sshConfig, { mode: 0o600 }); - - // Pass message and API key via stdin to avoid shell interpolation. - // The remote command reads them from environment/stdin rather than - // embedding user content in a shell string. - const safeSessionId = String(sessionId).replace(/[^a-zA-Z0-9-]/g, ""); - const cmd = `export NVIDIA_API_KEY=${shellQuote(API_KEY)} && nemoclaw-start openclaw agent --agent main --local -m ${shellQuote(message)} --session-id ${shellQuote("tg-" + safeSessionId)}`; - - const proc = spawn("ssh", ["-T", "-F", confPath, `openshell-${SANDBOX}`, cmd], { - timeout: 120000, - stdio: ["ignore", "pipe", "pipe"], - }); - - let stdout = ""; - let stderr = ""; - - proc.stdout.on("data", (d) => (stdout += d.toString())); - proc.stderr.on("data", (d) => (stderr += d.toString())); - - proc.on("close", (code) => { - try { require("fs").unlinkSync(confPath); require("fs").rmdirSync(confDir); } catch { /* ignored */ } - - // Extract the actual agent response — skip setup lines - const lines = stdout.split("\n"); - const responseLines = lines.filter( - (l) => - !l.startsWith("Setting up NemoClaw") && - !l.startsWith("[plugins]") && - !l.startsWith("(node:") && - !l.includes("NemoClaw ready") && - !l.includes("NemoClaw registered") && - !l.includes("openclaw agent") && - !l.includes("┌─") && - !l.includes("│ ") && - !l.includes("└─") && - l.trim() !== "", - ); - - const response = responseLines.join("\n").trim(); - - if (response) { - resolve(response); - } else if (code !== 0) { - resolve(`Agent exited with code ${code}. ${stderr.trim().slice(0, 500)}`); - } else { - resolve("(no response)"); - } - }); - - proc.on("error", (err) => { - resolve(`Error: ${err.message}`); - }); - }); -} - -// ── Poll loop ───────────────────────────────────────────────────── - -async function poll() { - try { - const res = await tgApi("getUpdates", { offset, timeout: 30 }); - - if (res.ok && res.result?.length > 0) { - for (const update of res.result) { - offset = update.update_id + 1; - - const msg = update.message; - if (!msg?.text) continue; - - const chatId = String(msg.chat.id); - - // Access control - if (!isChatAllowed(ALLOWED_CHATS, chatId)) { - console.log(`[ignored] chat ${chatId} not in allowed list`); - continue; - } - - const userName = msg.from?.first_name || "someone"; - console.log(`[${chatId}] ${userName}: ${msg.text}`); - - // Handle /start - if (msg.text === "/start") { - await sendMessage( - chatId, - "🦀 *NemoClaw* — powered by Nemotron 3 Super 120B\n\n" + - "Send me a message and I'll run it through the OpenClaw agent " + - "inside an OpenShell sandbox.\n\n" + - "If the agent needs external access, the TUI will prompt for approval.", - msg.message_id, - ); - continue; - } - - // Handle /reset - if (msg.text === "/reset") { - activeSessions.delete(chatId); - await sendMessage(chatId, "Session reset.", msg.message_id); - continue; - } - - // Rate limiting: per-chat cooldown - const now = Date.now(); - const lastTime = lastMessageTime.get(chatId) || 0; - if (now - lastTime < COOLDOWN_MS) { - const wait = Math.ceil((COOLDOWN_MS - (now - lastTime)) / 1000); - await sendMessage(chatId, `Please wait ${wait}s before sending another message.`, msg.message_id); - continue; - } - - // Per-chat serialization: reject if this chat already has an active session - if (busyChats.has(chatId)) { - await sendMessage(chatId, "Still processing your previous message.", msg.message_id); - continue; - } - - lastMessageTime.set(chatId, now); - busyChats.add(chatId); - - // Send typing indicator - await sendTyping(chatId); - - // Keep a typing indicator going while agent runs - const typingInterval = setInterval(() => sendTyping(chatId), 4000); - - try { - const response = await runAgentInSandbox(msg.text, chatId); - clearInterval(typingInterval); - console.log(`[${chatId}] agent: ${response.slice(0, 100)}...`); - await sendMessage(chatId, response, msg.message_id); - } catch (err) { - clearInterval(typingInterval); - await sendMessage(chatId, `Error: ${err.message}`, msg.message_id); - } finally { - busyChats.delete(chatId); - } - } - } - } catch (err) { - console.error("Poll error:", err.message); - } - - // Continue polling (1s floor prevents tight-loop resource waste) - setTimeout(poll, 1000); -} - -// ── Main ────────────────────────────────────────────────────────── - -async function main() { - const me = await tgApi("getMe", {}); - if (!me.ok) { - console.error("Failed to connect to Telegram:", JSON.stringify(me)); - process.exit(1); - } - - console.log(""); - console.log(" ┌─────────────────────────────────────────────────────┐"); - console.log(" │ NemoClaw Telegram Bridge │"); - console.log(" │ │"); - console.log(` │ Bot: @${(me.result.username + " ").slice(0, 37)}│`); - console.log(" │ Sandbox: " + (SANDBOX + " ").slice(0, 40) + "│"); - console.log(" │ Model: nvidia/nemotron-3-super-120b-a12b │"); - console.log(" │ │"); - console.log(" │ Messages are forwarded to the OpenClaw agent │"); - console.log(" │ inside the sandbox. Run 'openshell term' in │"); - console.log(" │ another terminal to monitor + approve egress. │"); - console.log(" └─────────────────────────────────────────────────────┘"); - console.log(""); - - poll(); -} - -main(); diff --git a/src/lib/services.test.ts b/src/lib/services.test.ts index 702438e48..b726a969d 100644 --- a/src/lib/services.test.ts +++ b/src/lib/services.test.ts @@ -26,17 +26,16 @@ describe("getServiceStatuses", () => { it("returns stopped status when no PID files exist", () => { const statuses = getServiceStatuses({ pidDir }); - expect(statuses).toHaveLength(2); + expect(statuses).toHaveLength(1); for (const s of statuses) { expect(s.running).toBe(false); expect(s.pid).toBeNull(); } }); - it("returns service names telegram-bridge and cloudflared", () => { + it("returns service name cloudflared", () => { const statuses = getServiceStatuses({ pidDir }); const names = statuses.map((s) => s.name); - expect(names).toContain("telegram-bridge"); expect(names).toContain("cloudflared"); }); @@ -51,18 +50,18 @@ describe("getServiceStatuses", () => { }); it("ignores invalid PID file contents", () => { - writeFileSync(join(pidDir, "telegram-bridge.pid"), "not-a-number"); + writeFileSync(join(pidDir, "cloudflared.pid"), "not-a-number"); const statuses = getServiceStatuses({ pidDir }); - const tg = statuses.find((s) => s.name === "telegram-bridge"); - expect(tg?.pid).toBeNull(); - expect(tg?.running).toBe(false); + const cf = statuses.find((s) => s.name === "cloudflared"); + expect(cf?.pid).toBeNull(); + expect(cf?.running).toBe(false); }); it("creates pidDir if it does not exist", () => { const nested = join(pidDir, "nested", "deep"); const statuses = getServiceStatuses({ pidDir: nested }); expect(existsSync(nested)).toBe(true); - expect(statuses).toHaveLength(2); + expect(statuses).toHaveLength(1); }); }); @@ -99,7 +98,6 @@ describe("showStatus", () => { const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); showStatus({ pidDir }); const output = logSpy.mock.calls.map((c) => c[0]).join("\n"); - expect(output).toContain("telegram-bridge"); expect(output).toContain("cloudflared"); expect(output).toContain("stopped"); logSpy.mockRestore(); @@ -135,14 +133,12 @@ describe("stopAll", () => { it("removes stale PID files", () => { writeFileSync(join(pidDir, "cloudflared.pid"), "999999999"); - writeFileSync(join(pidDir, "telegram-bridge.pid"), "999999998"); const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); stopAll({ pidDir }); logSpy.mockRestore(); expect(existsSync(join(pidDir, "cloudflared.pid"))).toBe(false); - expect(existsSync(join(pidDir, "telegram-bridge.pid"))).toBe(false); }); it("is idempotent — calling twice does not throw", () => { diff --git a/src/lib/services.ts b/src/lib/services.ts index ba5b1134e..7e53cd319 100644 --- a/src/lib/services.ts +++ b/src/lib/services.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { execFileSync, execSync, spawn } from "node:child_process"; +import { execSync, spawn } from "node:child_process"; import { closeSync, existsSync, @@ -12,7 +12,6 @@ import { unlinkSync, } from "node:fs"; import { join } from "node:path"; -import { platform } from "node:os"; // --------------------------------------------------------------------------- // Types @@ -101,7 +100,7 @@ function removePid(pidDir: string, name: string): void { // Service lifecycle // --------------------------------------------------------------------------- -const SERVICE_NAMES = ["telegram-bridge", "cloudflared"] as const; +const SERVICE_NAMES = ["cloudflared"] as const; type ServiceName = (typeof SERVICE_NAMES)[number]; function startService( @@ -242,65 +241,18 @@ export function stopAll(opts: ServiceOptions = {}): void { const pidDir = resolvePidDir(opts); ensurePidDir(pidDir); stopService(pidDir, "cloudflared"); - stopService(pidDir, "telegram-bridge"); info("All services stopped."); } export async function startAll(opts: ServiceOptions = {}): Promise { const pidDir = resolvePidDir(opts); const dashboardPort = opts.dashboardPort ?? (Number(process.env.DASHBOARD_PORT) || 18789); - // Compiled location: dist/lib/services.js → repo root is 2 levels up - const repoDir = opts.repoDir ?? join(__dirname, "..", ".."); - - if (!process.env.TELEGRAM_BOT_TOKEN) { - warn("TELEGRAM_BOT_TOKEN not set — Telegram bridge will not start."); - warn("Create a bot via @BotFather on Telegram and set the token."); - } else if (!process.env.NVIDIA_API_KEY) { - warn("NVIDIA_API_KEY not set — Telegram bridge will not start."); - warn("Set NVIDIA_API_KEY if you want Telegram requests to reach inference."); - } - - // Warn if no sandbox is ready - try { - const output = execFileSync("openshell", ["sandbox", "list"], { - encoding: "utf-8", - stdio: ["ignore", "pipe", "pipe"], - }); - if (!output.includes("Ready")) { - warn("No sandbox in Ready state. Telegram bridge may not work until sandbox is running."); - } - } catch { - /* openshell not installed or no ready sandbox — skip check */ - } ensurePidDir(pidDir); - // WSL2 ships with broken IPv6 routing — force IPv4-first DNS for bridge processes - if (platform() === "linux") { - const isWSL = - !!process.env.WSL_DISTRO_NAME || - !!process.env.WSL_INTEROP || - (existsSync("/proc/version") && - readFileSync("/proc/version", "utf-8").toLowerCase().includes("microsoft")); - if (isWSL) { - const existing = process.env.NODE_OPTIONS ?? ""; - process.env.NODE_OPTIONS = `${existing ? existing + " " : ""}--dns-result-order=ipv4first`; - info("WSL2 detected — setting --dns-result-order=ipv4first for Node.js bridge processes"); - } - } - - // Telegram bridge (only if both token and API key are set) - if (process.env.TELEGRAM_BOT_TOKEN && process.env.NVIDIA_API_KEY) { - const sandboxName = - opts.sandboxName ?? process.env.NEMOCLAW_SANDBOX ?? process.env.SANDBOX_NAME ?? "default"; - startService( - pidDir, - "telegram-bridge", - "node", - [join(repoDir, "scripts", "telegram-bridge.js")], - { SANDBOX_NAME: sandboxName }, - ); - } + // Messaging (Telegram, Discord, Slack) is now handled natively by OpenClaw + // inside the sandbox via the OpenShell provider/placeholder/L7-proxy pipeline. + // No host-side bridge processes are needed. See: PR #1081. // cloudflared tunnel try { @@ -353,11 +305,7 @@ export async function startAll(opts: ServiceOptions = {}): Promise { console.log(` │ Public URL: ${tunnelUrl.padEnd(40)}│`); } - if (isRunning(pidDir, "telegram-bridge")) { - console.log(" │ Telegram: bridge running │"); - } else { - console.log(" │ Telegram: not started (no token) │"); - } + console.log(" │ Messaging: via OpenClaw native channels (if configured) │"); console.log(" │ │"); console.log(" │ Run 'openshell term' to monitor egress approvals │"); diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 828432633..ca148ba1c 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -63,7 +63,19 @@ describe("credential exposure in process arguments", () => { it("onboard.js does not embed sandbox secrets in the sandbox create command line", () => { const src = fs.readFileSync(ONBOARD_JS, "utf-8"); - expect(src).toMatch(/const sandboxEnv = \{ \.\.\.process\.env \};/); + // sandboxEnv must be built with a blocklist that strips all credential env vars. + // The blocklist derives provider keys from REMOTE_PROVIDER_CONFIG and adds + // messaging tokens explicitly. Verify both mechanisms are present. + const blocklistMatch = src.match(/const blockedSandboxEnvNames = new Set\(\[([\s\S]*?)\]\);/); + expect(blocklistMatch).not.toBeNull(); + const blocklist = blocklistMatch[1]; + // Provider credentials are derived from REMOTE_PROVIDER_CONFIG + expect(blocklist).toContain("REMOTE_PROVIDER_CONFIG"); + // Messaging and additional credentials are listed explicitly + expect(blocklist).toContain('"BEDROCK_API_KEY"'); + expect(blocklist).toContain('"DISCORD_BOT_TOKEN"'); + expect(blocklist).toContain('"SLACK_BOT_TOKEN"'); + expect(blocklist).toContain('"TELEGRAM_BOT_TOKEN"'); expect(src).toMatch(/streamSandboxCreate\(createCommand, sandboxEnv(?:, \{)?/); expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("NVIDIA_API_KEY"/); expect(src).not.toMatch(/envArgs\.push\(formatEnvAssignment\("DISCORD_BOT_TOKEN"/); diff --git a/test/e2e/brev-e2e.test.js b/test/e2e/brev-e2e.test.js index a7e4bd17a..a874b3385 100644 --- a/test/e2e/brev-e2e.test.js +++ b/test/e2e/brev-e2e.test.js @@ -19,12 +19,18 @@ * The local `brev` CLI must already be authenticated before this suite runs. * * Optional env vars: - * TEST_SUITE — which test to run: full (default), deploy-cli, credential-sanitization, telegram-injection, all + * TEST_SUITE — which test to run: full (default), deploy-cli, credential-sanitization, + * telegram-injection, messaging-providers, all * LAUNCHABLE_SETUP_SCRIPT — URL to setup script for launchable path (default: brev-launchable-ci-cpu.sh on main) * BREV_MIN_VCPU — Minimum vCPUs for CPU instance (default: 4) * BREV_MIN_RAM — Minimum RAM in GB for CPU instance (default: 16) * BREV_PROVIDER — Cloud provider filter for brev search (default: gcp) * BREV_MIN_DISK — Minimum disk size in GB (default: 50) + * TELEGRAM_BOT_TOKEN — Telegram bot token for messaging-providers test (fake OK) + * DISCORD_BOT_TOKEN — Discord bot token for messaging-providers test (fake OK) + * TELEGRAM_BOT_TOKEN_REAL — Real Telegram token for optional live round-trip + * DISCORD_BOT_TOKEN_REAL — Real Discord token for optional live round-trip + * TELEGRAM_CHAT_ID_E2E — Telegram chat ID for optional sendMessage test */ import { describe, it, expect, beforeAll, afterAll } from "vitest"; @@ -134,13 +140,26 @@ function shellEscape(value) { /** Run a command on the remote VM with env vars set for NemoClaw. */ function sshEnv(cmd, { timeout = 600_000, stream = false } = {}) { - const envPrefix = [ + const envParts = [ `export NVIDIA_API_KEY='${shellEscape(process.env.NVIDIA_API_KEY)}'`, `export GITHUB_TOKEN='${shellEscape(process.env.GITHUB_TOKEN)}'`, `export NEMOCLAW_NON_INTERACTIVE=1`, `export NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1`, `export NEMOCLAW_SANDBOX_NAME=e2e-test`, - ].join(" && "); + ]; + // Forward optional messaging tokens for the messaging-providers test + for (const key of [ + "TELEGRAM_BOT_TOKEN", + "DISCORD_BOT_TOKEN", + "TELEGRAM_BOT_TOKEN_REAL", + "DISCORD_BOT_TOKEN_REAL", + "TELEGRAM_CHAT_ID_E2E", + ]) { + if (process.env[key]) { + envParts.push(`export ${key}='${shellEscape(process.env[key])}'`); + } + } + const envPrefix = envParts.join(" && "); return ssh(`${envPrefix} && ${cmd}`, { timeout, stream }); } @@ -668,4 +687,17 @@ describe.runIf(hasRequiredVars && hasAuthenticatedBrev)("Brev E2E", () => { }, 120_000, ); + + // NOTE: The messaging-providers test creates its own sandbox (e2e-msg-provider) + // with messaging tokens attached. It does not conflict with the e2e-test sandbox + // used by other tests, but it may recreate the gateway. + it.runIf(TEST_SUITE === "messaging-providers" || TEST_SUITE === "all")( + "messaging credential provider suite passes on remote VM", + () => { + const output = runRemoteTest("test/e2e/test-messaging-providers.sh"); + expect(output).toContain("PASS"); + expect(output).not.toMatch(/FAIL:/); + }, + 900_000, // 15 min — creates a new sandbox with messaging providers + ); }); diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh new file mode 100755 index 000000000..6b0103294 --- /dev/null +++ b/test/e2e/test-messaging-providers.sh @@ -0,0 +1,653 @@ +#!/bin/bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# shellcheck disable=SC2016,SC2034 +# SC2016: Single-quoted strings are intentional — Node.js code passed via SSH. +# SC2034: Some variables are used indirectly or reserved for later phases. + +# Messaging Credential Provider E2E Tests +# +# Validates that messaging credentials (Telegram, Discord) flow correctly +# through the OpenShell provider/placeholder/L7-proxy pipeline. Tests every +# layer of the chain introduced in PR #1081: +# +# 1. Provider creation — openshell stores the real token +# 2. Sandbox attachment — --provider flags wire providers to the sandbox +# 3. Credential isolation — real tokens never appear in sandbox env +# 4. Config patching — openclaw.json channels use placeholder values +# 5. Network reachability — Node.js can reach messaging APIs through proxy +# 6. L7 proxy rewriting — placeholder is rewritten to real token at egress +# +# Uses fake tokens by default (no external accounts needed). With fake tokens, +# the API returns 401 — proving the full chain worked (request reached the +# real API with the token rewritten). Optional real tokens enable a bonus +# round-trip phase. +# +# Prerequisites: +# - Docker running +# - NemoClaw installed (install.sh or brev-setup.sh already ran) +# - NVIDIA_API_KEY set +# - openshell on PATH +# +# Environment variables: +# NVIDIA_API_KEY — required +# NEMOCLAW_NON_INTERACTIVE=1 — required +# NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 — required +# NEMOCLAW_SANDBOX_NAME — sandbox name (default: e2e-msg-provider) +# TELEGRAM_BOT_TOKEN — defaults to fake token +# DISCORD_BOT_TOKEN — defaults to fake token +# TELEGRAM_ALLOWED_IDS — comma-separated Telegram user IDs for DM allowlisting +# TELEGRAM_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip +# DISCORD_BOT_TOKEN_REAL — optional: enables Phase 6 real round-trip +# TELEGRAM_CHAT_ID_E2E — optional: enables sendMessage test +# +# Usage: +# NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ +# NVIDIA_API_KEY=nvapi-... bash test/e2e/test-messaging-providers.sh +# +# See: https://github.com/NVIDIA/NemoClaw/pull/1081 + +set -uo pipefail + +PASS=0 +FAIL=0 +SKIP=0 +TOTAL=0 + +pass() { + ((PASS++)) + ((TOTAL++)) + printf '\033[32m PASS: %s\033[0m\n' "$1" +} +fail() { + ((FAIL++)) + ((TOTAL++)) + printf '\033[31m FAIL: %s\033[0m\n' "$1" +} +skip() { + ((SKIP++)) + ((TOTAL++)) + printf '\033[33m SKIP: %s\033[0m\n' "$1" +} +section() { + echo "" + printf '\033[1;36m=== %s ===\033[0m\n' "$1" +} +info() { printf '\033[1;34m [info]\033[0m %s\n' "$1"; } + +# Determine repo root +if [ -d /workspace ] && [ -f /workspace/install.sh ]; then + REPO="/workspace" +elif [ -f "$(cd "$(dirname "$0")/../.." && pwd)/install.sh" ]; then + REPO="$(cd "$(dirname "$0")/../.." && pwd)" +else + echo "ERROR: Cannot find repo root." + exit 1 +fi + +SANDBOX_NAME="${NEMOCLAW_SANDBOX_NAME:-e2e-msg-provider}" + +# Default to fake tokens if not provided +TELEGRAM_TOKEN="${TELEGRAM_BOT_TOKEN:-test-fake-telegram-token-e2e}" +DISCORD_TOKEN="${DISCORD_BOT_TOKEN:-test-fake-discord-token-e2e}" +TELEGRAM_IDS="${TELEGRAM_ALLOWED_IDS:-123456789}" +export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" +export DISCORD_BOT_TOKEN="$DISCORD_TOKEN" +export TELEGRAM_ALLOWED_IDS="$TELEGRAM_IDS" + +# Run a command inside the sandbox and capture output +sandbox_exec() { + local cmd="$1" + local ssh_config + ssh_config="$(mktemp)" + openshell sandbox ssh-config "$SANDBOX_NAME" >"$ssh_config" 2>/dev/null + + local result + result=$(timeout 60 ssh -F "$ssh_config" \ + -o StrictHostKeyChecking=no \ + -o UserKnownHostsFile=/dev/null \ + -o ConnectTimeout=10 \ + -o LogLevel=ERROR \ + "openshell-${SANDBOX_NAME}" \ + "$cmd" \ + 2>&1) || true + + rm -f "$ssh_config" + echo "$result" +} + +# ══════════════════════════════════════════════════════════════════ +# Phase 0: Prerequisites +# ══════════════════════════════════════════════════════════════════ +section "Phase 0: Prerequisites" + +if [ -z "${NVIDIA_API_KEY:-}" ]; then + fail "NVIDIA_API_KEY not set" + exit 1 +fi +pass "NVIDIA_API_KEY is set" + +if ! docker info >/dev/null 2>&1; then + fail "Docker is not running" + exit 1 +fi +pass "Docker is running" + +info "Telegram token: ${TELEGRAM_TOKEN:0:10}... (${#TELEGRAM_TOKEN} chars)" +info "Discord token: ${DISCORD_TOKEN:0:10}... (${#DISCORD_TOKEN} chars)" +info "Sandbox name: $SANDBOX_NAME" + +# ══════════════════════════════════════════════════════════════════ +# Phase 1: Install NemoClaw (non-interactive mode) +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Install NemoClaw with messaging tokens" + +cd "$REPO" || exit 1 + +# Pre-cleanup: destroy any leftover sandbox from previous runs +info "Pre-cleanup..." +if command -v nemoclaw >/dev/null 2>&1; then + nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +fi +if command -v openshell >/dev/null 2>&1; then + openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + openshell gateway destroy -g nemoclaw 2>/dev/null || true +fi +pass "Pre-cleanup complete" + +# Run install.sh --non-interactive which installs Node.js, openshell, +# NemoClaw, and runs onboard. Messaging tokens are already exported so +# the onboard step creates providers and attaches them to the sandbox. +info "Running install.sh --non-interactive..." +info "This installs Node.js, openshell, NemoClaw, and runs onboard with messaging providers." +info "Expected duration: 5-10 minutes on first run." + +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" +export NEMOCLAW_RECREATE_SANDBOX=1 + +INSTALL_LOG="/tmp/nemoclaw-e2e-install.log" +bash install.sh --non-interactive >"$INSTALL_LOG" 2>&1 & +install_pid=$! +tail -f "$INSTALL_LOG" --pid=$install_pid 2>/dev/null & +tail_pid=$! +wait $install_pid +install_exit=$? +kill $tail_pid 2>/dev/null || true +wait $tail_pid 2>/dev/null || true + +# Source shell profile to pick up nvm/PATH changes from install.sh +if [ -f "$HOME/.bashrc" ]; then + # shellcheck source=/dev/null + source "$HOME/.bashrc" 2>/dev/null || true +fi +export NVM_DIR="${NVM_DIR:-$HOME/.nvm}" +if [ -s "$NVM_DIR/nvm.sh" ]; then + # shellcheck source=/dev/null + . "$NVM_DIR/nvm.sh" +fi +if [ -d "$HOME/.local/bin" ] && [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then + export PATH="$HOME/.local/bin:$PATH" +fi + +if [ $install_exit -eq 0 ]; then + pass "M0: install.sh completed (exit 0)" +else + fail "M0: install.sh failed (exit $install_exit)" + info "Last 30 lines of install log:" + tail -30 "$INSTALL_LOG" 2>/dev/null || true + exit 1 +fi + +# Verify tools are on PATH +if ! command -v openshell >/dev/null 2>&1; then + fail "openshell not found on PATH after install" + exit 1 +fi +pass "openshell installed ($(openshell --version 2>&1 || echo unknown))" + +if ! command -v nemoclaw >/dev/null 2>&1; then + fail "nemoclaw not found on PATH after install" + exit 1 +fi +pass "nemoclaw installed at $(command -v nemoclaw)" + +# Verify sandbox is ready +sandbox_list=$(openshell sandbox list 2>&1 || true) +if echo "$sandbox_list" | grep -q "$SANDBOX_NAME.*Ready"; then + pass "M0b: Sandbox '$SANDBOX_NAME' is Ready" +else + fail "M0b: Sandbox '$SANDBOX_NAME' not Ready (list: ${sandbox_list:0:200})" + exit 1 +fi + +# M1: Verify Telegram provider exists in gateway +if openshell provider get "${SANDBOX_NAME}-telegram-bridge" >/dev/null 2>&1; then + pass "M1: Provider '${SANDBOX_NAME}-telegram-bridge' exists in gateway" +else + fail "M1: Provider '${SANDBOX_NAME}-telegram-bridge' not found in gateway" +fi + +# M2: Verify Discord provider exists in gateway +if openshell provider get "${SANDBOX_NAME}-discord-bridge" >/dev/null 2>&1; then + pass "M2: Provider '${SANDBOX_NAME}-discord-bridge' exists in gateway" +else + fail "M2: Provider '${SANDBOX_NAME}-discord-bridge' not found in gateway" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 2: Credential Isolation — env vars inside sandbox +# ══════════════════════════════════════════════════════════════════ +section "Phase 2: Credential Isolation" + +# M3: TELEGRAM_BOT_TOKEN inside sandbox must NOT contain the host-side token +sandbox_telegram=$(sandbox_exec "printenv TELEGRAM_BOT_TOKEN" 2>/dev/null || true) +if [ -z "$sandbox_telegram" ]; then + info "TELEGRAM_BOT_TOKEN not set inside sandbox (provider-only mode)" + TELEGRAM_PLACEHOLDER="" +elif echo "$sandbox_telegram" | grep -qF "$TELEGRAM_TOKEN"; then + fail "M3: Real Telegram token leaked into sandbox env" +else + pass "M3: Sandbox TELEGRAM_BOT_TOKEN is a placeholder (not the real token)" + TELEGRAM_PLACEHOLDER="$sandbox_telegram" + info "Telegram placeholder: ${TELEGRAM_PLACEHOLDER:0:30}..." +fi + +# M4: DISCORD_BOT_TOKEN inside sandbox must NOT contain the host-side token +sandbox_discord=$(sandbox_exec "printenv DISCORD_BOT_TOKEN" 2>/dev/null || true) +if [ -z "$sandbox_discord" ]; then + info "DISCORD_BOT_TOKEN not set inside sandbox (provider-only mode)" + DISCORD_PLACEHOLDER="" +elif echo "$sandbox_discord" | grep -qF "$DISCORD_TOKEN"; then + fail "M4: Real Discord token leaked into sandbox env" +else + pass "M4: Sandbox DISCORD_BOT_TOKEN is a placeholder (not the real token)" + DISCORD_PLACEHOLDER="$sandbox_discord" + info "Discord placeholder: ${DISCORD_PLACEHOLDER:0:30}..." +fi + +# M5: At least one placeholder should be present for subsequent phases +if [ -n "$TELEGRAM_PLACEHOLDER" ] || [ -n "$DISCORD_PLACEHOLDER" ]; then + pass "M5: At least one messaging placeholder detected in sandbox" +else + skip "M5: No messaging placeholders found — OpenShell may not inject them as env vars" + info "Subsequent phases that depend on placeholders will adapt" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 3: Config Patching — openclaw.json channels +# ══════════════════════════════════════════════════════════════════ +section "Phase 3: Config Patching Verification" + +# Read openclaw.json and extract channel config +channel_json=$(sandbox_exec "python3 -c \" +import json, sys +try: + cfg = json.load(open('/sandbox/.openclaw/openclaw.json')) + channels = cfg.get('channels', {}) + print(json.dumps(channels)) +except Exception as e: + print(json.dumps({'error': str(e)})) +\"" 2>/dev/null || true) + +if [ -z "$channel_json" ] || echo "$channel_json" | grep -q '"error"'; then + fail "M6: Could not read openclaw.json channels (${channel_json:0:200})" +else + info "Channel config: ${channel_json:0:300}" + + # M6: Telegram channel exists with a bot token + # Note: non-root sandboxes cannot patch openclaw.json (chmod 444, root-owned). + # Channels still work via L7 proxy token rewriting without config patching. + # SKIP (not FAIL) when channels are absent — this is the expected non-root path. + tg_token=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('botToken', '')) +" 2>/dev/null || true) + + if [ -n "$tg_token" ]; then + pass "M6: Telegram channel botToken present in openclaw.json" + else + skip "M6: Telegram channel not in openclaw.json (expected in non-root sandbox)" + fi + + # M7: Telegram token is NOT the real/fake host token + if [ -n "$tg_token" ] && [ "$tg_token" != "$TELEGRAM_TOKEN" ]; then + pass "M7: Telegram botToken is not the host-side token (placeholder confirmed)" + elif [ -n "$tg_token" ]; then + fail "M7: Telegram botToken matches host-side token — credential leaked into config!" + else + skip "M7: No Telegram botToken to check" + fi + + # M8: Discord channel exists with a token + dc_token=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('token', '')) +" 2>/dev/null || true) + + if [ -n "$dc_token" ]; then + pass "M8: Discord channel token present in openclaw.json" + else + skip "M8: Discord channel not in openclaw.json (expected in non-root sandbox)" + fi + + # M9: Discord token is NOT the real/fake host token + if [ -n "$dc_token" ] && [ "$dc_token" != "$DISCORD_TOKEN" ]; then + pass "M9: Discord token is not the host-side token (placeholder confirmed)" + elif [ -n "$dc_token" ]; then + fail "M9: Discord token matches host-side token — credential leaked into config!" + else + skip "M9: No Discord token to check" + fi + + # M10: Telegram enabled + tg_enabled=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('enabled', False)) +" 2>/dev/null || true) + + if [ "$tg_enabled" = "True" ]; then + pass "M10: Telegram channel is enabled" + else + skip "M10: Telegram channel not enabled (expected in non-root sandbox)" + fi + + # M11: Discord enabled + dc_enabled=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('enabled', False)) +" 2>/dev/null || true) + + if [ "$dc_enabled" = "True" ]; then + pass "M11: Discord channel is enabled" + else + skip "M11: Discord channel not enabled (expected in non-root sandbox)" + fi + + # M11b: Telegram dmPolicy is allowlist (not pairing) + tg_dm_policy=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('dmPolicy', '')) +" 2>/dev/null || true) + + if [ "$tg_dm_policy" = "allowlist" ]; then + pass "M11b: Telegram dmPolicy is 'allowlist'" + elif [ -n "$tg_dm_policy" ]; then + fail "M11b: Telegram dmPolicy is '$tg_dm_policy' (expected 'allowlist')" + else + skip "M11b: Telegram dmPolicy not set (channel may not be configured)" + fi + + # M11c: Telegram allowFrom contains the expected user IDs + tg_allow_from=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +ids = d.get('telegram', {}).get('accounts', {}).get('main', {}).get('allowFrom', []) +print(','.join(str(i) for i in ids)) +" 2>/dev/null || true) + + if [ -n "$tg_allow_from" ]; then + # Check that at least one of the configured IDs is present + IFS=',' read -ra expected_ids <<<"$TELEGRAM_IDS" + found_match=false + for eid in "${expected_ids[@]}"; do + if echo "$tg_allow_from" | grep -qF "$eid"; then + found_match=true + break + fi + done + if [ "$found_match" = "true" ]; then + pass "M11c: Telegram allowFrom contains expected user ID(s): $tg_allow_from" + else + fail "M11c: Telegram allowFrom ($tg_allow_from) does not contain any expected ID ($TELEGRAM_IDS)" + fi + else + skip "M11c: Telegram allowFrom not set (channel may not be configured)" + fi +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 4: Network Reachability +# ══════════════════════════════════════════════════════════════════ +section "Phase 4: Network Reachability" + +# M12: Node.js can reach api.telegram.org through the proxy +tg_reach=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const req = https.get(\"https://api.telegram.org/\", (res) => { + console.log(\"HTTP_\" + res.statusCode); + res.resume(); +}); +req.on(\"error\", (e) => console.log(\"ERROR: \" + e.message)); +req.setTimeout(15000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); +"' 2>/dev/null || true) + +if echo "$tg_reach" | grep -q "HTTP_"; then + pass "M12: Node.js reached api.telegram.org (${tg_reach})" +elif echo "$tg_reach" | grep -q "TIMEOUT"; then + skip "M12: api.telegram.org timed out (network may be slow)" +else + fail "M12: Node.js could not reach api.telegram.org (${tg_reach:0:200})" +fi + +# M13: Node.js can reach discord.com through the proxy +dc_reach=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const req = https.get(\"https://discord.com/api/v10/gateway\", (res) => { + console.log(\"HTTP_\" + res.statusCode); + res.resume(); +}); +req.on(\"error\", (e) => console.log(\"ERROR: \" + e.message)); +req.setTimeout(15000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); +"' 2>/dev/null || true) + +if echo "$dc_reach" | grep -q "HTTP_"; then + pass "M13: Node.js reached discord.com (${dc_reach})" +elif echo "$dc_reach" | grep -q "TIMEOUT"; then + skip "M13: discord.com timed out (network may be slow)" +else + fail "M13: Node.js could not reach discord.com (${dc_reach:0:200})" +fi + +# M14 (negative): curl should be blocked by binary restriction +curl_reach=$(sandbox_exec "curl -s --max-time 10 https://api.telegram.org/ 2>&1" 2>/dev/null || true) +if echo "$curl_reach" | grep -qiE "(blocked|denied|forbidden|refused|not found|no such)"; then + pass "M14: curl to api.telegram.org blocked (binary restriction enforced)" +elif [ -z "$curl_reach" ]; then + pass "M14: curl returned empty (likely blocked by policy)" +else + # curl may not be installed in the sandbox at all + if echo "$curl_reach" | grep -qiE "(command not found|not installed)"; then + pass "M14: curl not available in sandbox (defense in depth)" + else + info "M14: curl output: ${curl_reach:0:200}" + skip "M14: Could not confirm curl is blocked (may need manual check)" + fi +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 5: L7 Proxy Token Rewriting +# ══════════════════════════════════════════════════════════════════ +section "Phase 5: L7 Proxy Token Rewriting" + +# M15-M16: Telegram getMe with placeholder token +# If proxy rewrites correctly: reaches Telegram → 401 (fake) or 200 (real) +# If proxy is broken: proxy error, timeout, or mangled URL +info "Calling api.telegram.org/bot{placeholder}/getMe from inside sandbox..." +tg_api=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const token = process.env.TELEGRAM_BOT_TOKEN || \"missing\"; +const url = \"https://api.telegram.org/bot\" + token + \"/getMe\"; +const req = https.get(url, (res) => { + let body = \"\"; + res.on(\"data\", (d) => body += d); + res.on(\"end\", () => console.log(res.statusCode + \" \" + body.slice(0, 300))); +}); +req.on(\"error\", (e) => console.log(\"ERROR: \" + e.message)); +req.setTimeout(30000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); +"' 2>/dev/null || true) + +info "Telegram API response: ${tg_api:0:300}" + +# Filter out Node.js warnings (e.g. UNDICI-EHPA) before extracting status code +tg_status=$(echo "$tg_api" | grep -E '^[0-9]' | head -1 | awk '{print $1}') +if [ "$tg_status" = "200" ]; then + pass "M15: Telegram getMe returned 200 — real token verified!" +elif [ "$tg_status" = "401" ] || [ "$tg_status" = "404" ]; then + # Telegram returns 404 (not 401) for invalid bot tokens in the URL path. + # Either status proves the L7 proxy rewrote the placeholder and the request + # reached the real Telegram API. + pass "M15: Telegram getMe returned $tg_status — L7 proxy rewrote placeholder (fake token rejected by API)" + pass "M16: Full chain verified: sandbox → proxy → token rewrite → Telegram API" +elif echo "$tg_api" | grep -q "TIMEOUT"; then + skip "M15: Telegram API timed out (network issue, not a plumbing failure)" +elif echo "$tg_api" | grep -q "ERROR"; then + fail "M15: Telegram API call failed with error: ${tg_api:0:200}" +else + fail "M15: Unexpected Telegram response (status=$tg_status): ${tg_api:0:200}" +fi + +# M17: Discord users/@me with placeholder token +info "Calling discord.com/api/v10/users/@me from inside sandbox..." +dc_api=$(sandbox_exec 'node -e " +const https = require(\"https\"); +const token = process.env.DISCORD_BOT_TOKEN || \"missing\"; +const options = { + hostname: \"discord.com\", + path: \"/api/v10/users/@me\", + headers: { \"Authorization\": \"Bot \" + token }, +}; +const req = https.get(options, (res) => { + let body = \"\"; + res.on(\"data\", (d) => body += d); + res.on(\"end\", () => console.log(res.statusCode + \" \" + body.slice(0, 300))); +}); +req.on(\"error\", (e) => console.log(\"ERROR: \" + e.message)); +req.setTimeout(30000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); +"' 2>/dev/null || true) + +info "Discord API response: ${dc_api:0:300}" + +# Filter out Node.js warnings (e.g. UNDICI-EHPA) before extracting status code +dc_status=$(echo "$dc_api" | grep -E '^[0-9]' | head -1 | awk '{print $1}') +if [ "$dc_status" = "200" ]; then + pass "M17: Discord users/@me returned 200 — real token verified!" +elif [ "$dc_status" = "401" ]; then + pass "M17: Discord users/@me returned 401 — L7 proxy rewrote placeholder (fake token rejected by API)" +elif echo "$dc_api" | grep -q "TIMEOUT"; then + skip "M17: Discord API timed out (network issue, not a plumbing failure)" +elif echo "$dc_api" | grep -q "ERROR"; then + fail "M17: Discord API call failed with error: ${dc_api:0:200}" +else + fail "M17: Unexpected Discord response (status=$dc_status): ${dc_api:0:200}" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 6: Real API Round-Trip (Optional) +# ══════════════════════════════════════════════════════════════════ +section "Phase 6: Real API Round-Trip (Optional)" + +if [ -n "${TELEGRAM_BOT_TOKEN_REAL:-}" ]; then + info "Real Telegram token available — testing live round-trip" + + # M18: Telegram getMe with real token should return 200 + bot info + # Note: the real token must be set up as the provider credential, not as env + # For this to work, the sandbox must have been created with the real token + if [ "$tg_status" = "200" ]; then + pass "M18: Telegram getMe returned 200 with real token" + if echo "$tg_api" | grep -q '"ok":true'; then + pass "M18b: Telegram response contains ok:true" + fi + else + fail "M18: Expected Telegram getMe 200 with real token, got: $tg_status" + fi + + # M19: sendMessage if chat ID is available + if [ -n "${TELEGRAM_CHAT_ID_E2E:-}" ]; then + info "Sending test message to chat ${TELEGRAM_CHAT_ID_E2E}..." + send_result=$(sandbox_exec "node -e \" +const https = require('https'); +const token = process.env.TELEGRAM_BOT_TOKEN || ''; +const chatId = '${TELEGRAM_CHAT_ID_E2E}'; +const msg = 'NemoClaw E2E test ' + new Date().toISOString(); +const data = JSON.stringify({ chat_id: chatId, text: msg }); +const options = { + hostname: 'api.telegram.org', + path: '/bot' + token + '/sendMessage', + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Content-Length': data.length }, +}; +const req = https.request(options, (res) => { + let body = ''; + res.on('data', (d) => body += d); + res.on('end', () => console.log(res.statusCode + ' ' + body.slice(0, 300))); +}); +req.on('error', (e) => console.log('ERROR: ' + e.message)); +req.setTimeout(30000, () => { req.destroy(); console.log('TIMEOUT'); }); +req.write(data); +req.end(); +\"" 2>/dev/null || true) + + if echo "$send_result" | grep -q "^200"; then + pass "M19: Telegram sendMessage succeeded" + else + fail "M19: Telegram sendMessage failed: ${send_result:0:200}" + fi + else + skip "M19: TELEGRAM_CHAT_ID_E2E not set — skipping sendMessage test" + fi +else + skip "M18: TELEGRAM_BOT_TOKEN_REAL not set — skipping real Telegram round-trip" + skip "M19: TELEGRAM_BOT_TOKEN_REAL not set — skipping sendMessage test" +fi + +if [ -n "${DISCORD_BOT_TOKEN_REAL:-}" ]; then + if [ "$dc_status" = "200" ]; then + pass "M20: Discord users/@me returned 200 with real token" + else + fail "M20: Expected Discord users/@me 200 with real token, got: $dc_status" + fi +else + skip "M20: DISCORD_BOT_TOKEN_REAL not set — skipping real Discord round-trip" +fi + +# ══════════════════════════════════════════════════════════════════ +# Phase 7: Cleanup +# ══════════════════════════════════════════════════════════════════ +section "Phase 7: Cleanup" + +info "Destroying sandbox '$SANDBOX_NAME'..." +nemoclaw "$SANDBOX_NAME" destroy --yes 2>/dev/null || true +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + +# Verify cleanup +if openshell sandbox list 2>&1 | grep -q "$SANDBOX_NAME"; then + fail "Cleanup: Sandbox '$SANDBOX_NAME' still present after cleanup" +else + pass "Cleanup: Sandbox '$SANDBOX_NAME' removed" +fi + +# ══════════════════════════════════════════════════════════════════ +# Summary +# ══════════════════════════════════════════════════════════════════ +echo "" +echo "========================================" +echo " Messaging Provider Test Results:" +echo " Passed: $PASS" +echo " Failed: $FAIL" +echo " Skipped: $SKIP" +echo " Total: $TOTAL" +echo "========================================" + +if [ "$FAIL" -eq 0 ]; then + printf '\n\033[1;32m Messaging provider tests PASSED.\033[0m\n' + exit 0 +else + printf '\n\033[1;31m %d test(s) FAILED.\033[0m\n' "$FAIL" + exit 1 +fi diff --git a/test/onboard.test.js b/test/onboard.test.js index b6f0a858f..cf2ea37e1 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -9,8 +9,12 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { + buildProviderArgs, buildSandboxConfigSyncScript, classifySandboxCreateFailure, + compactText, + formatEnvAssignment, + getNavigationChoice, getGatewayReuseState, getPortConflictServiceHints, getFutureShellPathHint, @@ -27,9 +31,12 @@ import { classifyValidationFailure, isLoopbackHostname, normalizeProviderBaseUrl, + parsePolicyPresetEnv, patchStagedDockerfile, printSandboxCreateRecoveryHints, resolveDashboardForwardTarget, + summarizeCurlFailure, + summarizeProbeFailure, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; @@ -559,6 +566,124 @@ describe("onboard helpers", () => { } }); + it("formatEnvAssignment produces NAME=VALUE pairs for sandbox env", () => { + expect(formatEnvAssignment("CHAT_UI_URL", "http://127.0.0.1:18789")).toBe( + "CHAT_UI_URL=http://127.0.0.1:18789", + ); + expect(formatEnvAssignment("EMPTY", "")).toBe("EMPTY="); + }); + + it("compactText collapses whitespace and trims leading/trailing space", () => { + expect(compactText(" gateway unreachable ")).toBe("gateway unreachable"); + expect(compactText("")).toBe(""); + expect(compactText()).toBe(""); + expect(compactText("single")).toBe("single"); + expect(compactText("line1\n line2\t\tline3")).toBe("line1 line2 line3"); + }); + + it("getNavigationChoice recognizes back and exit commands case-insensitively", () => { + expect(getNavigationChoice("back")).toBe("back"); + expect(getNavigationChoice("BACK")).toBe("back"); + expect(getNavigationChoice(" Back ")).toBe("back"); + expect(getNavigationChoice("exit")).toBe("exit"); + expect(getNavigationChoice("quit")).toBe("exit"); + expect(getNavigationChoice("QUIT")).toBe("exit"); + expect(getNavigationChoice("")).toBeNull(); + expect(getNavigationChoice("something")).toBeNull(); + expect(getNavigationChoice(null)).toBeNull(); + }); + + it("parsePolicyPresetEnv splits comma-separated preset names and trims whitespace", () => { + expect(parsePolicyPresetEnv("strict,standard")).toEqual(["strict", "standard"]); + expect(parsePolicyPresetEnv(" strict , standard , ")).toEqual(["strict", "standard"]); + expect(parsePolicyPresetEnv("")).toEqual([]); + expect(parsePolicyPresetEnv(null)).toEqual([]); + expect(parsePolicyPresetEnv("single")).toEqual(["single"]); + }); + + it("summarizeCurlFailure formats curl errors with exit code and truncated detail", () => { + expect(summarizeCurlFailure(7, "Connection refused", "")).toBe( + "curl failed (exit 7): Connection refused", + ); + expect(summarizeCurlFailure(28, "", "")).toBe("curl failed (exit 28)"); + expect(summarizeCurlFailure(0, "", "")).toBe("curl failed (exit 0)"); + }); + + it("summarizeProbeFailure prioritizes curl failures then HTTP status then generic message", () => { + // curl failure takes precedence + expect(summarizeProbeFailure("body", 500, 7, "Connection refused")).toBe( + "curl failed (exit 7): Connection refused", + ); + // HTTP error when no curl failure + expect(summarizeProbeFailure("Not Found", 404, 0, "")).toBe("HTTP 404: Not Found"); + // Fallback: no curl failure and no body → HTTP status with no body message + expect(summarizeProbeFailure("", 0, 0, "")).toBe("HTTP 0 with no response body"); + // Non-JSON body gets compacted and returned + expect(summarizeProbeFailure(" Service Unavailable ", 503, 0, "")).toBe( + "HTTP 503: Service Unavailable", + ); + }); + + it("buildProviderArgs produces correct create arguments for generic providers", () => { + const args = buildProviderArgs( + "create", + "discord-bridge", + "generic", + "DISCORD_BOT_TOKEN", + null, + ); + expect(args).toEqual([ + "provider", + "create", + "--name", + "discord-bridge", + "--type", + "generic", + "--credential", + "DISCORD_BOT_TOKEN", + ]); + }); + + it("buildProviderArgs produces correct update arguments", () => { + const args = buildProviderArgs("update", "inference", "openai", "NVIDIA_API_KEY", null); + expect(args).toEqual(["provider", "update", "inference", "--credential", "NVIDIA_API_KEY"]); + }); + + it("buildProviderArgs appends OPENAI_BASE_URL config for openai providers with a base URL", () => { + const args = buildProviderArgs( + "create", + "inference", + "openai", + "NVIDIA_API_KEY", + "https://api.example.com/v1", + ); + expect(args).toContain("--config"); + expect(args).toContain("OPENAI_BASE_URL=https://api.example.com/v1"); + }); + + it("buildProviderArgs appends ANTHROPIC_BASE_URL config for anthropic providers with a base URL", () => { + const args = buildProviderArgs( + "create", + "inference", + "anthropic", + "ANTHROPIC_API_KEY", + "https://api.anthropic.example.com", + ); + expect(args).toContain("--config"); + expect(args).toContain("ANTHROPIC_BASE_URL=https://api.anthropic.example.com"); + }); + + it("buildProviderArgs ignores base URL for generic providers", () => { + const args = buildProviderArgs( + "create", + "slack-bridge", + "generic", + "SLACK_BOT_TOKEN", + "https://ignored.example.com", + ); + expect(args).not.toContain("--config"); + }); + it("rejects sandbox names starting with a digit", () => { // The validation regex must require names to start with a letter, // not a digit — Kubernetes rejects digit-prefixed names downstream. @@ -1156,7 +1281,7 @@ const { setupInference } = require(${onboardPath}); assert.match(source, /const ONBOARD_STEP_INDEX = \{/); assert.match(source, /function skippedStepMessage\(stepName, detail, reason = "resume"\)/); - assert.match(source, /step\(stepInfo\.number, 7, stepInfo\.title\);/); + assert.match(source, /step\(stepInfo\.number, 8, stepInfo\.title\);/); assert.match(source, /skippedStepMessage\("openclaw", sandboxName\)/); assert.match( source, @@ -1337,6 +1462,7 @@ runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; registry.registerSandbox = () => true; @@ -1441,6 +1567,7 @@ runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; registry.registerSandbox = () => true; @@ -1495,6 +1622,601 @@ const { createSandbox } = require(${onboardPath}); ); }); + it( + "creates providers for messaging tokens and attaches them to the sandbox", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-messaging-providers-"), + ); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "messaging-provider-check.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get' 'my-assistant'")) return ""; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("'provider' 'get'")) return "Provider: discord-bridge"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +childProcess.spawn = (...args) => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + commands.push({ command: args[1][1], env: args[2]?.env || null }); + process.nextTick(() => { + child.stdout.emit("data", Buffer.from("Created sandbox: my-assistant\n")); + child.emit("close", 0); + }); + return child; +}; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token-value"; + process.env.TELEGRAM_BOT_TOKEN = "123456:ABC-test-telegram-token"; + const sandboxName = await createSandbox(null, "gpt-5.4"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + // Verify providers were created with the right credential keys + const providerCommands = payload.commands.filter((e) => + e.command.includes("'provider' 'create'"), + ); + const discordProvider = providerCommands.find((e) => + e.command.includes("my-assistant-discord-bridge"), + ); + assert.ok(discordProvider, "expected my-assistant-discord-bridge provider create command"); + assert.match(discordProvider.command, /'--credential' 'DISCORD_BOT_TOKEN'/); + + const slackProvider = providerCommands.find((e) => + e.command.includes("my-assistant-slack-bridge"), + ); + assert.ok(slackProvider, "expected my-assistant-slack-bridge provider create command"); + assert.match(slackProvider.command, /'--credential' 'SLACK_BOT_TOKEN'/); + + const telegramProvider = providerCommands.find((e) => + e.command.includes("my-assistant-telegram-bridge"), + ); + assert.ok(telegramProvider, "expected my-assistant-telegram-bridge provider create command"); + assert.match(telegramProvider.command, /'--credential' 'TELEGRAM_BOT_TOKEN'/); + + // Verify sandbox create includes --provider flags for all three + const createCommand = payload.commands.find((e) => e.command.includes("'sandbox' 'create'")); + assert.ok(createCommand, "expected sandbox create command"); + assert.match(createCommand.command, /'--provider' 'my-assistant-discord-bridge'/); + assert.match(createCommand.command, /'--provider' 'my-assistant-slack-bridge'/); + assert.match(createCommand.command, /'--provider' 'my-assistant-telegram-bridge'/); + + // Verify real token values are NOT in the sandbox create command + assert.doesNotMatch(createCommand.command, /test-discord-token-value/); + assert.doesNotMatch(createCommand.command, /xoxb-test-slack-token-value/); + assert.doesNotMatch(createCommand.command, /123456:ABC-test-telegram-token/); + + // Verify blocked credentials are NOT in the sandbox spawn environment + assert.ok(createCommand.env, "expected env to be captured from spawn call"); + assert.equal( + createCommand.env.DISCORD_BOT_TOKEN, + undefined, + "DISCORD_BOT_TOKEN must not be in sandbox env", + ); + assert.equal( + createCommand.env.SLACK_BOT_TOKEN, + undefined, + "SLACK_BOT_TOKEN must not be in sandbox env", + ); + assert.equal( + createCommand.env.TELEGRAM_BOT_TOKEN, + undefined, + "TELEGRAM_BOT_TOKEN must not be in sandbox env", + ); + assert.equal( + createCommand.env.NVIDIA_API_KEY, + undefined, + "NVIDIA_API_KEY must not be in sandbox env", + ); + + // Belt-and-suspenders: raw token values must not appear anywhere in env + const envString = JSON.stringify(createCommand.env); + assert.ok( + !envString.includes("test-discord-token-value"), + "Discord token value must not leak into sandbox env", + ); + assert.ok( + !envString.includes("xoxb-test-slack-token-value"), + "Slack token value must not leak into sandbox env", + ); + assert.ok( + !envString.includes("123456:ABC-test-telegram-token"), + "Telegram token value must not leak into sandbox env", + ); + }, + ); + + it("aborts onboard when a messaging provider upsert fails", { timeout: 60_000 }, async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-provider-fail-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "provider-upsert-fail.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + const preflightPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "preflight.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); +const preflight = require(${preflightPath}); +const credentials = require(${credentialsPath}); +const childProcess = require("node:child_process"); +const { EventEmitter } = require("node:events"); + +runner.run = (command, opts = {}) => { + // Fail all provider create and update calls + if (command.includes("'provider'")) { + return { status: 1, stdout: "", stderr: "gateway unreachable" }; + } + return { status: 0 }; +}; +runner.runCapture = (command) => { + if (command.includes("'sandbox' 'get'")) return ""; + if (command.includes("'sandbox' 'list'")) return ""; + return ""; +}; +registry.registerSandbox = () => true; +registry.removeSandbox = () => true; +preflight.checkPortAvailable = async () => ({ ok: true }); +credentials.prompt = async () => ""; + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.DISCORD_BOT_TOKEN = "test-discord-token-value"; + await createSandbox(null, "gpt-5.4"); + // Should not reach here + console.log("ERROR_DID_NOT_EXIT"); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.notEqual(result.status, 0, "expected non-zero exit when provider upsert fails"); + assert.ok( + !result.stdout.includes("ERROR_DID_NOT_EXIT"), + "onboard should have aborted before reaching sandbox create", + ); + }); + + it( + "reuses sandbox when messaging providers already exist in gateway", + { timeout: 60_000 }, + async () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-reuse-providers-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "reuse-with-providers.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + const registryPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "registry.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = String.raw` +const runner = require(${runnerPath}); +const registry = require(${registryPath}); + +const commands = []; +runner.run = (command, opts = {}) => { + commands.push({ command, env: opts.env || null }); + return { status: 0 }; +}; +runner.runCapture = (command) => { + // Existing sandbox that is ready + if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; + if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + // All messaging providers already exist in gateway + if (command.includes("'provider' 'get'")) return "Provider: exists"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; + return ""; +}; +registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); + +const { createSandbox } = require(${onboardPath}); + +(async () => { + process.env.OPENSHELL_GATEWAY = "nemoclaw"; + process.env.DISCORD_BOT_TOKEN = "test-discord-token"; + process.env.SLACK_BOT_TOKEN = "xoxb-test-slack-token"; + const sandboxName = await createSandbox(null, "gpt-5.4", "nvidia-prod", null, "my-assistant"); + console.log(JSON.stringify({ sandboxName, commands })); +})().catch((error) => { + console.error(error); + process.exit(1); +}); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { + ...process.env, + HOME: tmpDir, + PATH: `${fakeBin}:${process.env.PATH || ""}`, + NEMOCLAW_NON_INTERACTIVE: "1", + }, + }); + + assert.equal(result.status, 0, result.stderr); + const payloadLine = result.stdout + .trim() + .split("\n") + .slice() + .reverse() + .find((line) => line.startsWith("{") && line.endsWith("}")); + assert.ok(payloadLine, `expected JSON payload in stdout:\n${result.stdout}`); + const payload = JSON.parse(payloadLine); + + assert.equal(payload.sandboxName, "my-assistant", "should reuse existing sandbox"); + assert.ok( + payload.commands.every((entry) => !entry.command.includes("'sandbox' 'create'")), + "should NOT recreate sandbox when providers already exist in gateway", + ); + assert.ok( + payload.commands.every((entry) => !entry.command.includes("'sandbox' 'delete'")), + "should NOT delete sandbox when providers already exist in gateway", + ); + + // Providers should still be upserted on reuse (credential refresh) + const providerUpserts = payload.commands.filter((entry) => + entry.command.includes("'provider' 'create'"), + ); + assert.ok( + providerUpserts.some((e) => e.command.includes("my-assistant-discord-bridge")), + "should upsert discord provider on reuse to refresh credentials", + ); + assert.ok( + providerUpserts.some((e) => e.command.includes("my-assistant-slack-bridge")), + "should upsert slack provider on reuse to refresh credentials", + ); + }, + ); + + it("upsertProvider creates a new provider and returns ok on success", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-upsert-provider-create-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "upsert-provider-create.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const runner = require(${runnerPath}); +const commands = []; +runner.run = (command, opts = {}) => { + commands.push(command); + return { status: 0, stdout: "", stderr: "" }; +}; +const { upsertProvider } = require(${onboardPath}); +const result = upsertProvider("discord-bridge", "generic", "DISCORD_BOT_TOKEN", null, { DISCORD_BOT_TOKEN: "fake" }); +console.log(JSON.stringify({ result, commands })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.deepEqual(payload.result, { ok: true }); + assert.equal(payload.commands.length, 1); + assert.match(payload.commands[0], /'provider' 'create' '--name' 'discord-bridge'/); + assert.match(payload.commands[0], /'--credential' 'DISCORD_BOT_TOKEN'/); + }); + + it("upsertProvider falls back to update when create fails", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-upsert-provider-update-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "upsert-provider-update.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const runner = require(${runnerPath}); +const commands = []; +let callCount = 0; +runner.run = (command, opts = {}) => { + commands.push(command); + callCount++; + // First call (create) fails, second call (update) succeeds + return callCount === 1 + ? { status: 1, stdout: "", stderr: "already exists" } + : { status: 0, stdout: "", stderr: "" }; +}; +const { upsertProvider } = require(${onboardPath}); +const result = upsertProvider("inference", "openai", "NVIDIA_API_KEY", "https://integrate.api.nvidia.com/v1"); +console.log(JSON.stringify({ result, commands })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.deepEqual(payload.result, { ok: true }); + assert.equal(payload.commands.length, 2); + assert.match(payload.commands[0], /'provider' 'create'/); + assert.match(payload.commands[1], /'provider' 'update'/); + assert.match( + payload.commands[1], + /'--config' 'OPENAI_BASE_URL=https:\/\/integrate.api.nvidia.com\/v1'/, + ); + }); + + it("upsertProvider returns error details when both create and update fail", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-upsert-provider-fail-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "upsert-provider-fail.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const runner = require(${runnerPath}); +runner.run = (command, opts = {}) => { + return { status: 1, stdout: "", stderr: "gateway unreachable" }; +}; +const { upsertProvider } = require(${onboardPath}); +const result = upsertProvider("bad-provider", "generic", "SOME_KEY", null); +console.log(JSON.stringify(result)); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.ok, false); + assert.equal(payload.status, 1); + assert.match(payload.message, /gateway unreachable/); + }); + + it("providerExistsInGateway returns true when provider exists", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-exists-true-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "provider-exists-true.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const runner = require(${runnerPath}); +runner.run = (command) => { + return { status: 0, stdout: "Provider: discord-bridge", stderr: "" }; +}; +const { providerExistsInGateway } = require(${onboardPath}); +console.log(JSON.stringify({ exists: providerExistsInGateway("discord-bridge") })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.exists, true); + }); + + it("hydrateCredentialEnv writes stored credentials into process.env for host-side bridges", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-hydrate-cred-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "hydrate-cred.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const credentialsPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "credentials.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const credentials = require(${credentialsPath}); +// Mock getCredential to return a stored value +credentials.getCredential = (name) => name === "TELEGRAM_BOT_TOKEN" ? "stored-telegram-token" : null; +const { hydrateCredentialEnv } = require(${onboardPath}); + +// Should return null for falsy input +const nullResult = hydrateCredentialEnv(null); + +// Should hydrate from stored credential and set process.env +delete process.env.TELEGRAM_BOT_TOKEN; +const hydrated = hydrateCredentialEnv("TELEGRAM_BOT_TOKEN"); + +// Should return null when credential is not stored +const missing = hydrateCredentialEnv("NONEXISTENT_KEY"); + +console.log(JSON.stringify({ + nullResult, + hydrated, + envSet: process.env.TELEGRAM_BOT_TOKEN, + missing, +})); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.nullResult, null, "should return null for null input"); + assert.equal( + payload.hydrated, + "stored-telegram-token", + "should return stored credential value", + ); + assert.equal( + payload.envSet, + "stored-telegram-token", + "should set process.env with stored value", + ); + assert.equal(payload.missing, null, "should return null when credential is not stored"); + }); + + it("providerExistsInGateway returns false when provider is missing", () => { + const repoRoot = path.join(import.meta.dirname, ".."); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-provider-exists-false-")); + const fakeBin = path.join(tmpDir, "bin"); + const scriptPath = path.join(tmpDir, "provider-exists-false.js"); + const onboardPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "onboard.js")); + const runnerPath = JSON.stringify(path.join(repoRoot, "bin", "lib", "runner.js")); + + fs.mkdirSync(fakeBin, { recursive: true }); + fs.writeFileSync(path.join(fakeBin, "openshell"), "#!/usr/bin/env bash\nexit 0\n", { + mode: 0o755, + }); + + const script = ` +const runner = require(${runnerPath}); +runner.run = (command) => { + return { status: 1, stdout: "", stderr: "provider not found" }; +}; +const { providerExistsInGateway } = require(${onboardPath}); +console.log(JSON.stringify({ exists: providerExistsInGateway("nonexistent") })); +`; + fs.writeFileSync(scriptPath, script); + + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: { ...process.env, HOME: tmpDir, PATH: `${fakeBin}:${process.env.PATH || ""}` }, + }); + + assert.equal(result.status, 0, result.stderr); + const payload = JSON.parse(result.stdout.trim().split("\n").pop()); + assert.equal(payload.exists, false); + }); + it("continues once the sandbox is Ready even if the create stream never closes", async () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-create-ready-")); @@ -1535,6 +2257,7 @@ runner.runCapture = (command) => { return sandboxListCalls >= 2 ? "my-assistant Ready" : "my-assistant Pending"; } if (command.includes("sandbox exec my-assistant curl -sf http://localhost:18789/")) return "ok"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; registry.registerSandbox = () => true; @@ -1642,6 +2365,7 @@ runner.run = (command, opts = {}) => { runner.runCapture = (command) => { if (command.includes("'sandbox' 'get' 'my-assistant'")) return "my-assistant"; if (command.includes("'sandbox' 'list'")) return "my-assistant Ready"; + if (command.includes("'forward' 'list'")) return "18789 -> my-assistant:18789"; return ""; }; registry.getSandbox = () => ({ name: "my-assistant", gpuEnabled: false }); diff --git a/test/runner.test.js b/test/runner.test.js index fc07849ff..b88f9143a 100644 --- a/test/runner.test.js +++ b/test/runner.test.js @@ -456,15 +456,6 @@ describe("regression guards", () => { } }); - it("telegram bridge validates SANDBOX_NAME on startup", () => { - const src = fs.readFileSync( - path.join(import.meta.dirname, "..", "scripts", "telegram-bridge.js"), - "utf-8", - ); - expect(src.includes("validateName(SANDBOX")).toBeTruthy(); - expect(src.includes("execSync")).toBeFalsy(); - }); - describe("credential exposure guards (#429)", () => { it("onboard createSandbox does not pass NVIDIA_API_KEY to sandbox env", () => { const fs = require("fs"); diff --git a/test/service-env.test.js b/test/service-env.test.js index 429e17a70..6e811dc9e 100644 --- a/test/service-env.test.js +++ b/test/service-env.test.js @@ -13,40 +13,19 @@ describe("service environment", () => { describe("start-services behavior", () => { const scriptPath = join(import.meta.dirname, "../scripts/start-services.sh"); - it("starts local-only services without NVIDIA_API_KEY", () => { + it("starts without messaging-related warnings", () => { const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-no-key-")); const result = execFileSync("bash", [scriptPath], { encoding: "utf-8", env: { ...process.env, - NVIDIA_API_KEY: "", - TELEGRAM_BOT_TOKEN: "", SANDBOX_NAME: "test-box", TMPDIR: workspace, }, }); - expect(result).not.toContain("NVIDIA_API_KEY required"); - expect(result).toContain("TELEGRAM_BOT_TOKEN not set"); - expect(result).toContain("Telegram: not started (no token)"); - }); - - it("warns and skips Telegram bridge when token is set without NVIDIA_API_KEY", () => { - const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-services-missing-key-")); - const result = execFileSync("bash", [scriptPath], { - encoding: "utf-8", - env: { - ...process.env, - NVIDIA_API_KEY: "", - TELEGRAM_BOT_TOKEN: "test-token", - SANDBOX_NAME: "test-box", - TMPDIR: workspace, - }, - }); - - expect(result).not.toContain("NVIDIA_API_KEY required"); - expect(result).toContain("NVIDIA_API_KEY not set"); - expect(result).toContain("Telegram: not started (no token)"); + // Messaging channels are now native to OpenClaw inside the sandbox + expect(result).toContain("Messaging: via OpenClaw native channels"); }); }); @@ -154,144 +133,7 @@ describe("service environment", () => { }); }); - describe("ALLOWED_CHAT_IDS propagation (issue #896)", () => { - const scriptPath = join(import.meta.dirname, "../scripts/start-services.sh"); - - it("start-services.sh propagates ALLOWED_CHAT_IDS to nohup child", () => { - // Patch start-services.sh to launch an env-dump script instead of the - // real telegram-bridge.js. The real bridge needs Telegram API + openshell, - // so we swap the node command with a script that writes its env to a file. - const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-chatids-")); - const envDump = join(workspace, "child-env.txt"); - - // Fake node script that dumps env and exits - const fakeScript = join(workspace, "fake-bridge.js"); - writeFileSync( - fakeScript, - `require("fs").writeFileSync(${JSON.stringify(envDump)}, Object.entries(process.env).map(([k,v])=>k+"="+v).join("\\n"));`, - ); - - // Wrapper that overrides REPO_DIR so start-services.sh launches our fake - // bridge instead of the real one, and stubs out openshell + cloudflared - const wrapper = join(workspace, "run.sh"); - writeFileSync( - wrapper, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - // Create a fake repo dir with the fake bridge at the expected path - `FAKE_REPO="${workspace}/fakerepo"`, - `mkdir -p "$FAKE_REPO/scripts"`, - `cp "${fakeScript}" "$FAKE_REPO/scripts/telegram-bridge.js"`, - // Source the start function from the real script, then call it with our fake repo - `export SANDBOX_NAME="test-box"`, - `export TELEGRAM_BOT_TOKEN="test-token"`, - `export NVIDIA_API_KEY="test-key"`, - `export ALLOWED_CHAT_IDS="111,222,333"`, - // Stub openshell (prints "Ready" to pass sandbox check) and hide cloudflared - `BIN_DIR="${workspace}/bin"`, - `mkdir -p "$BIN_DIR"`, - `printf '#!/usr/bin/env bash\\necho "Ready"\\n' > "$BIN_DIR/openshell"`, - `chmod +x "$BIN_DIR/openshell"`, - `NODE_DIR="$(dirname "$(command -v node)")"`, - `export PATH="$BIN_DIR:$NODE_DIR:/usr/bin:/bin:/usr/local/bin"`, - // Run the real script but with REPO_DIR overridden via sed — also disable cloudflared - `PATCHED="${workspace}/patched-start.sh"`, - `sed -e 's|REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"|REPO_DIR="'"$FAKE_REPO"'"|' -e 's|command -v cloudflared|false|g' "${scriptPath}" > "$PATCHED"`, - `chmod +x "$PATCHED"`, - `bash "$PATCHED"`, - // Poll for the env dump file (nohup child writes it asynchronously) - `for i in $(seq 1 20); do [ -s "${envDump}" ] && break; sleep 0.1; done`, - ].join("\n"), - { mode: 0o755 }, - ); - - execFileSync("bash", [wrapper], { encoding: "utf-8", timeout: 10000 }); - - const childEnv = readFileSync(envDump, "utf-8"); - expect(childEnv).toContain("ALLOWED_CHAT_IDS=111,222,333"); - expect(childEnv).toContain("SANDBOX_NAME=test-box"); - expect(childEnv).toContain("TELEGRAM_BOT_TOKEN=test-token"); - expect(childEnv).toContain("NVIDIA_API_KEY=test-key"); - }); - - it("telegram-bridge.js imports and uses chat-filter module with correct env var", () => { - const bridgeSrc = readFileSync( - join(import.meta.dirname, "../scripts/telegram-bridge.js"), - "utf-8", - ); - // Verify it imports the module (not inline parsing) - expect(bridgeSrc).toContain('require("../bin/lib/chat-filter")'); - // Verify it parses the correct env var name (not a typo like ALLOWED_CHATS) - expect(bridgeSrc).toContain("parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS)"); - // Verify it uses isChatAllowed for access control - expect(bridgeSrc).toContain("isChatAllowed(ALLOWED_CHATS, chatId)"); - // Verify the old inline pattern is gone - expect(bridgeSrc).not.toContain('.split(",").map((s) => s.trim())'); - }); - - it("nohup child can parse the propagated ALLOWED_CHAT_IDS value", () => { - // End-to-end: start-services.sh passes env to child, child parses it - // using the same chat-filter module telegram-bridge.js uses. - const workspace = mkdtempSync(join(tmpdir(), "nemoclaw-parse-e2e-")); - const resultFile = join(workspace, "parse-result.json"); - - // Fake bridge that parses ALLOWED_CHAT_IDS using chat-filter and dumps result - const chatFilterPath = join(import.meta.dirname, "../bin/lib/chat-filter.js"); - const fakeScript = join(workspace, "fake-bridge.js"); - writeFileSync( - fakeScript, - [ - `const { parseAllowedChatIds, isChatAllowed } = require(${JSON.stringify(chatFilterPath)});`, - `const parsed = parseAllowedChatIds(process.env.ALLOWED_CHAT_IDS);`, - `const result = {`, - ` raw: process.env.ALLOWED_CHAT_IDS,`, - ` parsed,`, - ` allows111: isChatAllowed(parsed, "111"),`, - ` allows999: isChatAllowed(parsed, "999"),`, - `};`, - `require("fs").writeFileSync(${JSON.stringify(resultFile)}, JSON.stringify(result));`, - ].join("\n"), - ); - - const wrapper = join(workspace, "run.sh"); - writeFileSync( - wrapper, - [ - "#!/usr/bin/env bash", - "set -euo pipefail", - `FAKE_REPO="${workspace}/fakerepo"`, - `mkdir -p "$FAKE_REPO/scripts"`, - `cp "${fakeScript}" "$FAKE_REPO/scripts/telegram-bridge.js"`, - `export SANDBOX_NAME="test-box"`, - `export TELEGRAM_BOT_TOKEN="test-token"`, - `export NVIDIA_API_KEY="test-key"`, - `export ALLOWED_CHAT_IDS="111, 222 , 333"`, - // Stub openshell (prints "Ready" to pass sandbox check) and hide cloudflared - `BIN_DIR="${workspace}/bin"`, - `mkdir -p "$BIN_DIR"`, - `printf '#!/usr/bin/env bash\\necho "Ready"\\n' > "$BIN_DIR/openshell"`, - `chmod +x "$BIN_DIR/openshell"`, - `NODE_DIR="$(dirname "$(command -v node)")"`, - `export PATH="$BIN_DIR:$NODE_DIR:/usr/bin:/bin:/usr/local/bin"`, - `PATCHED="${workspace}/patched-start.sh"`, - `sed -e 's|REPO_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"|REPO_DIR="'"$FAKE_REPO"'"|' -e 's|command -v cloudflared|false|g' "${scriptPath}" > "$PATCHED"`, - `chmod +x "$PATCHED"`, - `bash "$PATCHED"`, - `for i in $(seq 1 20); do [ -s "${resultFile}" ] && break; sleep 0.1; done`, - ].join("\n"), - { mode: 0o755 }, - ); - - execFileSync("bash", [wrapper], { encoding: "utf-8", timeout: 10000 }); - - const result = JSON.parse(readFileSync(resultFile, "utf-8")); - expect(result.raw).toBe("111, 222 , 333"); - expect(result.parsed).toEqual(["111", "222", "333"]); - expect(result.allows111).toBe(true); - expect(result.allows999).toBe(false); - }); - + describe("chat-filter module", () => { it("parseAllowedChatIds parses comma-separated IDs with whitespace", () => { expect(parseAllowedChatIds("111, 222 , 333")).toEqual(["111", "222", "333"]); });