From 6aa13c843ea06c211aad85c9d14c30530dce7b21 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 29 Mar 2026 19:25:44 -0700 Subject: [PATCH 01/45] feat(onboard): use OpenShell providers for messaging credentials Create OpenShell providers for Discord, Slack, and Telegram tokens during onboard instead of passing them as raw environment variables into the sandbox. The L7 proxy rewrites Authorization headers (Bearer/Bot) with real secrets at egress, so sandbox processes never see the actual token values. - Discord and Slack tokens flow through provider/placeholder system - Telegram provider is created for credential storage but host-side bridge still reads from host env (Telegram uses URL-path auth that the proxy can't rewrite yet) - Raw env var injection removed for Discord and Slack --- bin/lib/onboard.js | 47 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 53069e339..e38610a6d 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1778,27 +1778,46 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, ]; // --gpu is intentionally omitted. See comment in startGateway(). - console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); - const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; - patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); - // 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). - const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; - const sandboxEnv = { ...process.env }; - delete sandboxEnv.NVIDIA_API_KEY; + // 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) with real secrets at egress. + // Telegram provider is created for credential storage but the host-side bridge + // still reads from host env — Telegram uses URL-path auth (/bot{TOKEN}/) which + // the proxy can't rewrite yet. + const messagingProviders = []; const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; if (discordToken) { - sandboxEnv.DISCORD_BOT_TOKEN = discordToken; + upsertProvider("discord-bridge", "generic", "DISCORD_BOT_TOKEN", null, { DISCORD_BOT_TOKEN: discordToken }); + messagingProviders.push("discord-bridge"); } const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; if (slackToken) { - sandboxEnv.SLACK_BOT_TOKEN = slackToken; + upsertProvider("slack-bridge", "generic", "SLACK_BOT_TOKEN", null, { SLACK_BOT_TOKEN: slackToken }); + messagingProviders.push("slack-bridge"); + } + const telegramToken = getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN; + if (telegramToken) { + upsertProvider("telegram-bridge", "generic", "TELEGRAM_BOT_TOKEN", null, { TELEGRAM_BOT_TOKEN: telegramToken }); + messagingProviders.push("telegram-bridge"); + } + for (const p of messagingProviders) { + createArgs.push("--provider", p); } + console.log(` Creating sandbox '${sandboxName}' (this takes a few minutes on first run)...`); + const chatUiUrl = process.env.CHAT_UI_URL || "http://127.0.0.1:18789"; + patchStagedDockerfile(stagedDockerfile, model, chatUiUrl, String(Date.now()), provider, preferredInferenceApi); + // 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; + delete sandboxEnv.DISCORD_BOT_TOKEN; + delete sandboxEnv.SLACK_BOT_TOKEN; + // 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 From d8985440e386baa6fa2bbc6991b866fbb2e7364e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 29 Mar 2026 19:43:14 -0700 Subject: [PATCH 02/45] fix(security): verify messaging providers exist after sandbox creation Add verifyProviderExists() check post-sandbox-creation to confirm messaging credential providers are actually in the gateway. Warns with remediation steps if a provider is missing. --- bin/lib/onboard.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index f97aa6d50..3c9b6fdfa 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -477,6 +477,11 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { } } +function verifyProviderExists(name) { + const output = runCaptureOpenshell(["provider", "get", name], { ignoreError: true }); + return Boolean(output && !output.includes("not found")); +} + function verifyInferenceRoute(_provider, _model) { const output = runCaptureOpenshell(["inference", "get"], { ignoreError: true }); if (!output || /Gateway inference:\s*[\r\n]+\s*Not configured/i.test(output)) { @@ -1902,6 +1907,15 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, console.log(" Setting up sandbox DNS proxy..."); run(`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, { ignoreError: true }); + // Verify messaging providers are attached to the sandbox + for (const p of messagingProviders) { + if (!verifyProviderExists(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; } From 499053d059cdbac7093868286121bbd6c86fed3c Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 29 Mar 2026 19:49:56 -0700 Subject: [PATCH 03/45] fix(security): test provider creation and sandbox attachment for messaging tokens Verify that messaging credentials flow through the provider system: - Provider create commands issued with correct credential key names - Sandbox create includes --provider flags for all detected tokens - Real token values never appear in sandbox create command or env --- test/onboard.test.js | 114 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/test/onboard.test.js b/test/onboard.test.js index 16b7e5453..480e93ecb 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1029,6 +1029,120 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); }); + it("creates providers for messaging tokens and attaches them to the sandbox", 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"; + 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("discord-bridge")); + assert.ok(discordProvider, "expected discord-bridge provider create command"); + assert.match(discordProvider.command, /'--credential' 'DISCORD_BOT_TOKEN'/); + + const slackProvider = providerCommands.find((e) => e.command.includes("slack-bridge")); + assert.ok(slackProvider, "expected slack-bridge provider create command"); + assert.match(slackProvider.command, /'--credential' 'SLACK_BOT_TOKEN'/); + + const telegramProvider = providerCommands.find((e) => e.command.includes("telegram-bridge")); + assert.ok(telegramProvider, "expected 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' 'discord-bridge'/); + assert.match(createCommand.command, /'--provider' 'slack-bridge'/); + assert.match(createCommand.command, /'--provider' 'telegram-bridge'/); + + // Verify real token values are NOT in the sandbox create command or env + 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/); + }); + 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-")); From 9d4d5e1a1bcae3d44b3ab396fd1e1f23165e6035 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 29 Mar 2026 19:56:52 -0700 Subject: [PATCH 04/45] fix(security): address CodeRabbit review findings - Use exit code instead of string matching in verifyProviderExists() - Use hydrateCredentialEnv() for Telegram so host-side bridge can find the token from stored credentials - Replace individual delete statements with a blocklist that covers all credential env vars (NVIDIA, OpenAI, Anthropic, Gemini, compatible endpoints, Discord, Slack, Telegram) --- bin/lib/onboard.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 3c9b6fdfa..16ad2e440 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -478,8 +478,8 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { } function verifyProviderExists(name) { - const output = runCaptureOpenshell(["provider", "get", name], { ignoreError: true }); - return Boolean(output && !output.includes("not found")); + const result = runOpenshell(["provider", "get", name], { ignoreError: true }); + return result.status === 0; } function verifyInferenceRoute(_provider, _model) { @@ -1800,7 +1800,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, upsertProvider("slack-bridge", "generic", "SLACK_BOT_TOKEN", null, { SLACK_BOT_TOKEN: slackToken }); messagingProviders.push("slack-bridge"); } - const telegramToken = getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN; + const telegramToken = hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN; if (telegramToken) { upsertProvider("telegram-bridge", "generic", "TELEGRAM_BOT_TOKEN", null, { TELEGRAM_BOT_TOKEN: telegramToken }); messagingProviders.push("telegram-bridge"); @@ -1818,10 +1818,20 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, // 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; - delete sandboxEnv.DISCORD_BOT_TOKEN; - delete sandboxEnv.SLACK_BOT_TOKEN; + const blockedSandboxEnvNames = new Set([ + "NVIDIA_API_KEY", + "OPENAI_API_KEY", + "ANTHROPIC_API_KEY", + "GEMINI_API_KEY", + "COMPATIBLE_API_KEY", + "COMPATIBLE_ANTHROPIC_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 From 752fc94878c5147152b14f63658064668655a9c4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 29 Mar 2026 20:41:08 -0700 Subject: [PATCH 05/45] fix(security): update credential-exposure test for blocklist pattern The test pattern-matched on the old `{ ...process.env }` spread. Update to verify the blocklist approach that strips all credential env vars from sandboxEnv. --- test/credential-exposure.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 08f880a9d..43e947158 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -76,7 +76,12 @@ 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 + expect(src).toMatch(/blockedSandboxEnvNames/); + expect(src).toMatch(/NVIDIA_API_KEY/); + expect(src).toMatch(/DISCORD_BOT_TOKEN/); + expect(src).toMatch(/SLACK_BOT_TOKEN/); + expect(src).toMatch(/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"/); From 6d23a1630569197f64c1315dddaa6baa372933b4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 31 Mar 2026 11:09:58 -0700 Subject: [PATCH 06/45] fix(onboard): address review feedback for messaging credential providers Rename verifyProviderExists to providerExistsInGateway to clarify it only checks gateway-level existence, not sandbox attachment. Add BEDROCK_API_KEY to the credential blocklist (merged via #963). Add env var leakage assertions to the messaging provider test. Add JSDoc to buildProviderArgs, upsertProvider, and providerExistsInGateway. Increase messaging provider test timeout for post-merge code paths. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 38 +++++++++++++++++++++++++++++--- test/credential-exposure.test.js | 1 + test/onboard.test.js | 24 ++++++++++++++++++-- 3 files changed, 58 insertions(+), 5 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 269333a8d..df95199a9 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -844,6 +844,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" @@ -857,6 +866,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"] }; @@ -882,7 +903,16 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { return { ok: true }; } -function verifyProviderExists(name) { +/** + * 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 }); return result.status === 0; } @@ -2223,6 +2253,7 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY", + "BEDROCK_API_KEY", "COMPATIBLE_API_KEY", "COMPATIBLE_ANTHROPIC_API_KEY", "DISCORD_BOT_TOKEN", @@ -2333,9 +2364,10 @@ async function createSandbox(gpu, model, provider, preferredInferenceApi = null, console.log(" Setting up sandbox DNS proxy..."); run(`bash "${path.join(SCRIPTS, "setup-dns-proxy.sh")}" ${GATEWAY_NAME} "${sandboxName}" 2>&1 || true`, { ignoreError: true }); - // Verify messaging providers are attached to the sandbox + // 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 (!verifyProviderExists(p)) { + 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 `); diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 5a69d5121..3270188b4 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -79,6 +79,7 @@ describe("credential exposure in process arguments", () => { // sandboxEnv must be built with a blocklist that strips all credential env vars expect(src).toMatch(/blockedSandboxEnvNames/); expect(src).toMatch(/NVIDIA_API_KEY/); + expect(src).toMatch(/BEDROCK_API_KEY/); expect(src).toMatch(/DISCORD_BOT_TOKEN/); expect(src).toMatch(/SLACK_BOT_TOKEN/); expect(src).toMatch(/TELEGRAM_BOT_TOKEN/); diff --git a/test/onboard.test.js b/test/onboard.test.js index c85aad057..bc537c121 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1267,7 +1267,7 @@ const { createSandbox } = require(${onboardPath}); assert.doesNotMatch(createCommand.command, /SLACK_BOT_TOKEN=/); }); - it("creates providers for messaging tokens and attaches them to the sandbox", async () => { + 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"); @@ -1375,10 +1375,30 @@ const { createSandbox } = require(${onboardPath}); assert.match(createCommand.command, /'--provider' 'slack-bridge'/); assert.match(createCommand.command, /'--provider' 'telegram-bridge'/); - // Verify real token values are NOT in the sandbox create command or env + // 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("continues once the sandbox is Ready even if the create stream never closes", async () => { From 1824df41ca7cb8bd2397fe5c96a754ea468b03c6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 31 Mar 2026 12:48:39 -0700 Subject: [PATCH 07/45] test(onboard): add unit tests for provider and utility functions Add direct in-process tests for buildProviderArgs (all branch paths), formatEnvAssignment, compactText, getNavigationChoice, parsePolicyPresetEnv, summarizeCurlFailure, and summarizeProbeFailure. Add subprocess tests for upsertProvider (create, update fallback, and both-fail error paths), providerExistsInGateway (true/false), and hydrateCredentialEnv (null input, stored credential, missing key). Export newly-tested functions from onboard.js. CLI function coverage: 33.24% (threshold 32%). Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 9 ++ test/onboard.test.js | 333 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 342 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index df95199a9..4c4218175 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3723,13 +3723,17 @@ async function onboard(opts = {}) { } module.exports = { + buildProviderArgs, buildSandboxConfigSyncScript, + compactText, copyBuildContextDir, classifySandboxCreateFailure, createSandbox, + formatEnvAssignment, getFutureShellPathHint, getGatewayStartEnv, getGatewayReuseState, + getNavigationChoice, getSandboxInferenceConfig, getInstalledOpenshellVersion, getRequestedModelHint, @@ -3748,6 +3752,8 @@ module.exports = { onboard, onboardSession, printSandboxCreateRecoveryHints, + providerExistsInGateway, + parsePolicyPresetEnv, pruneStaleSandboxEntry, repairRecordedSandbox, recoverGatewayRuntime, @@ -3759,6 +3765,9 @@ module.exports = { isOpenclawReady, arePolicyPresetsApplied, setupPoliciesWithSelection, + summarizeCurlFailure, + summarizeProbeFailure, + upsertProvider, hydrateCredentialEnv, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, diff --git a/test/onboard.test.js b/test/onboard.test.js index bc537c121..5546194e2 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, getFutureShellPathHint, getSandboxInferenceConfig, @@ -25,8 +29,11 @@ import { isGatewayHealthy, classifyValidationFailure, normalizeProviderBaseUrl, + parsePolicyPresetEnv, patchStagedDockerfile, printSandboxCreateRecoveryHints, + summarizeCurlFailure, + summarizeProbeFailure, shouldIncludeBuildContextPath, writeSandboxConfigSyncFile, } from "../bin/lib/onboard"; @@ -466,6 +473,101 @@ 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("passes credential names to openshell without embedding secret values in argv", () => { const repoRoot = path.join(import.meta.dirname, ".."); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-inference-")); @@ -1401,6 +1503,237 @@ const { createSandbox } = require(${onboardPath}); "Telegram token value must not leak into sandbox env"); }); + 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-")); From 230f5083bd166ebece9a6e88087ac664ffb3433a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Tue, 31 Mar 2026 17:06:53 -0700 Subject: [PATCH 08/45] fix(security): address remaining CodeRabbit review feedback on PR #1081 Suppress provider-get CLI output during post-create verification loop, force sandbox recreation when messaging tokens are configured to prevent legacy raw-env-var leaks, and scope credential-exposure test assertions to the actual blockedSandboxEnvNames set. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 41 +++++++++++++++++++++++++------- test/credential-exposure.test.js | 17 +++++++------ 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 279391a23..3657285f7 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -934,7 +934,10 @@ function upsertProvider(name, type, credentialEnv, baseUrl, env = {}) { * @returns {boolean} True if the provider exists in the gateway. */ function providerExistsInGateway(name) { - const result = runOpenshell(["provider", "get", name], { ignoreError: true }); + const result = runOpenshell(["provider", "get", name], { + ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], + }); return result.status === 0; } @@ -2336,24 +2339,44 @@ async function createSandbox( 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 hasMessagingTokens = + !!(getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) || + !!(getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) || + !!(hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN); + // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); if (liveExists) { const existingSandboxState = getSandboxReuseState(sandboxName); 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 messaging tokens are configured, the existing sandbox may not have + // provider attachments (created before this change). Force recreation so + // credentials flow through the provider pipeline instead of raw env vars. + if (hasMessagingTokens) { + console.log( + ` Sandbox '${sandboxName}' exists but messaging providers may not be 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."); + 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" && hasMessagingTokens) { + 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.`); diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index dbf1b5105..32ef98069 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -63,13 +63,16 @@ 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"); - // sandboxEnv must be built with a blocklist that strips all credential env vars - expect(src).toMatch(/blockedSandboxEnvNames/); - expect(src).toMatch(/NVIDIA_API_KEY/); - expect(src).toMatch(/BEDROCK_API_KEY/); - expect(src).toMatch(/DISCORD_BOT_TOKEN/); - expect(src).toMatch(/SLACK_BOT_TOKEN/); - expect(src).toMatch(/TELEGRAM_BOT_TOKEN/); + // sandboxEnv must be built with a blocklist that strips all credential env vars. + // Extract the actual blocklist array to avoid false positives from other mentions. + const blocklistMatch = src.match(/const blockedSandboxEnvNames = new Set\(\[([\s\S]*?)\]\);/); + expect(blocklistMatch).not.toBeNull(); + const blocklist = blocklistMatch[1]; + expect(blocklist).toContain('"NVIDIA_API_KEY"'); + 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"/); From d14e405c8c2fc4899cdf9ff4f91b1d0c5fdcf308 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 08:57:08 -0700 Subject: [PATCH 09/45] fix: check upsertProvider result and skip recreation when providers exist Address review feedback: - Check upsertProvider return value for messaging providers and abort onboard with a clear error if any provider upsert fails. - Before forcing sandbox recreation, verify whether messaging providers already exist in the gateway via providerExistsInGateway(). Only recreate when at least one required provider is missing, preserving sandboxes that were already created with provider attachments. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 72 ++++++++++++-------- test/onboard.test.js | 158 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+), 29 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index bbafeb79f..1e9bf8e1b 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2141,14 +2141,24 @@ async function createSandbox( 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 && + [ + { envKey: "DISCORD_BOT_TOKEN", provider: "discord-bridge" }, + { envKey: "SLACK_BOT_TOKEN", provider: "slack-bridge" }, + { envKey: "TELEGRAM_BOT_TOKEN", provider: "telegram-bridge" }, + ].some( + ({ envKey, provider }) => + (getCredential(envKey) || process.env[envKey]) && !providerExistsInGateway(provider), + ); + if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { - // If messaging tokens are configured, the existing sandbox may not have - // provider attachments (created before this change). Force recreation so - // credentials flow through the provider pipeline instead of raw env vars. - if (hasMessagingTokens) { - console.log( - ` Sandbox '${sandboxName}' exists but messaging providers may not be attached.`, - ); + 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 { ensureDashboardForward(sandboxName, chatUiUrl); @@ -2163,7 +2173,7 @@ async function createSandbox( } } - if (existingSandboxState === "ready" && hasMessagingTokens) { + 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.`); @@ -2206,27 +2216,31 @@ async function createSandbox( // still reads from host env — Telegram uses URL-path auth (/bot{TOKEN}/) which // the proxy can't rewrite yet. const messagingProviders = []; - const discordToken = getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN; - if (discordToken) { - upsertProvider("discord-bridge", "generic", "DISCORD_BOT_TOKEN", null, { - DISCORD_BOT_TOKEN: discordToken, - }); - messagingProviders.push("discord-bridge"); - } - const slackToken = getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN; - if (slackToken) { - upsertProvider("slack-bridge", "generic", "SLACK_BOT_TOKEN", null, { - SLACK_BOT_TOKEN: slackToken, - }); - messagingProviders.push("slack-bridge"); - } - const telegramToken = - hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN; - if (telegramToken) { - upsertProvider("telegram-bridge", "generic", "TELEGRAM_BOT_TOKEN", null, { - TELEGRAM_BOT_TOKEN: telegramToken, - }); - messagingProviders.push("telegram-bridge"); + const messagingTokenDefs = [ + { + name: "discord-bridge", + envKey: "DISCORD_BOT_TOKEN", + token: getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN, + }, + { + name: "slack-bridge", + envKey: "SLACK_BOT_TOKEN", + token: getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN, + }, + { + name: "telegram-bridge", + envKey: "TELEGRAM_BOT_TOKEN", + token: hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN, + }, + ]; + for (const { name, envKey, token } of messagingTokenDefs) { + 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); + } + messagingProviders.push(name); } for (const p of messagingProviders) { createArgs.push("--provider", p); diff --git a/test/onboard.test.js b/test/onboard.test.js index 81a35b826..240e44700 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1712,6 +1712,164 @@ const { createSandbox } = require(${onboardPath}); }, ); + 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"; + 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", + ); + }, + ); + 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-")); From 6dd38a5f27da11d4648739fe0c0e0c89ad618658 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 09:27:11 -0700 Subject: [PATCH 10/45] fix: upgrade Telegram provider to full L7 URL-path credential injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenShell ≥ 0.0.20 supports URL-path segment rewriting, so Telegram bot tokens now flow entirely through the provider pipeline like Discord and Slack. Remove the hydrateCredentialEnv workaround and bump the install-openshell minimum version from 0.0.7 to 0.0.20. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 10 ++++------ scripts/install-openshell.sh | 4 ++-- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 1e9bf8e1b..fd9f298da 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2134,7 +2134,7 @@ async function createSandbox( const hasMessagingTokens = !!(getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) || !!(getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) || - !!(hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN); + !!(getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN); // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); @@ -2211,10 +2211,8 @@ async function createSandbox( // 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) with real secrets at egress. - // Telegram provider is created for credential storage but the host-side bridge - // still reads from host env — Telegram uses URL-path auth (/bot{TOKEN}/) which - // the proxy can't rewrite yet. + // rewrites Authorization headers (Bearer/Bot) and URL-path segments + // (/bot{TOKEN}/) with real secrets at egress (OpenShell ≥ 0.0.20). const messagingProviders = []; const messagingTokenDefs = [ { @@ -2230,7 +2228,7 @@ async function createSandbox( { name: "telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", - token: hydrateCredentialEnv("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN, + token: getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN, }, ]; for (const { name, envKey, token } of messagingTokenDefs) { diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index dde9f0911..e46180090 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -33,8 +33,8 @@ esac info "Detected $OS_LABEL ($ARCH_LABEL)" -# Minimum version required for cgroup v2 fix (NVIDIA/OpenShell#329) -MIN_VERSION="0.0.7" +# Minimum version required for L7 URL-path credential injection (NVIDIA/OpenShell#708) +MIN_VERSION="0.0.20" version_gte() { # Returns 0 (true) if $1 >= $2 — portable, no sort -V (BSD compat) From b57fb1840a717032865910561242982744e2b22f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 10:06:11 -0700 Subject: [PATCH 11/45] feat: replace host-side telegram bridge with native OpenClaw channels Messaging channels (Telegram, Discord, Slack) now run natively inside the sandbox via OpenClaw's built-in channel system. Credential placeholders injected by OpenShell providers flow through to API calls where the L7 proxy swaps them for real secrets at egress. - Add configure_messaging_channels() to nemoclaw-start.sh that patches openclaw.json at startup when provider tokens are present - Delete scripts/telegram-bridge.js (host-side SSH relay) - Remove telegram bridge from scripts/start-services.sh (cloudflared only) - Update tests to reflect the new architecture Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 86 ++++++++++++ scripts/start-services.sh | 72 +++------- scripts/telegram-bridge.js | 275 ------------------------------------- test/runner.test.js | 9 -- test/service-env.test.js | 166 +--------------------- 5 files changed, 106 insertions(+), 502 deletions(-) delete mode 100755 scripts/telegram-bridge.js diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index f80c4a272..40e85d5e5 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -137,6 +137,86 @@ os.chmod(path, 0o600) PYAUTH } +configure_messaging_channels() { + # If any messaging tokens are present (injected as placeholders by the + # OpenShell provider system), patch openclaw.json to enable the + # corresponding OpenClaw native channels. The placeholder values flow + # through to API calls where the L7 proxy swaps them for real secrets. + # Real tokens are never visible inside the sandbox. + # + # Requires root: openclaw.json is owned by root with chmod 444. + # Non-root mode cannot patch the config — channels are unavailable. + [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 + + if [ "$(id -u)" -ne 0 ]; then + echo "[channels] Messaging tokens detected but running as non-root — channels unavailable" >&2 + return 0 + fi + + local config_path="/sandbox/.openclaw/openclaw.json" + local hash_path="/sandbox/.openclaw/.config-hash" + + # Temporarily make config writable + chmod 644 "$config_path" + chmod 644 "$hash_path" + + python3 - <<'PYCHANNELS' +import json, os + +config_path = '/sandbox/.openclaw/openclaw.json' +config = json.load(open(config_path)) + +channels = config.get('channels', {'defaults': {'configWrites': False}}) + +telegram_token = os.environ.get('TELEGRAM_BOT_TOKEN', '') +if telegram_token: + channels['telegram'] = { + 'accounts': { + 'main': { + 'botToken': telegram_token, + 'enabled': True, + } + } + } + +discord_token = os.environ.get('DISCORD_BOT_TOKEN', '') +if discord_token: + channels['discord'] = { + 'accounts': { + 'main': { + 'token': discord_token, + 'enabled': True, + } + } + } + +slack_token = os.environ.get('SLACK_BOT_TOKEN', '') +if slack_token: + channels['slack'] = { + 'accounts': { + 'main': { + 'botToken': slack_token, + 'enabled': True, + } + } + } + +config['channels'] = channels +json.dump(config, open(config_path, 'w'), indent=2) +os.chmod(config_path, 0o444) +PYCHANNELS + + # Recompute config hash after patching + (cd /sandbox/.openclaw && sha256sum openclaw.json >.config-hash) + chmod 444 "$hash_path" + chown root:root "$hash_path" + + echo "[channels] Messaging channels configured:" >&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 +} + print_dashboard_urls() { local token chat_ui_base local_url remote_url @@ -345,6 +425,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 +456,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 0c64d1341..7d69d3a74 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 │" 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/test/runner.test.js b/test/runner.test.js index 120c532c2..bd3e96698 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"]); }); From 8b6a06e9df95828697fe66198eacc76bf2f238b4 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 10:21:56 -0700 Subject: [PATCH 12/45] fix: normalize messaging tokens and derive credential blocklist Address CodeRabbit feedback: - Add getMessagingToken() helper that normalizes process.env fallback via normalizeCredentialValue to reject whitespace-only tokens - Use getMessagingToken consistently in hasMessagingTokens, needsProviderMigration, and messagingTokenDefs - Derive blockedSandboxEnvNames from REMOTE_PROVIDER_CONFIG to prevent drift when new provider credential env vars are added Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 28 ++++++++++++++-------------- test/credential-exposure.test.js | 7 +++++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index fd9f298da..34d5856df 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2131,10 +2131,12 @@ async function createSandbox( // 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 hasMessagingTokens = - !!(getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) || - !!(getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) || - !!(getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN); + !!getMessagingToken("DISCORD_BOT_TOKEN") || + !!getMessagingToken("SLACK_BOT_TOKEN") || + !!getMessagingToken("TELEGRAM_BOT_TOKEN"); // Reconcile local registry state with the live OpenShell gateway state. const liveExists = pruneStaleSandboxEntry(sandboxName); @@ -2152,8 +2154,7 @@ async function createSandbox( { envKey: "SLACK_BOT_TOKEN", provider: "slack-bridge" }, { envKey: "TELEGRAM_BOT_TOKEN", provider: "telegram-bridge" }, ].some( - ({ envKey, provider }) => - (getCredential(envKey) || process.env[envKey]) && !providerExistsInGateway(provider), + ({ envKey, provider }) => getMessagingToken(envKey) && !providerExistsInGateway(provider), ); if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { @@ -2218,17 +2219,17 @@ async function createSandbox( { name: "discord-bridge", envKey: "DISCORD_BOT_TOKEN", - token: getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN, + token: getMessagingToken("DISCORD_BOT_TOKEN"), }, { name: "slack-bridge", envKey: "SLACK_BOT_TOKEN", - token: getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN, + token: getMessagingToken("SLACK_BOT_TOKEN"), }, { name: "telegram-bridge", envKey: "TELEGRAM_BOT_TOKEN", - token: getCredential("TELEGRAM_BOT_TOKEN") || process.env.TELEGRAM_BOT_TOKEN, + token: getMessagingToken("TELEGRAM_BOT_TOKEN"), }, ]; for (const { name, envKey, token } of messagingTokenDefs) { @@ -2260,13 +2261,12 @@ async function createSandbox( // crates/openshell-router/src/backend.rs (inference auth injection). const envArgs = [formatEnvAssignment("CHAT_UI_URL", chatUiUrl)]; const blockedSandboxEnvNames = new Set([ - "NVIDIA_API_KEY", - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "GEMINI_API_KEY", + // 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", - "COMPATIBLE_API_KEY", - "COMPATIBLE_ANTHROPIC_API_KEY", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "TELEGRAM_BOT_TOKEN", diff --git a/test/credential-exposure.test.js b/test/credential-exposure.test.js index 32ef98069..ca148ba1c 100644 --- a/test/credential-exposure.test.js +++ b/test/credential-exposure.test.js @@ -64,11 +64,14 @@ describe("credential exposure in process arguments", () => { const src = fs.readFileSync(ONBOARD_JS, "utf-8"); // sandboxEnv must be built with a blocklist that strips all credential env vars. - // Extract the actual blocklist array to avoid false positives from other mentions. + // 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]; - expect(blocklist).toContain('"NVIDIA_API_KEY"'); + // 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"'); From dc2b83cc7993bcdf2328f650f2067e5d121eed9a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 11:26:18 -0700 Subject: [PATCH 13/45] test: pin OpenShell install to v0.0.20 to isolate e2e hang The cloud-e2e timed out at the forward start step with OpenShell v0.0.21. Pin to v0.0.20 (which passed on main) to determine whether the hang is caused by the v0.0.21 relay changes or by our code. Signed-off-by: Aaron Erickson --- scripts/install-openshell.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/scripts/install-openshell.sh b/scripts/install-openshell.sh index e46180090..2ab9ffece 100755 --- a/scripts/install-openshell.sh +++ b/scripts/install-openshell.sh @@ -80,15 +80,16 @@ tmpdir="$(mktemp -d)" trap 'rm -rf "$tmpdir"' EXIT CHECKSUM_FILE="openshell-checksums-sha256.txt" +OPENSHELL_PIN="v0.0.20" if command -v gh >/dev/null 2>&1; then - GH_TOKEN="${GITHUB_TOKEN:-}" gh release download --repo NVIDIA/OpenShell \ + GH_TOKEN="${GITHUB_TOKEN:-}" gh release download "$OPENSHELL_PIN" --repo NVIDIA/OpenShell \ --pattern "$ASSET" --dir "$tmpdir" - GH_TOKEN="${GITHUB_TOKEN:-}" gh release download --repo NVIDIA/OpenShell \ + GH_TOKEN="${GITHUB_TOKEN:-}" gh release download "$OPENSHELL_PIN" --repo NVIDIA/OpenShell \ --pattern "$CHECKSUM_FILE" --dir "$tmpdir" else - curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$ASSET" \ + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_PIN}/$ASSET" \ -o "$tmpdir/$ASSET" - curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/latest/download/$CHECKSUM_FILE" \ + curl -fsSL "https://github.com/NVIDIA/OpenShell/releases/download/${OPENSHELL_PIN}/$CHECKSUM_FILE" \ -o "$tmpdir/$CHECKSUM_FILE" fi From fd54399a2adc57cbc96bb0b5e0899777e0ba2f10 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 12:30:05 -0700 Subject: [PATCH 14/45] fix: prevent forward start from hanging on piped stdio inheritance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spawnSync with piped stdout/stderr waits for all file descriptors to close. openshell forward start --background forks a child that inherits the pipes and never closes them, causing the onboard to block forever. Use stdio ["ignore","ignore","ignore"] for the forward start call so the background process does not inherit any pipes. Also revert the v0.0.20 pin in install-openshell.sh — the hang was not an OpenShell version regression but a latent fd inheritance bug in our ensureDashboardForward that manifests with OpenShell >= 0.0.20. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 34d5856df..25f223e63 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3424,8 +3424,12 @@ const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); + // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. + // The --background flag forks a child that inherits stdout/stderr; if those are + // pipes, spawnSync blocks until the background process exits (never). runOpenshell(["forward", "start", "--background", forwardTarget, sandboxName], { ignoreError: true, + stdio: ["ignore", "ignore", "ignore"], }); } From 92546cc1c1c2555d56a7f953f6ae19f874f79aec Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 15:38:03 -0700 Subject: [PATCH 15/45] fix: add stderr capture, verification, and retry to dashboard forward ensureDashboardForward was double-silenced: ignoreError swallowed the exit code and stdio ["ignore"] suppressed all output. If openshell forward start failed, we had no diagnostics and no way to know. - Redirect stderr to a temp file instead of suppressing it, so we can read and log the actual error from openshell without triggering the pipe-inheritance hang from --background. - Verify the forward is active via openshell forward list after each attempt. - Retry up to 3 times with 2s backoff before giving up. - Return success/failure so callers can warn the user with a manual fix command. - Update test mocks to respond to forward list verification. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 62 +++++++++++++++++++++++++++++++++++++------- test/onboard.test.js | 6 +++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index d8689df14..eb4a3c3d0 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2167,7 +2167,13 @@ async function createSandbox( console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else { - ensureDashboardForward(sandboxName, chatUiUrl); + const fwdOk = ensureDashboardForward(sandboxName, chatUiUrl); + if (!fwdOk) { + console.warn(" Dashboard will not be reachable until the forward is established."); + console.warn( + ` Manual fix: openshell forward start --background ${CONTROL_UI_PORT} ${sandboxName}`, + ); + } if (isNonInteractive()) { note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); } else { @@ -2368,7 +2374,13 @@ async function createSandbox( // Release any stale forward on port 18789 before claiming it for the new sandbox. // A previous onboard run may have left the port forwarded to a different sandbox, // which would silently prevent the new sandbox's dashboard from being reachable. - ensureDashboardForward(sandboxName, chatUiUrl); + const forwardOk = ensureDashboardForward(sandboxName, chatUiUrl); + if (!forwardOk) { + console.warn(" Dashboard will not be reachable until the forward is established."); + console.warn( + ` Manual fix: openshell forward start --background ${CONTROL_UI_PORT} ${sandboxName}`, + ); + } // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ @@ -3429,13 +3441,45 @@ const { resolveDashboardForwardTarget, buildControlUiUrls } = dashboard; function ensureDashboardForward(sandboxName, chatUiUrl = `http://127.0.0.1:${CONTROL_UI_PORT}`) { const forwardTarget = resolveDashboardForwardTarget(chatUiUrl); runOpenshell(["forward", "stop", String(CONTROL_UI_PORT)], { ignoreError: true }); - // Use stdio "ignore" to prevent spawnSync from waiting on inherited pipe fds. - // The --background flag forks a child that inherits stdout/stderr; if those are - // pipes, spawnSync blocks until the background process exits (never). - runOpenshell(["forward", "start", "--background", forwardTarget, sandboxName], { - ignoreError: true, - stdio: ["ignore", "ignore", "ignore"], - }); + + const tmpErr = path.join(os.tmpdir(), `nemoclaw-fwd-${Date.now()}.err`); + for (let attempt = 0; attempt < 3; attempt++) { + // Redirect stderr to a file instead of piping — prevents spawnSync from + // blocking on inherited pipe fds when --background forks a child process. + run( + openshellShellCommand(["forward", "start", "--background", forwardTarget, sandboxName]) + + ` 2>${shellQuote(tmpErr)}`, + { ignoreError: true, stdio: ["ignore", "ignore", "ignore"] }, + ); + + let errText = ""; + try { + errText = fs.readFileSync(tmpErr, "utf8").trim(); + } catch (_e) { + /* may not exist */ + } + try { + fs.unlinkSync(tmpErr); + } catch (_e) { + /* best-effort cleanup */ + } + + // Verify the forward is actually active via openshell forward list. + const fwdList = runCaptureOpenshell(["forward", "list"], { ignoreError: true }); + if (fwdList && fwdList.includes(String(CONTROL_UI_PORT))) { + return true; + } + + if (errText) { + console.warn(` forward start attempt ${attempt + 1} stderr: ${errText}`); + } + if (attempt < 2) sleep(2); + } + + console.warn( + ` \u26a0 Dashboard forward failed after 3 attempts \u2014 port ${CONTROL_UI_PORT} not forwarded`, + ); + return false; } function findOpenclawJsonPath(dir) { diff --git a/test/onboard.test.js b/test/onboard.test.js index 240e44700..0112dbebf 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1392,6 +1392,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; @@ -1496,6 +1497,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; @@ -1588,6 +1590,7 @@ 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; @@ -1818,6 +1821,7 @@ runner.runCapture = (command) => { 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 }); @@ -2164,6 +2168,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; @@ -2271,6 +2276,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 }); From c521f7539954525311f775afc89ce37235005b92 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 16:28:01 -0700 Subject: [PATCH 16/45] fix: remove void-return checks on ensureDashboardForward after merge Main's ensureDashboardForward returns void (no retry logic). Remove stale return-value checks from our branch that reference the old retry-based version. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a318fe1f4..a3f5cb624 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2167,13 +2167,7 @@ async function createSandbox( console.log(` Sandbox '${sandboxName}' exists but messaging providers are not attached.`); console.log(" Recreating to ensure credentials flow through the provider pipeline."); } else { - const fwdOk = ensureDashboardForward(sandboxName, chatUiUrl); - if (!fwdOk) { - console.warn(" Dashboard will not be reachable until the forward is established."); - console.warn( - ` Manual fix: openshell forward start --background ${CONTROL_UI_PORT} ${sandboxName}`, - ); - } + ensureDashboardForward(sandboxName, chatUiUrl); if (isNonInteractive()) { note(` [non-interactive] Sandbox '${sandboxName}' exists and is ready — reusing it`); } else { @@ -2374,13 +2368,7 @@ async function createSandbox( // Release any stale forward on port 18789 before claiming it for the new sandbox. // A previous onboard run may have left the port forwarded to a different sandbox, // which would silently prevent the new sandbox's dashboard from being reachable. - const forwardOk = ensureDashboardForward(sandboxName, chatUiUrl); - if (!forwardOk) { - console.warn(" Dashboard will not be reachable until the forward is established."); - console.warn( - ` Manual fix: openshell forward start --background ${CONTROL_UI_PORT} ${sandboxName}`, - ); - } + ensureDashboardForward(sandboxName, chatUiUrl); // Register only after confirmed ready — prevents phantom entries registry.registerSandbox({ From 088cef8859a85c15460af8d3a266d13a0be67016 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 17:51:44 -0700 Subject: [PATCH 17/45] fix(security): address coderabbit review feedback on messaging providers Namespace messaging providers per sandbox to prevent credential overwrites across multiple sandboxes. Upsert providers on sandbox reuse so credential changes take effect without full recreation. Fail fast when non-root cannot patch messaging config. Add cleanup trap for temporary permission changes in nemoclaw-start.sh. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 83 +++++++++++++++++++++------------------ scripts/nemoclaw-start.sh | 9 +++-- scripts/start-services.sh | 2 +- test/onboard.test.js | 37 ++++++++++++----- 4 files changed, 80 insertions(+), 51 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a3f5cb624..5fbac9448 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -798,6 +798,27 @@ 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. * @@ -2138,10 +2159,24 @@ async function createSandbox( // without provider attachments (security: prevents legacy raw-env-var leaks). const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; - const hasMessagingTokens = - !!getMessagingToken("DISCORD_BOT_TOKEN") || - !!getMessagingToken("SLACK_BOT_TOKEN") || - !!getMessagingToken("TELEGRAM_BOT_TOKEN"); + 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); @@ -2154,19 +2189,17 @@ async function createSandbox( // this avoids destroying sandboxes already created with provider attachments. const needsProviderMigration = hasMessagingTokens && - [ - { envKey: "DISCORD_BOT_TOKEN", provider: "discord-bridge" }, - { envKey: "SLACK_BOT_TOKEN", provider: "slack-bridge" }, - { envKey: "TELEGRAM_BOT_TOKEN", provider: "telegram-bridge" }, - ].some( - ({ envKey, provider }) => getMessagingToken(envKey) && !providerExistsInGateway(provider), - ); + messagingTokenDefs.some(({ name, token }) => token && !providerExistsInGateway(name)); if (existingSandboxState === "ready" && process.env.NEMOCLAW_RECREATE_SANDBOX !== "1") { 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 { + // 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`); @@ -2219,33 +2252,7 @@ async function createSandbox( // 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 = []; - const messagingTokenDefs = [ - { - name: "discord-bridge", - envKey: "DISCORD_BOT_TOKEN", - token: getMessagingToken("DISCORD_BOT_TOKEN"), - }, - { - name: "slack-bridge", - envKey: "SLACK_BOT_TOKEN", - token: getMessagingToken("SLACK_BOT_TOKEN"), - }, - { - name: "telegram-bridge", - envKey: "TELEGRAM_BOT_TOKEN", - token: getMessagingToken("TELEGRAM_BOT_TOKEN"), - }, - ]; - for (const { name, envKey, token } of messagingTokenDefs) { - 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); - } - messagingProviders.push(name); - } + const messagingProviders = upsertMessagingProviders(messagingTokenDefs); for (const p of messagingProviders) { createArgs.push("--provider", p); } diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 40e85d5e5..e8e682953 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -149,16 +149,19 @@ configure_messaging_channels() { [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 if [ "$(id -u)" -ne 0 ]; then - echo "[channels] Messaging tokens detected but running as non-root — channels unavailable" >&2 - return 0 + echo "[channels] ERROR: Messaging tokens detected but running as non-root — cannot patch openclaw.json" >&2 + echo "[channels] Messaging requires root to modify the immutable config. Recreate the sandbox or remove messaging tokens." >&2 + return 1 fi local config_path="/sandbox/.openclaw/openclaw.json" local hash_path="/sandbox/.openclaw/.config-hash" - # Temporarily make config writable + # Temporarily make config writable. Use a trap to guarantee restoration + # on early exit (set -e can bail before the manual chmod 444 below). chmod 644 "$config_path" chmod 644 "$hash_path" + trap 'chmod 444 "$config_path" "$hash_path" 2>/dev/null; chown root:root "$hash_path" 2>/dev/null' RETURN python3 - <<'PYCHANNELS' import json, os diff --git a/scripts/start-services.sh b/scripts/start-services.sh index 7d69d3a74..cdb2b5b5d 100755 --- a/scripts/start-services.sh +++ b/scripts/start-services.sh @@ -158,7 +158,7 @@ do_start() { printf " │ Public URL: %-40s│\n" "$tunnel_url" fi - echo " │ Messaging: via OpenClaw native channels │" + echo " │ Messaging: via OpenClaw native channels (if configured) │" echo " │ │" echo " │ Run 'openshell term' to monitor egress approvals │" echo " └─────────────────────────────────────────────────────┘" diff --git a/test/onboard.test.js b/test/onboard.test.js index 0112dbebf..d82031ad1 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1651,24 +1651,30 @@ const { createSandbox } = require(${onboardPath}); const providerCommands = payload.commands.filter((e) => e.command.includes("'provider' 'create'"), ); - const discordProvider = providerCommands.find((e) => e.command.includes("discord-bridge")); - assert.ok(discordProvider, "expected discord-bridge provider create command"); + 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("slack-bridge")); - assert.ok(slackProvider, "expected slack-bridge provider create command"); + 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("telegram-bridge")); - assert.ok(telegramProvider, "expected telegram-bridge provider create command"); + 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' 'discord-bridge'/); - assert.match(createCommand.command, /'--provider' 'slack-bridge'/); - assert.match(createCommand.command, /'--provider' 'telegram-bridge'/); + 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/); @@ -1871,6 +1877,19 @@ const { createSandbox } = require(${onboardPath}); 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", + ); }, ); From d5c275b149a0c6e2cbf2510d289a27a6d8c0ee45 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 18:24:25 -0700 Subject: [PATCH 18/45] test(e2e): add messaging credential provider E2E test Validates the full provider/placeholder/L7-proxy chain for Telegram and Discord: provider creation, sandbox attachment, credential isolation, openclaw.json config patching, network reachability, and L7 proxy token rewriting. Uses fake tokens by default (no external accounts needed); optional real tokens enable live round-trip phase. Signed-off-by: Aaron Erickson --- test/e2e/brev-e2e.test.js | 38 +- test/e2e/test-messaging-providers.sh | 570 +++++++++++++++++++++++++++ 2 files changed, 605 insertions(+), 3 deletions(-) create mode 100755 test/e2e/test-messaging-providers.sh diff --git a/test/e2e/brev-e2e.test.js b/test/e2e/brev-e2e.test.js index 79695f038..d37726955 100644 --- a/test/e2e/brev-e2e.test.js +++ b/test/e2e/brev-e2e.test.js @@ -17,11 +17,17 @@ * INSTANCE_NAME — Brev instance name (e.g. pr-156-test) * * Optional env vars: - * TEST_SUITE — which test to run: full (default), credential-sanitization, telegram-injection, all + * TEST_SUITE — which test to run: full (default), credential-sanitization, + * telegram-injection, messaging-providers, all * USE_LAUNCHABLE — "1" (default) to use CI launchable, "0" for bare brev create + brev-setup.sh * 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) + * 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"; @@ -85,13 +91,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 }); } @@ -644,4 +663,17 @@ describe.runIf(hasRequiredVars)("Brev E2E", () => { }, 600_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..4dfed0217 --- /dev/null +++ b/test/e2e/test-messaging-providers.sh @@ -0,0 +1,570 @@ +#!/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_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}" +export TELEGRAM_BOT_TOKEN="$TELEGRAM_TOKEN" +export DISCORD_BOT_TOKEN="$DISCORD_TOKEN" + +# 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 ! command -v openshell >/dev/null 2>&1; then + fail "openshell not found on PATH" + exit 1 +fi +pass "openshell found" + +if ! command -v nemoclaw >/dev/null 2>&1; then + fail "nemoclaw not found on PATH" + exit 1 +fi +pass "nemoclaw found" + +if ! command -v node >/dev/null 2>&1; then + fail "node not found on PATH" + exit 1 +fi +pass "node found" + +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: Sandbox Creation with Messaging Providers +# ══════════════════════════════════════════════════════════════════ +section "Phase 1: Sandbox Creation with Messaging Providers" + +# 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 +openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true + +# Run onboard with messaging tokens in the environment. +# The PR #1081 code in createSandbox() detects these tokens and creates +# OpenShell providers (e2e-msg-provider-telegram-bridge, etc.) +info "Running nemoclaw onboard with messaging tokens..." +export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" +export NEMOCLAW_RECREATE_SANDBOX=1 + +cd "$REPO" || exit 1 +if ! nemoclaw onboard --non-interactive --yes-i-accept-third-party-software 2>&1; then + fail "M0: nemoclaw onboard failed" + exit 1 +fi +pass "M0: nemoclaw onboard completed successfully" + +# 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 + 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 + fail "M6: Telegram channel botToken missing from openclaw.json" + 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 + fail "M8: Discord channel token missing from openclaw.json" + 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 + fail "M10: Telegram channel is not enabled (got: $tg_enabled)" + 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 + fail "M11: Discord channel is not enabled (got: $dc_enabled)" + 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}" + +tg_status=$(echo "$tg_api" | head -1 | awk '{print $1}') +if [ "$tg_status" = "200" ]; then + pass "M15: Telegram getMe returned 200 — real token verified!" +elif [ "$tg_status" = "401" ]; then + pass "M15: Telegram getMe returned 401 — 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}" + +dc_status=$(echo "$dc_api" | 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 From ce95b90d7ba71c9de06465ead8e74628eee33e66 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 18:52:44 -0700 Subject: [PATCH 19/45] ci: add messaging-providers to e2e-brev workflow dispatch options Signed-off-by: Aaron Erickson --- .github/workflows/e2e-brev.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/e2e-brev.yaml b/.github/workflows/e2e-brev.yaml index 678b4775b..699392e5b 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)" From aa8d056edff57e4800325ccbd8de12e1cb29168f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 18:58:07 -0700 Subject: [PATCH 20/45] ci: add messaging-providers-e2e to nightly workflow Runs test/e2e/test-messaging-providers.sh on ubuntu-latest alongside the existing cloud-e2e and cloud-experimental-e2e jobs. Uses fake tokens by default so no additional secrets are needed. Signed-off-by: Aaron Erickson --- .github/workflows/nightly-e2e.yaml | 39 ++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/.github/workflows/nightly-e2e.yaml b/.github/workflows/nightly-e2e.yaml index c15f11914..3b9b475a0 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. # gpu-e2e Local Ollama inference on a GPU self-hosted runner. # Controlled by the GPU_E2E_ENABLED repository variable. # Set vars.GPU_E2E_ENABLED to "true" in repo settings to enable. @@ -161,6 +163,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 + # ── GPU E2E (Ollama local inference) ────────────────────────── # Enable by setting repository variable GPU_E2E_ENABLED=true # (Settings → Secrets and variables → Actions → Variables) @@ -213,8 +248,8 @@ jobs: notify-on-failure: runs-on: ubuntu-latest - needs: [cloud-e2e, cloud-experimental-e2e, gpu-e2e] - if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} + needs: [cloud-e2e, cloud-experimental-e2e, messaging-providers-e2e, gpu-e2e] + if: ${{ always() && (needs.cloud-e2e.result == 'failure' || needs.cloud-experimental-e2e.result == 'failure' || needs.messaging-providers-e2e.result == 'failure' || needs.gpu-e2e.result == 'failure') }} permissions: issues: write steps: From 3a328d8c3548e57fc6d4d19802dece8208449175 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 19:01:23 -0700 Subject: [PATCH 21/45] fix(e2e): run install.sh in messaging providers test for bare runners The nightly workflow runs on ubuntu-latest which has no openshell, nemoclaw, or Node.js. Add Phase 1 install step matching test-full-e2e.sh pattern: run install.sh --non-interactive, source nvm, verify tools. Signed-off-by: Aaron Erickson --- test/e2e/test-messaging-providers.sh | 88 +++++++++++++++++++--------- 1 file changed, 59 insertions(+), 29 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 4dfed0217..feaf98d28 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -125,24 +125,6 @@ if [ -z "${NVIDIA_API_KEY:-}" ]; then fi pass "NVIDIA_API_KEY is set" -if ! command -v openshell >/dev/null 2>&1; then - fail "openshell not found on PATH" - exit 1 -fi -pass "openshell found" - -if ! command -v nemoclaw >/dev/null 2>&1; then - fail "nemoclaw not found on PATH" - exit 1 -fi -pass "nemoclaw found" - -if ! command -v node >/dev/null 2>&1; then - fail "node not found on PATH" - exit 1 -fi -pass "node found" - if ! docker info >/dev/null 2>&1; then fail "Docker is not running" exit 1 @@ -154,30 +136,78 @@ info "Discord token: ${DISCORD_TOKEN:0:10}... (${#DISCORD_TOKEN} chars)" info "Sandbox name: $SANDBOX_NAME" # ══════════════════════════════════════════════════════════════════ -# Phase 1: Sandbox Creation with Messaging Providers +# Phase 1: Install NemoClaw (non-interactive mode) # ══════════════════════════════════════════════════════════════════ -section "Phase 1: Sandbox Creation with Messaging Providers" +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 -openshell sandbox delete "$SANDBOX_NAME" 2>/dev/null || true +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." -# Run onboard with messaging tokens in the environment. -# The PR #1081 code in createSandbox() detects these tokens and creates -# OpenShell providers (e2e-msg-provider-telegram-bridge, etc.) -info "Running nemoclaw onboard with messaging tokens..." export NEMOCLAW_SANDBOX_NAME="$SANDBOX_NAME" export NEMOCLAW_RECREATE_SANDBOX=1 -cd "$REPO" || exit 1 -if ! nemoclaw onboard --non-interactive --yes-i-accept-third-party-software 2>&1; then - fail "M0: nemoclaw onboard failed" +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 "M0: nemoclaw onboard completed successfully" +pass "nemoclaw installed at $(command -v nemoclaw)" # Verify sandbox is ready sandbox_list=$(openshell sandbox list 2>&1 || true) From aa8b625e278c4f90ad5d175b6a6f05d89435d95d Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 19:10:40 -0700 Subject: [PATCH 22/45] fix(security): revert non-root fail-fast in configure_messaging_channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sandbox runs as non-root (uid=998) by default. Messaging tokens are injected as placeholders by the OpenShell provider system and rewritten by the L7 proxy at egress — openclaw.json patching is not required for the proxy path to work. Returning 1 here kills sandbox startup on every messaging-enabled sandbox. Revert to return 0 with an informational message. The CodeRabbit suggestion was incorrect: non-root + messaging tokens is the normal operating mode, not a misconfiguration. Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index e8e682953..cd039e5c6 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -149,9 +149,9 @@ configure_messaging_channels() { [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 if [ "$(id -u)" -ne 0 ]; then - echo "[channels] ERROR: Messaging tokens detected but running as non-root — cannot patch openclaw.json" >&2 - echo "[channels] Messaging requires root to modify the immutable config. Recreate the sandbox or remove messaging tokens." >&2 - return 1 + echo "[channels] Messaging tokens detected but running as non-root — skipping openclaw.json patch" >&2 + echo "[channels] Channels still work via L7 proxy token rewriting (no config patch needed)" >&2 + return 0 fi local config_path="/sandbox/.openclaw/openclaw.json" From 457394f1281b87535364598f87ad585e00181c92 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 19:22:39 -0700 Subject: [PATCH 23/45] fix(e2e): handle non-root sandbox and Node.js warnings in messaging test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3: SKIP (not FAIL) when openclaw.json channels are absent — non-root sandboxes cannot patch the immutable config, but channels still work via L7 proxy token rewriting. Phase 5: Filter Node.js UNDICI-EHPA warnings from stdout before extracting HTTP status codes. Signed-off-by: Aaron Erickson --- test/e2e/test-messaging-providers.sh | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index feaf98d28..0bbc7cf53 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -293,6 +293,9 @@ 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) @@ -302,7 +305,7 @@ print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('botToken', if [ -n "$tg_token" ]; then pass "M6: Telegram channel botToken present in openclaw.json" else - fail "M6: Telegram channel botToken missing from openclaw.json" + skip "M6: Telegram channel not in openclaw.json (expected in non-root sandbox)" fi # M7: Telegram token is NOT the real/fake host token @@ -324,7 +327,7 @@ print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('token', '')) if [ -n "$dc_token" ]; then pass "M8: Discord channel token present in openclaw.json" else - fail "M8: Discord channel token missing from openclaw.json" + skip "M8: Discord channel not in openclaw.json (expected in non-root sandbox)" fi # M9: Discord token is NOT the real/fake host token @@ -346,7 +349,7 @@ print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('enabled', F if [ "$tg_enabled" = "True" ]; then pass "M10: Telegram channel is enabled" else - fail "M10: Telegram channel is not enabled (got: $tg_enabled)" + skip "M10: Telegram channel not enabled (expected in non-root sandbox)" fi # M11: Discord enabled @@ -359,7 +362,7 @@ print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('enabled', Fa if [ "$dc_enabled" = "True" ]; then pass "M11: Discord channel is enabled" else - fail "M11: Discord channel is not enabled (got: $dc_enabled)" + skip "M11: Discord channel not enabled (expected in non-root sandbox)" fi fi @@ -446,7 +449,8 @@ req.setTimeout(30000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); info "Telegram API response: ${tg_api:0:300}" -tg_status=$(echo "$tg_api" | head -1 | awk '{print $1}') +# 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" ]; then @@ -481,7 +485,8 @@ req.setTimeout(30000, () => { req.destroy(); console.log(\"TIMEOUT\"); }); info "Discord API response: ${dc_api:0:300}" -dc_status=$(echo "$dc_api" | head -1 | awk '{print $1}') +# 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 From c1407548c97542ac4f23109861358aef2d2f94f6 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Thu, 2 Apr 2026 19:41:25 -0700 Subject: [PATCH 24/45] fix(e2e): accept Telegram 404 as valid proxy rewrite proof Telegram returns 404 (not 401) for invalid bot tokens in URL paths. Both prove the L7 proxy rewrote the placeholder and the request reached the real API. Signed-off-by: Aaron Erickson --- test/e2e/test-messaging-providers.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 0bbc7cf53..ba5920096 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -453,8 +453,11 @@ info "Telegram API response: ${tg_api:0:300}" 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" ]; then - pass "M15: Telegram getMe returned 401 — L7 proxy rewrote placeholder (fake token rejected by API)" +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)" From 2123c056a958f3479a8f6c41352c836558eb6b1f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 13:28:51 -0700 Subject: [PATCH 25/45] fix: remove stale telegram-bridge spawn from services.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-side Telegram bridge (scripts/telegram-bridge.js) was removed in this PR — messaging now flows through native OpenClaw channels via the OpenShell provider/placeholder/L7-proxy pipeline. But startAll() still tried to spawn the deleted script, causing a MODULE_NOT_FOUND crash hidden behind a detached process. Remove telegram-bridge from SERVICE_NAMES, stopAll, startAll, and the banner. Update tests accordingly. Reported-by: mercl-lau Signed-off-by: Aaron Erickson --- src/lib/services.test.ts | 18 +++++------ src/lib/services.ts | 64 ++++------------------------------------ 2 files changed, 13 insertions(+), 69 deletions(-) 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 9582a5921..3df7191dc 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 │"); From 3fc08b879f7250f17aa7cb5e35b4c9ca62c7ad55 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 14:20:57 -0700 Subject: [PATCH 26/45] feat: prompt for messaging channel tokens during interactive onboard When no messaging tokens are found in the environment or credential store, the onboard wizard now asks whether the user wants to connect Telegram, Discord, or Slack. Tokens are saved to the secure credential store and picked up by the provider pipeline in createSandbox(). Skipped automatically in non-interactive mode (CI/scripts). Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 75 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 5fbac9448..823378677 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2159,6 +2159,81 @@ async function createSandbox( // without provider attachments (security: prevents legacy raw-env-var leaks). const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + + // Offer to configure messaging channels if none are set and we're interactive. + // Check the env var directly as well — NON_INTERACTIVE is only set inside + // onboard(), but createSandbox() can be called directly by tests or scripts. + if ( + !isNonInteractive() && + process.env.NEMOCLAW_NON_INTERACTIVE !== "1" && + !getMessagingToken("TELEGRAM_BOT_TOKEN") && + !getMessagingToken("DISCORD_BOT_TOKEN") && + !getMessagingToken("SLACK_BOT_TOKEN") + ) { + console.log(""); + console.log(" ┌──────────────────────────────────────────────────────────────┐"); + console.log(" │ Messaging channels (optional) │"); + console.log(" │ │"); + console.log(" │ Connect Telegram, Discord, or Slack so your assistant │"); + console.log(" │ can send and receive messages. Tokens are stored securely │"); + console.log(" │ and never exposed inside the sandbox. │"); + console.log(" │ │"); + console.log(" │ Press Enter to skip, or type a channel name to configure. │"); + console.log(" └──────────────────────────────────────────────────────────────┘"); + console.log(""); + + const MESSAGING_CHANNELS = [ + { + name: "telegram", + envKey: "TELEGRAM_BOT_TOKEN", + label: "Telegram Bot Token", + help: "Create a bot via @BotFather on Telegram → copy the token", + }, + { + name: "discord", + envKey: "DISCORD_BOT_TOKEN", + label: "Discord Bot Token", + help: "Discord Developer Portal → Applications → Bot → Copy token", + }, + { + name: "slack", + envKey: "SLACK_BOT_TOKEN", + label: "Slack Bot Token", + help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...)", + }, + ]; + + const channelAnswer = ( + await prompt(" Connect a channel? (telegram / discord / slack / skip) [skip]: ") + ) + .trim() + .toLowerCase(); + + if (channelAnswer && channelAnswer !== "skip") { + const channels = channelAnswer.split(/[,\s]+/).filter(Boolean); + for (const ch of channels) { + const def = MESSAGING_CHANNELS.find((c) => c.name === ch); + if (!def) { + console.log(` Unknown channel: ${ch} (available: telegram, discord, slack)`); + continue; + } + console.log(""); + console.log(` ${def.help}`); + const token = normalizeCredentialValue( + await prompt(` ${def.label}: `, { secret: true }), + ); + if (token) { + saveCredential(def.envKey, token); + process.env[def.envKey] = token; + console.log(` ✓ ${def.name} token saved`); + } else { + console.log(` Skipped ${def.name}`); + } + } + console.log(""); + } + } + const messagingTokenDefs = [ { name: `${sandboxName}-discord-bridge`, From c50c3b48b0bc71f98847ad4f937cd72f788a3a00 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 14:26:48 -0700 Subject: [PATCH 27/45] fix: prompt for each messaging token individually during onboard Instead of asking users to name a channel, prompt for each token separately with Enter-to-skip. Simpler UX: paste or skip, three times. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 47 ++++++++++++++-------------------------------- 1 file changed, 14 insertions(+), 33 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 823378677..a7ba9ed44 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2178,57 +2178,38 @@ async function createSandbox( console.log(" │ can send and receive messages. Tokens are stored securely │"); console.log(" │ and never exposed inside the sandbox. │"); console.log(" │ │"); - console.log(" │ Press Enter to skip, or type a channel name to configure. │"); + console.log(" │ For each channel, paste the bot token or press Enter to │"); + console.log(" │ skip that channel. │"); console.log(" └──────────────────────────────────────────────────────────────┘"); console.log(""); const MESSAGING_CHANNELS = [ { - name: "telegram", envKey: "TELEGRAM_BOT_TOKEN", label: "Telegram Bot Token", - help: "Create a bot via @BotFather on Telegram → copy the token", + help: "Create a bot via @BotFather on Telegram, then copy the token.", }, { - name: "discord", envKey: "DISCORD_BOT_TOKEN", label: "Discord Bot Token", - help: "Discord Developer Portal → Applications → Bot → Copy token", + help: "Discord Developer Portal → Applications → Bot → Copy token.", }, { - name: "slack", envKey: "SLACK_BOT_TOKEN", label: "Slack Bot Token", - help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...)", + help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", }, ]; - const channelAnswer = ( - await prompt(" Connect a channel? (telegram / discord / slack / skip) [skip]: ") - ) - .trim() - .toLowerCase(); - - if (channelAnswer && channelAnswer !== "skip") { - const channels = channelAnswer.split(/[,\s]+/).filter(Boolean); - for (const ch of channels) { - const def = MESSAGING_CHANNELS.find((c) => c.name === ch); - if (!def) { - console.log(` Unknown channel: ${ch} (available: telegram, discord, slack)`); - continue; - } - console.log(""); - console.log(` ${def.help}`); - const token = normalizeCredentialValue( - await prompt(` ${def.label}: `, { secret: true }), - ); - if (token) { - saveCredential(def.envKey, token); - process.env[def.envKey] = token; - console.log(` ✓ ${def.name} token saved`); - } else { - console.log(` Skipped ${def.name}`); - } + for (const def of MESSAGING_CHANNELS) { + console.log(` ${def.help}`); + const token = normalizeCredentialValue( + await prompt(` ${def.label} (Enter to skip): `, { secret: true }), + ); + if (token) { + saveCredential(def.envKey, token); + process.env[def.envKey] = token; + console.log(` ✓ Saved`); } console.log(""); } From 21acf9a6ea284f9d42ae8023b1a05330d623cf36 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 14:32:47 -0700 Subject: [PATCH 28/45] fix: ask which messaging channels first, then prompt only those tokens Users first select which channels they want (telegram, discord, slack) from a single prompt, then only get token prompts for the services they chose. Enter to skip the whole thing. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 63 ++++++++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 21 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a7ba9ed44..748b447d6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2171,45 +2171,66 @@ async function createSandbox( !getMessagingToken("SLACK_BOT_TOKEN") ) { console.log(""); - console.log(" ┌──────────────────────────────────────────────────────────────┐"); - console.log(" │ Messaging channels (optional) │"); - console.log(" │ │"); - console.log(" │ Connect Telegram, Discord, or Slack so your assistant │"); - console.log(" │ can send and receive messages. Tokens are stored securely │"); - console.log(" │ and never exposed inside the sandbox. │"); - console.log(" │ │"); - console.log(" │ For each channel, paste the bot token or press Enter to │"); - console.log(" │ skip that channel. │"); - console.log(" └──────────────────────────────────────────────────────────────┘"); - console.log(""); - const MESSAGING_CHANNELS = [ { + name: "telegram", envKey: "TELEGRAM_BOT_TOKEN", label: "Telegram Bot Token", help: "Create a bot via @BotFather on Telegram, then copy the token.", }, { + name: "discord", envKey: "DISCORD_BOT_TOKEN", label: "Discord Bot Token", help: "Discord Developer Portal → Applications → Bot → Copy token.", }, { + name: "slack", envKey: "SLACK_BOT_TOKEN", label: "Slack Bot Token", help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", }, ]; - for (const def of MESSAGING_CHANNELS) { - console.log(` ${def.help}`); - const token = normalizeCredentialValue( - await prompt(` ${def.label} (Enter to skip): `, { secret: true }), - ); - if (token) { - saveCredential(def.envKey, token); - process.env[def.envKey] = token; - console.log(` ✓ Saved`); + console.log(""); + console.log(" Messaging channels (optional):"); + console.log(" Connect Telegram, Discord, or Slack so your assistant can"); + console.log(" send and receive messages. Tokens are stored securely and"); + console.log(" never exposed inside the sandbox."); + console.log(""); + console.log(` Available: ${MESSAGING_CHANNELS.map((c) => c.name).join(", ")}`); + console.log(""); + + const channelAnswer = ( + await prompt(" Which channels? (comma-separated, or Enter to skip): ") + ) + .trim() + .toLowerCase(); + + if (channelAnswer) { + const selected = channelAnswer + .split(/[,\s]+/) + .map((s) => s.trim()) + .filter(Boolean); + + for (const ch of selected) { + const def = MESSAGING_CHANNELS.find((c) => c.name === ch); + if (!def) { + console.log(` Unknown channel: ${ch}`); + continue; + } + console.log(""); + console.log(` ${def.help}`); + const token = normalizeCredentialValue( + await prompt(` ${def.label}: `, { secret: true }), + ); + if (token) { + saveCredential(def.envKey, token); + process.env[def.envKey] = token; + console.log(` ✓ Saved`); + } else { + console.log(` Skipped ${def.name}`); + } } console.log(""); } From ca5f607db01d52b05746f9115453f80951771111 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 14:39:09 -0700 Subject: [PATCH 29/45] fix: prompt for each messaging token separately with setup instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ask for each token individually (Telegram, Discord, Slack) with clear instructions on where to get it. Enter to skip any channel. No comma-separated selection — just three simple paste-or-skip prompts. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 87 ++++++++++++++++++---------------------------- 1 file changed, 33 insertions(+), 54 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 748b447d6..238d22800 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2170,70 +2170,49 @@ async function createSandbox( !getMessagingToken("DISCORD_BOT_TOKEN") && !getMessagingToken("SLACK_BOT_TOKEN") ) { - console.log(""); - const MESSAGING_CHANNELS = [ - { - name: "telegram", - envKey: "TELEGRAM_BOT_TOKEN", - label: "Telegram Bot Token", - help: "Create a bot via @BotFather on Telegram, then copy the token.", - }, - { - name: "discord", - envKey: "DISCORD_BOT_TOKEN", - label: "Discord Bot Token", - help: "Discord Developer Portal → Applications → Bot → Copy token.", - }, - { - name: "slack", - envKey: "SLACK_BOT_TOKEN", - label: "Slack Bot Token", - help: "Slack API → Your Apps → OAuth & Permissions → Bot User OAuth Token (xoxb-...).", - }, - ]; - console.log(""); console.log(" Messaging channels (optional):"); console.log(" Connect Telegram, Discord, or Slack so your assistant can"); console.log(" send and receive messages. Tokens are stored securely and"); console.log(" never exposed inside the sandbox."); - console.log(""); - console.log(` Available: ${MESSAGING_CHANNELS.map((c) => c.name).join(", ")}`); + console.log(" Press Enter to skip any channel you don't need."); console.log(""); - const channelAnswer = ( - await prompt(" Which channels? (comma-separated, or Enter to skip): ") - ) - .trim() - .toLowerCase(); + // Telegram + console.log(" Telegram: Create a bot via @BotFather on Telegram, then copy the token."); + const tgInput = normalizeCredentialValue( + await prompt(" Telegram Bot Token (Enter to skip): ", { secret: true }), + ); + if (tgInput) { + saveCredential("TELEGRAM_BOT_TOKEN", tgInput); + process.env.TELEGRAM_BOT_TOKEN = tgInput; + console.log(" ✓ Telegram token saved"); + } + console.log(""); - if (channelAnswer) { - const selected = channelAnswer - .split(/[,\s]+/) - .map((s) => s.trim()) - .filter(Boolean); + // Discord + console.log(" Discord: Developer Portal → Applications → Bot → Reset/Copy Token."); + const dcInput = normalizeCredentialValue( + await prompt(" Discord Bot Token (Enter to skip): ", { secret: true }), + ); + if (dcInput) { + saveCredential("DISCORD_BOT_TOKEN", dcInput); + process.env.DISCORD_BOT_TOKEN = dcInput; + console.log(" ✓ Discord token saved"); + } + console.log(""); - for (const ch of selected) { - const def = MESSAGING_CHANNELS.find((c) => c.name === ch); - if (!def) { - console.log(` Unknown channel: ${ch}`); - continue; - } - console.log(""); - console.log(` ${def.help}`); - const token = normalizeCredentialValue( - await prompt(` ${def.label}: `, { secret: true }), - ); - if (token) { - saveCredential(def.envKey, token); - process.env[def.envKey] = token; - console.log(` ✓ Saved`); - } else { - console.log(` Skipped ${def.name}`); - } - } - console.log(""); + // Slack + console.log(" Slack: api.slack.com → Your Apps → OAuth & Permissions → Bot User OAuth Token."); + const slInput = normalizeCredentialValue( + await prompt(" Slack Bot Token (Enter to skip): ", { secret: true }), + ); + if (slInput) { + saveCredential("SLACK_BOT_TOKEN", slInput); + process.env.SLACK_BOT_TOKEN = slInput; + console.log(" ✓ Slack token saved"); } + console.log(""); } const messagingTokenDefs = [ From fd12dffe7350c0c00ea02265b4582e8abf0879c2 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 15:47:56 -0700 Subject: [PATCH 30/45] feat: make messaging channels its own onboard step (6 of 8) Move messaging token prompts out of "Creating sandbox" (step 5) into a dedicated "Messaging channels" step (6 of 8). Renumber OpenClaw setup to step 7 and Policy presets to step 8. The messaging step runs after sandbox creation so tokens are available for provider attachment. Shows existing tokens if already configured, skips silently in non-interactive mode. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 158 +++++++++++++++++++++++++------------------ test/onboard.test.js | 2 +- 2 files changed, 92 insertions(+), 68 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 238d22800..fb96b92cb 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1781,7 +1781,7 @@ function getNonInteractiveModel(providerKey) { // eslint-disable-next-line complexity async function preflight() { - step(1, 7, "Preflight checks"); + step(1, 8, "Preflight checks"); // Docker if (!isDockerRunning()) { @@ -1951,7 +1951,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], { @@ -2149,7 +2149,7 @@ async function createSandbox( preferredInferenceApi = null, sandboxNameOverride = null, ) { - step(5, 7, "Creating sandbox"); + step(5, 8, "Creating sandbox"); const sandboxName = sandboxNameOverride || (await promptValidatedSandboxName()); const chatUiUrl = process.env.CHAT_UI_URL || `http://127.0.0.1:${CONTROL_UI_PORT}`; @@ -2160,61 +2160,6 @@ async function createSandbox( const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; - // Offer to configure messaging channels if none are set and we're interactive. - // Check the env var directly as well — NON_INTERACTIVE is only set inside - // onboard(), but createSandbox() can be called directly by tests or scripts. - if ( - !isNonInteractive() && - process.env.NEMOCLAW_NON_INTERACTIVE !== "1" && - !getMessagingToken("TELEGRAM_BOT_TOKEN") && - !getMessagingToken("DISCORD_BOT_TOKEN") && - !getMessagingToken("SLACK_BOT_TOKEN") - ) { - console.log(""); - console.log(" Messaging channels (optional):"); - console.log(" Connect Telegram, Discord, or Slack so your assistant can"); - console.log(" send and receive messages. Tokens are stored securely and"); - console.log(" never exposed inside the sandbox."); - console.log(" Press Enter to skip any channel you don't need."); - console.log(""); - - // Telegram - console.log(" Telegram: Create a bot via @BotFather on Telegram, then copy the token."); - const tgInput = normalizeCredentialValue( - await prompt(" Telegram Bot Token (Enter to skip): ", { secret: true }), - ); - if (tgInput) { - saveCredential("TELEGRAM_BOT_TOKEN", tgInput); - process.env.TELEGRAM_BOT_TOKEN = tgInput; - console.log(" ✓ Telegram token saved"); - } - console.log(""); - - // Discord - console.log(" Discord: Developer Portal → Applications → Bot → Reset/Copy Token."); - const dcInput = normalizeCredentialValue( - await prompt(" Discord Bot Token (Enter to skip): ", { secret: true }), - ); - if (dcInput) { - saveCredential("DISCORD_BOT_TOKEN", dcInput); - process.env.DISCORD_BOT_TOKEN = dcInput; - console.log(" ✓ Discord token saved"); - } - console.log(""); - - // Slack - console.log(" Slack: api.slack.com → Your Apps → OAuth & Permissions → Bot User OAuth Token."); - const slInput = normalizeCredentialValue( - await prompt(" Slack Bot Token (Enter to skip): ", { secret: true }), - ); - if (slInput) { - saveCredential("SLACK_BOT_TOKEN", slInput); - process.env.SLACK_BOT_TOKEN = slInput; - console.log(" ✓ Slack token saved"); - } - console.log(""); - } - const messagingTokenDefs = [ { name: `${sandboxName}-discord-bridge`, @@ -2467,7 +2412,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; @@ -3035,7 +2980,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 ( @@ -3169,10 +3114,86 @@ async function setupInference( return { ok: true }; } -// ── Step 6: OpenClaw ───────────────────────────────────────────── +// ── Step 6: Messaging channels ─────────────────────────────────── + +async function setupMessagingChannels() { + step(6, 8, "Messaging channels"); + + const getMessagingToken = (envKey) => + getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; + + // Skip if tokens are already configured or we're non-interactive + if ( + isNonInteractive() || + process.env.NEMOCLAW_NON_INTERACTIVE === "1" || + getMessagingToken("TELEGRAM_BOT_TOKEN") || + getMessagingToken("DISCORD_BOT_TOKEN") || + getMessagingToken("SLACK_BOT_TOKEN") + ) { + if ( + getMessagingToken("TELEGRAM_BOT_TOKEN") || + getMessagingToken("DISCORD_BOT_TOKEN") || + getMessagingToken("SLACK_BOT_TOKEN") + ) { + const found = [ + getMessagingToken("TELEGRAM_BOT_TOKEN") && "telegram", + getMessagingToken("DISCORD_BOT_TOKEN") && "discord", + getMessagingToken("SLACK_BOT_TOKEN") && "slack", + ].filter(Boolean); + console.log(` Messaging tokens already configured: ${found.join(", ")}`); + } else { + note(" No messaging tokens configured. Skipping."); + } + return; + } + + console.log(" Connect Telegram, Discord, or Slack so your assistant can"); + console.log(" send and receive messages. Tokens are stored securely and"); + console.log(" never exposed inside the sandbox."); + console.log(" Press Enter to skip any channel you don't need."); + console.log(""); + + // Telegram + console.log(" Telegram: Create a bot via @BotFather on Telegram, then copy the token."); + const tgInput = normalizeCredentialValue( + await prompt(" Telegram Bot Token (Enter to skip): ", { secret: true }), + ); + if (tgInput) { + saveCredential("TELEGRAM_BOT_TOKEN", tgInput); + process.env.TELEGRAM_BOT_TOKEN = tgInput; + console.log(" ✓ Telegram token saved"); + } + console.log(""); + + // Discord + console.log(" Discord: Developer Portal → Applications → Bot → Reset/Copy Token."); + const dcInput = normalizeCredentialValue( + await prompt(" Discord Bot Token (Enter to skip): ", { secret: true }), + ); + if (dcInput) { + saveCredential("DISCORD_BOT_TOKEN", dcInput); + process.env.DISCORD_BOT_TOKEN = dcInput; + console.log(" ✓ Discord token saved"); + } + console.log(""); + + // Slack + console.log(" Slack: api.slack.com → Your Apps → OAuth & Permissions → Bot User OAuth Token."); + const slInput = normalizeCredentialValue( + await prompt(" Slack Bot Token (Enter to skip): ", { secret: true }), + ); + if (slInput) { + saveCredential("SLACK_BOT_TOKEN", slInput); + process.env.SLACK_BOT_TOKEN = slInput; + console.log(" ✓ Slack token saved"); + } + 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) { @@ -3199,7 +3220,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"]; @@ -3351,7 +3372,7 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; - step(7, 7, "Policy presets"); + step(8, 8, "Policy presets"); const suggestions = ["pypi", "npm"]; if (getCredential("TELEGRAM_BOT_TOKEN")) suggestions.push("telegram"); @@ -3627,14 +3648,15 @@ const ONBOARD_STEP_INDEX = { 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: 6, title: "Messaging channels" }, + 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})` : ""}`); @@ -3885,6 +3907,8 @@ async function onboard(opts = {}) { onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); } + await setupMessagingChannels(); + const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { skippedStepMessage("openclaw", sandboxName); diff --git a/test/onboard.test.js b/test/onboard.test.js index d82031ad1..e864f8b12 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -1211,7 +1211,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, From cf239284f80515ea6d8129c1dbe9d609c42246c1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 15:59:13 -0700 Subject: [PATCH 31/45] fix: use preset-style selection UX for messaging channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show available channels with bullet markers (● configured / ○ not), let the user select which to enable (comma-separated like policy presets), then only prompt for tokens where one isn't already set. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 120 +++++++++++++++++++++++++-------------------- 1 file changed, 67 insertions(+), 53 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index fb96b92cb..270012880 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3116,76 +3116,90 @@ async function setupInference( // ── 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", + }, + { + 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(6, 8, "Messaging channels"); const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; - // Skip if tokens are already configured or we're non-interactive - if ( - isNonInteractive() || - process.env.NEMOCLAW_NON_INTERACTIVE === "1" || - getMessagingToken("TELEGRAM_BOT_TOKEN") || - getMessagingToken("DISCORD_BOT_TOKEN") || - getMessagingToken("SLACK_BOT_TOKEN") - ) { - if ( - getMessagingToken("TELEGRAM_BOT_TOKEN") || - getMessagingToken("DISCORD_BOT_TOKEN") || - getMessagingToken("SLACK_BOT_TOKEN") - ) { - const found = [ - getMessagingToken("TELEGRAM_BOT_TOKEN") && "telegram", - getMessagingToken("DISCORD_BOT_TOKEN") && "discord", - getMessagingToken("SLACK_BOT_TOKEN") && "slack", - ].filter(Boolean); - console.log(` Messaging tokens already configured: ${found.join(", ")}`); + // 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(" No messaging tokens configured. Skipping."); + note(" [non-interactive] No messaging tokens configured. Skipping."); } return; } - console.log(" Connect Telegram, Discord, or Slack so your assistant can"); - console.log(" send and receive messages. Tokens are stored securely and"); - console.log(" never exposed inside the sandbox."); - console.log(" Press Enter to skip any channel you don't need."); + // Show available channels with ●/○ markers (same UX as policy presets) console.log(""); - - // Telegram - console.log(" Telegram: Create a bot via @BotFather on Telegram, then copy the token."); - const tgInput = normalizeCredentialValue( - await prompt(" Telegram Bot Token (Enter to skip): ", { secret: true }), - ); - if (tgInput) { - saveCredential("TELEGRAM_BOT_TOKEN", tgInput); - process.env.TELEGRAM_BOT_TOKEN = tgInput; - console.log(" ✓ Telegram token saved"); + console.log(" Available messaging channels:"); + for (const ch of MESSAGING_CHANNELS) { + const hasToken = !!getMessagingToken(ch.envKey); + const marker = hasToken ? "●" : "○"; + const status = hasToken ? " (token configured)" : ""; + console.log(` ${marker} ${ch.name} — ${ch.description}${status}`); } console.log(""); - // Discord - console.log(" Discord: Developer Portal → Applications → Bot → Reset/Copy Token."); - const dcInput = normalizeCredentialValue( - await prompt(" Discord Bot Token (Enter to skip): ", { secret: true }), - ); - if (dcInput) { - saveCredential("DISCORD_BOT_TOKEN", dcInput); - process.env.DISCORD_BOT_TOKEN = dcInput; - console.log(" ✓ Discord token saved"); + const answer = await prompt(" Enable channels (comma-separated, or Enter to skip): "); + const selected = answer + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + if (selected.length === 0) { + console.log(" Skipping messaging channels."); + return; } - console.log(""); - // Slack - console.log(" Slack: api.slack.com → Your Apps → OAuth & Permissions → Bot User OAuth Token."); - const slInput = normalizeCredentialValue( - await prompt(" Slack Bot Token (Enter to skip): ", { secret: true }), - ); - if (slInput) { - saveCredential("SLACK_BOT_TOKEN", slInput); - process.env.SLACK_BOT_TOKEN = slInput; - console.log(" ✓ Slack token saved"); + // 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} — token already configured`); + continue; + } + 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)`); + } } console.log(""); } From c9d6f33874da3a15e3aa928ef3bf31676e439824 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 16:10:56 -0700 Subject: [PATCH 32/45] fix: move messaging channels to step 5, before sandbox creation Messaging tokens must be collected before createSandbox (now step 6) runs, because that's where providers are created and attached via --provider flags. Swapped the order so tokens are available when the sandbox is built. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 270012880..eefc4bff9 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -2149,7 +2149,7 @@ async function createSandbox( preferredInferenceApi = null, sandboxNameOverride = null, ) { - step(5, 8, "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}`; @@ -3141,7 +3141,7 @@ const MESSAGING_CHANNELS = [ ]; async function setupMessagingChannels() { - step(6, 8, "Messaging channels"); + step(5, 8, "Messaging channels"); const getMessagingToken = (envKey) => getCredential(envKey) || normalizeCredentialValue(process.env[envKey]) || null; @@ -3661,8 +3661,8 @@ 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" }, - messaging: { number: 6, title: "Messaging channels" }, + 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" }, }; @@ -3916,13 +3916,13 @@ async function onboard(opts = {}) { } } } + await setupMessagingChannels(); + startRecordedStep("sandbox", { sandboxName, provider, model }); sandboxName = await createSandbox(gpu, model, provider, preferredInferenceApi, sandboxName); onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); } - await setupMessagingChannels(); - const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); if (resumeOpenclaw) { skippedStepMessage("openclaw", sandboxName); From 17d1a1f7e35b55a875344f36fb8e0c17b2074ae1 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 16:18:24 -0700 Subject: [PATCH 33/45] fix: match policy preset UX for messaging channel selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the same Y/n/list pattern as policy presets: - Show channels with ●/○ markers - If tokens exist: "Keep configured (telegram)? [Y/n/list]" - If none exist: "Enable messaging channels? [y/N/list]" - "list" lets user pick from the full list, then prompts for tokens only where missing Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 58 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 48 insertions(+), 10 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index eefc4bff9..6d6c040a6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3158,25 +3158,63 @@ async function setupMessagingChannels() { } // Show available channels with ●/○ markers (same UX as policy presets) + const configured = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)); + const suggestions = configured.map((c) => c.name); + console.log(""); console.log(" Available messaging channels:"); for (const ch of MESSAGING_CHANNELS) { const hasToken = !!getMessagingToken(ch.envKey); const marker = hasToken ? "●" : "○"; - const status = hasToken ? " (token configured)" : ""; + const status = hasToken ? " (configured)" : ""; console.log(` ${marker} ${ch.name} — ${ch.description}${status}`); } console.log(""); - const answer = await prompt(" Enable channels (comma-separated, or Enter to skip): "); - const selected = answer - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); + let selected; - if (selected.length === 0) { - console.log(" Skipping messaging channels."); - return; + if (suggestions.length > 0) { + const answer = ( + await prompt(` Keep configured channels (${suggestions.join(", ")})? [Y/n/list]: `) + ) + .trim() + .toLowerCase(); + + if (answer === "n") { + console.log(" Skipping messaging channels."); + return; + } + if (answer === "list") { + const picks = await prompt(" Enter channel names (comma-separated): "); + selected = picks + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } else { + // Y or Enter — keep what's configured, nothing more to do + console.log(` ✓ Keeping: ${suggestions.join(", ")}`); + return; + } + } else { + const answer = ( + await prompt(" Enable messaging channels? [y/N/list]: ") + ) + .trim() + .toLowerCase(); + + if (answer === "list") { + const picks = await prompt(" Enter channel names (comma-separated): "); + selected = picks + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } else if (answer === "y" || answer === "yes") { + // Enable all + selected = MESSAGING_CHANNELS.map((c) => c.name); + } else { + console.log(" Skipping messaging channels."); + return; + } } // For each selected channel, prompt for token if not already set @@ -3187,7 +3225,7 @@ async function setupMessagingChannels() { continue; } if (getMessagingToken(ch.envKey)) { - console.log(` ✓ ${ch.name} — token already configured`); + console.log(` ✓ ${ch.name} — already configured`); continue; } console.log(""); From 638851bd94b9817d8c7ecc77b0b40fd1d5f8a073 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 16:25:34 -0700 Subject: [PATCH 34/45] fix: use exact Y/n/list pattern from policy presets for messaging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the policy preset UX exactly: - Show channels with ●/○ markers - If tokens already configured: "Enable channels (telegram)? [Y/n/list]:" Y/Enter keeps configured, n skips, list picks from full list - If no tokens: "Enable messaging channels? [y/N/list]:" y enables all, N/Enter skips, list picks from full list - For each selected channel missing a token, prompt with instructions Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 76 +++++++++++++++++----------------------------- 1 file changed, 28 insertions(+), 48 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 6d6c040a6..a3d58082c 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3157,64 +3157,44 @@ async function setupMessagingChannels() { return; } - // Show available channels with ●/○ markers (same UX as policy presets) - const configured = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)); - const suggestions = configured.map((c) => c.name); + // Show available channels with ●/○ markers — same UX as policy presets + const suggestions = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map( + (c) => c.name, + ); console.log(""); console.log(" Available messaging channels:"); - for (const ch of MESSAGING_CHANNELS) { + MESSAGING_CHANNELS.forEach((ch) => { const hasToken = !!getMessagingToken(ch.envKey); const marker = hasToken ? "●" : "○"; - const status = hasToken ? " (configured)" : ""; - console.log(` ${marker} ${ch.name} — ${ch.description}${status}`); - } + const configured = hasToken ? " (configured)" : ""; + console.log(` ${marker} ${ch.name} — ${ch.description}${configured}`); + }); console.log(""); - let selected; + const promptText = + suggestions.length > 0 + ? ` Enable channels (${suggestions.join(", ")})? [Y/n/list]: ` + : " Enable messaging channels? [y/N/list]: "; + const defaultYes = suggestions.length > 0; - if (suggestions.length > 0) { - const answer = ( - await prompt(` Keep configured channels (${suggestions.join(", ")})? [Y/n/list]: `) - ) - .trim() - .toLowerCase(); + const answer = (await prompt(promptText)).trim().toLowerCase(); - if (answer === "n") { - console.log(" Skipping messaging channels."); - return; - } - if (answer === "list") { - const picks = await prompt(" Enter channel names (comma-separated): "); - selected = picks - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - } else { - // Y or Enter — keep what's configured, nothing more to do - console.log(` ✓ Keeping: ${suggestions.join(", ")}`); - return; - } - } else { - const answer = ( - await prompt(" Enable messaging channels? [y/N/list]: ") - ) - .trim() - .toLowerCase(); + if (answer === "n" || (!defaultYes && answer !== "y" && answer !== "yes" && answer !== "list")) { + console.log(" Skipping messaging channels."); + return; + } - if (answer === "list") { - const picks = await prompt(" Enter channel names (comma-separated): "); - selected = picks - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - } else if (answer === "y" || answer === "yes") { - // Enable all - selected = MESSAGING_CHANNELS.map((c) => c.name); - } else { - console.log(" Skipping messaging channels."); - return; - } + let selected; + if (answer === "list") { + const picks = await prompt(" Enter channel names (comma-separated): "); + selected = picks + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } else { + // Y/Enter (when suggestions exist) or y/yes (when none) — use suggestions or all + selected = suggestions.length > 0 ? suggestions : MESSAGING_CHANNELS.map((c) => c.name); } // For each selected channel, prompt for token if not already set From b1de2c94ae34746b5bfe8a088c9c8e4db9b4aa0a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 16:26:37 -0700 Subject: [PATCH 35/45] Revert "fix: use exact Y/n/list pattern from policy presets for messaging" This reverts commit 638851bd94b9817d8c7ecc77b0b40fd1d5f8a073. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 76 +++++++++++++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 28 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index a3d58082c..6d6c040a6 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3157,44 +3157,64 @@ async function setupMessagingChannels() { return; } - // Show available channels with ●/○ markers — same UX as policy presets - const suggestions = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map( - (c) => c.name, - ); + // Show available channels with ●/○ markers (same UX as policy presets) + const configured = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)); + const suggestions = configured.map((c) => c.name); console.log(""); console.log(" Available messaging channels:"); - MESSAGING_CHANNELS.forEach((ch) => { + for (const ch of MESSAGING_CHANNELS) { const hasToken = !!getMessagingToken(ch.envKey); const marker = hasToken ? "●" : "○"; - const configured = hasToken ? " (configured)" : ""; - console.log(` ${marker} ${ch.name} — ${ch.description}${configured}`); - }); + const status = hasToken ? " (configured)" : ""; + console.log(` ${marker} ${ch.name} — ${ch.description}${status}`); + } console.log(""); - const promptText = - suggestions.length > 0 - ? ` Enable channels (${suggestions.join(", ")})? [Y/n/list]: ` - : " Enable messaging channels? [y/N/list]: "; - const defaultYes = suggestions.length > 0; - - const answer = (await prompt(promptText)).trim().toLowerCase(); + let selected; - if (answer === "n" || (!defaultYes && answer !== "y" && answer !== "yes" && answer !== "list")) { - console.log(" Skipping messaging channels."); - return; - } + if (suggestions.length > 0) { + const answer = ( + await prompt(` Keep configured channels (${suggestions.join(", ")})? [Y/n/list]: `) + ) + .trim() + .toLowerCase(); - let selected; - if (answer === "list") { - const picks = await prompt(" Enter channel names (comma-separated): "); - selected = picks - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); + if (answer === "n") { + console.log(" Skipping messaging channels."); + return; + } + if (answer === "list") { + const picks = await prompt(" Enter channel names (comma-separated): "); + selected = picks + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } else { + // Y or Enter — keep what's configured, nothing more to do + console.log(` ✓ Keeping: ${suggestions.join(", ")}`); + return; + } } else { - // Y/Enter (when suggestions exist) or y/yes (when none) — use suggestions or all - selected = suggestions.length > 0 ? suggestions : MESSAGING_CHANNELS.map((c) => c.name); + const answer = ( + await prompt(" Enable messaging channels? [y/N/list]: ") + ) + .trim() + .toLowerCase(); + + if (answer === "list") { + const picks = await prompt(" Enter channel names (comma-separated): "); + selected = picks + .split(",") + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + } else if (answer === "y" || answer === "yes") { + // Enable all + selected = MESSAGING_CHANNELS.map((c) => c.name); + } else { + console.log(" Skipping messaging channels."); + return; + } } // For each selected channel, prompt for token if not already set From 163ba055a026b0aa3f60feda311f48f6656ea448 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 17:04:42 -0700 Subject: [PATCH 36/45] fix: replace comma-separated messaging selector with numbered toggle Replace the Y/n/list + comma-separated channel selection with a numbered toggle UI. Users type 1/2/3 to toggle channels on/off, press Enter when done. Channels with existing tokens are pre-selected. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 83 ++++++++++++++++++---------------------------- 1 file changed, 32 insertions(+), 51 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 6d6c040a6..777022f17 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3157,64 +3157,45 @@ async function setupMessagingChannels() { return; } - // Show available channels with ●/○ markers (same UX as policy presets) - const configured = MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)); - const suggestions = configured.map((c) => c.name); - - console.log(""); - console.log(" Available messaging channels:"); - for (const ch of MESSAGING_CHANNELS) { - const hasToken = !!getMessagingToken(ch.envKey); - const marker = hasToken ? "●" : "○"; - const status = hasToken ? " (configured)" : ""; - console.log(` ${marker} ${ch.name} — ${ch.description}${status}`); - } - console.log(""); + // Numbered toggle selector — pre-select channels that already have tokens + const enabled = new Set( + MESSAGING_CHANNELS.filter((c) => getMessagingToken(c.envKey)).map((c) => c.name), + ); - let selected; + const showList = () => { + console.log(""); + console.log(" Available messaging channels:"); + MESSAGING_CHANNELS.forEach((ch, i) => { + const marker = enabled.has(ch.name) ? "●" : "○"; + const status = getMessagingToken(ch.envKey) ? " (configured)" : ""; + console.log(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}`); + }); + console.log(""); + }; - if (suggestions.length > 0) { - const answer = ( - await prompt(` Keep configured channels (${suggestions.join(", ")})? [Y/n/list]: `) - ) - .trim() - .toLowerCase(); + showList(); - if (answer === "n") { - console.log(" Skipping messaging channels."); - return; + while (true) { + const input = (await prompt(" Enter a number to toggle, or press Enter when done: ")).trim(); + if (input === "") break; + const num = parseInt(input, 10); + if (isNaN(num) || num < 1 || num > MESSAGING_CHANNELS.length) { + console.log(` Please enter a number between 1 and ${MESSAGING_CHANNELS.length}.`); + continue; } - if (answer === "list") { - const picks = await prompt(" Enter channel names (comma-separated): "); - selected = picks - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); + const ch = MESSAGING_CHANNELS[num - 1]; + if (enabled.has(ch.name)) { + enabled.delete(ch.name); } else { - // Y or Enter — keep what's configured, nothing more to do - console.log(` ✓ Keeping: ${suggestions.join(", ")}`); - return; + enabled.add(ch.name); } - } else { - const answer = ( - await prompt(" Enable messaging channels? [y/N/list]: ") - ) - .trim() - .toLowerCase(); + showList(); + } - if (answer === "list") { - const picks = await prompt(" Enter channel names (comma-separated): "); - selected = picks - .split(",") - .map((s) => s.trim().toLowerCase()) - .filter(Boolean); - } else if (answer === "y" || answer === "yes") { - // Enable all - selected = MESSAGING_CHANNELS.map((c) => c.name); - } else { - console.log(" Skipping messaging channels."); - return; - } + 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 From 92e6807588cc9d16905123b4a8ee780572d3e00a Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 17:21:18 -0700 Subject: [PATCH 37/45] fix: use single-keypress toggle for messaging channel selection Press 1/2/3 to instantly toggle channels on/off without needing Enter after each selection. Press Enter only when done to continue. Uses raw mode stdin like the secret prompt does. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 82 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 63 insertions(+), 19 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 532d0c57e..6bdcbc0b7 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3171,40 +3171,84 @@ async function setupMessagingChannels() { return; } - // Numbered toggle selector — pre-select channels that already have tokens + // 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; const showList = () => { - console.log(""); - console.log(" Available messaging channels:"); + 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)" : ""; - console.log(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}`); + output.write(` [${i + 1}] ${marker} ${ch.name} — ${ch.description}${status}\n`); }); - console.log(""); + output.write("\n"); + output.write(" Press 1-3 to toggle, Enter when done: "); }; showList(); - while (true) { - const input = (await prompt(" Enter a number to toggle, or press Enter when done: ")).trim(); - if (input === "") break; - const num = parseInt(input, 10); - if (isNaN(num) || num < 1 || num > MESSAGING_CHANNELS.length) { - console.log(` Please enter a number between 1 and ${MESSAGING_CHANNELS.length}.`); - continue; + 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); + } } - const ch = MESSAGING_CHANNELS[num - 1]; - if (enabled.has(ch.name)) { - enabled.delete(ch.name); - } else { - enabled.add(ch.name); + + function finish() { + if (finished) return; + finished = true; + cleanup(); + output.write("\n"); + resolve(); } - showList(); - } + + 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) { From 0e67dbf5c86422b5a7f11ae761312e039a054413 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 17:26:27 -0700 Subject: [PATCH 38/45] fix: redraw messaging toggle list in place with ANSI escape codes Use cursor-up and clear-line sequences to overwrite the previous list on each toggle instead of appending a new copy below. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 6bdcbc0b7..90c16efbc 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3178,7 +3178,19 @@ async function setupMessagingChannels() { ); const output = process.stderr; + // Total lines drawn: 1 blank + 1 header + N channels + 1 blank + 1 prompt = N + 4 + const listLineCount = MESSAGING_CHANNELS.length + 4; + let firstDraw = true; const showList = () => { + if (!firstDraw) { + // Move cursor up to overwrite previous list, then clear each line + output.write(`\x1b[${listLineCount}A`); + for (let line = 0; line < listLineCount; line += 1) { + output.write("\x1b[2K\n"); + } + output.write(`\x1b[${listLineCount}A`); + } + firstDraw = false; output.write("\n"); output.write(" Available messaging channels:\n"); MESSAGING_CHANNELS.forEach((ch, i) => { From ee014e83165c6a04415e9d65bd2a49da6361e2c9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Fri, 3 Apr 2026 17:28:22 -0700 Subject: [PATCH 39/45] fix: clear screen below cursor on redraw to prevent prompt duplication Use \e[J (clear to end of screen) after cursor-up instead of line-by-line clearing which left duplicate prompt lines. Signed-off-by: Aaron Erickson --- bin/lib/onboard.js | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index b8bb9e63d..170da8d4b 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3191,17 +3191,13 @@ async function setupMessagingChannels() { ); const output = process.stderr; - // Total lines drawn: 1 blank + 1 header + N channels + 1 blank + 1 prompt = N + 4 - const listLineCount = MESSAGING_CHANNELS.length + 4; + // 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) { - // Move cursor up to overwrite previous list, then clear each line - output.write(`\x1b[${listLineCount}A`); - for (let line = 0; line < listLineCount; line += 1) { - output.write("\x1b[2K\n"); - } - output.write(`\x1b[${listLineCount}A`); + // 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"); From ac60cc74bcdada1fb0a54eba2edace96eec5f447 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 07:21:06 -0700 Subject: [PATCH 40/45] fix(onboard): bake messaging channels into openclaw.json at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In non-root mode, nemoclaw-start.sh couldn't patch the immutable openclaw.json to add Discord/Slack/Telegram as configured channels. Bake channel config (with placeholder tokens) into openclaw.json at image build time via NEMOCLAW_MESSAGING_CHANNELS_B64. The L7 proxy rewrites placeholders with real secrets at egress — no runtime config patching needed. Co-Authored-By: sayalinvidia Signed-off-by: Aaron Erickson --- Dockerfile | 11 ++++++++++- bin/lib/onboard.js | 17 +++++++++++++++++ scripts/nemoclaw-start.sh | 8 +++++--- 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index adef7f1d4..2c5430a6d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,10 @@ 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= # 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 +77,7 @@ 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_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} WORKDIR /sandbox @@ -94,6 +99,10 @@ 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')); \ +_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}}} 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 +120,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 78a3e7fc1..e9581d652 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1233,6 +1233,7 @@ function patchStagedDockerfile( provider = null, preferredInferenceApi = null, webSearchConfig = null, + messagingChannels = [], ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1276,6 +1277,12 @@ 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)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -2598,6 +2605,15 @@ 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); patchStagedDockerfile( stagedDockerfile, model, @@ -2606,6 +2622,7 @@ async function createSandbox( provider, preferredInferenceApi, webSearchConfig, + activeMessagingChannels, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index cd039e5c6..5d27352be 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -145,12 +145,14 @@ configure_messaging_channels() { # Real tokens are never visible inside the sandbox. # # Requires root: openclaw.json is owned by root with chmod 444. - # Non-root mode cannot patch the config — channels are unavailable. + # Non-root mode relies on channels being pre-baked into openclaw.json + # at build time via NEMOCLAW_MESSAGING_CHANNELS_B64. [ -n "${TELEGRAM_BOT_TOKEN:-}" ] || [ -n "${DISCORD_BOT_TOKEN:-}" ] || [ -n "${SLACK_BOT_TOKEN:-}" ] || return 0 if [ "$(id -u)" -ne 0 ]; then - echo "[channels] Messaging tokens detected but running as non-root — skipping openclaw.json patch" >&2 - echo "[channels] Channels still work via L7 proxy token rewriting (no config patch needed)" >&2 + echo "[channels] Messaging tokens detected (non-root mode)" >&2 + echo "[channels] Channel entries should be baked into openclaw.json at build time" >&2 + echo "[channels] (NEMOCLAW_MESSAGING_CHANNELS_B64). L7 proxy rewrites placeholder tokens at egress." >&2 return 0 fi From 366c51872b997dfd3211786ae38ada2436591e9f Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 08:13:09 -0700 Subject: [PATCH 41/45] fix(start): remove dead runtime openclaw.json patching from configure_messaging_channels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landlock enforces read-only on /sandbox/.openclaw/ at the kernel level, so runtime writes to openclaw.json fail with EPERM regardless of DAC permissions. Channel config is now baked at build time via NEMOCLAW_MESSAGING_CHANNELS_B64 — the runtime function only needs to log which channels are active. Addresses stevenrick's review finding on PR #1081. Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 85 +++++---------------------------------- 1 file changed, 9 insertions(+), 76 deletions(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 5d27352be..df0ed5653 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -138,85 +138,18 @@ PYAUTH } configure_messaging_channels() { - # If any messaging tokens are present (injected as placeholders by the - # OpenShell provider system), patch openclaw.json to enable the - # corresponding OpenClaw native channels. The placeholder values flow - # through to API calls where the L7 proxy swaps them for real secrets. - # Real tokens are never visible inside the sandbox. + # 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. # - # Requires root: openclaw.json is owned by root with chmod 444. - # Non-root mode relies on channels being pre-baked into openclaw.json - # at build time via NEMOCLAW_MESSAGING_CHANNELS_B64. + # 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 - if [ "$(id -u)" -ne 0 ]; then - echo "[channels] Messaging tokens detected (non-root mode)" >&2 - echo "[channels] Channel entries should be baked into openclaw.json at build time" >&2 - echo "[channels] (NEMOCLAW_MESSAGING_CHANNELS_B64). L7 proxy rewrites placeholder tokens at egress." >&2 - return 0 - fi - - local config_path="/sandbox/.openclaw/openclaw.json" - local hash_path="/sandbox/.openclaw/.config-hash" - - # Temporarily make config writable. Use a trap to guarantee restoration - # on early exit (set -e can bail before the manual chmod 444 below). - chmod 644 "$config_path" - chmod 644 "$hash_path" - trap 'chmod 444 "$config_path" "$hash_path" 2>/dev/null; chown root:root "$hash_path" 2>/dev/null' RETURN - - python3 - <<'PYCHANNELS' -import json, os - -config_path = '/sandbox/.openclaw/openclaw.json' -config = json.load(open(config_path)) - -channels = config.get('channels', {'defaults': {'configWrites': False}}) - -telegram_token = os.environ.get('TELEGRAM_BOT_TOKEN', '') -if telegram_token: - channels['telegram'] = { - 'accounts': { - 'main': { - 'botToken': telegram_token, - 'enabled': True, - } - } - } - -discord_token = os.environ.get('DISCORD_BOT_TOKEN', '') -if discord_token: - channels['discord'] = { - 'accounts': { - 'main': { - 'token': discord_token, - 'enabled': True, - } - } - } - -slack_token = os.environ.get('SLACK_BOT_TOKEN', '') -if slack_token: - channels['slack'] = { - 'accounts': { - 'main': { - 'botToken': slack_token, - 'enabled': True, - } - } - } - -config['channels'] = channels -json.dump(config, open(config_path, 'w'), indent=2) -os.chmod(config_path, 0o444) -PYCHANNELS - - # Recompute config hash after patching - (cd /sandbox/.openclaw && sha256sum openclaw.json >.config-hash) - chmod 444 "$hash_path" - chown root:root "$hash_path" - - echo "[channels] Messaging channels configured:" >&2 + 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 From e29bd43f64b97b20387b5b353785cbf3fab2eee9 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 09:02:26 -0700 Subject: [PATCH 42/45] fix(start): allow CLI clients in auto-pair watcher The auto-pair watcher rejected Telegram and other channel pairing requests that arrive as client=cli, mode=cli. Adding 'cli' to ALLOWED_MODES unblocks channel pairing and openclaw CLI commands (channels, status, tui) inside the sandbox. Closes #1310 Co-Authored-By: stevenrick Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index df0ed5653..966015172 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -208,7 +208,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) From 68de9fa6d22982c6780c129b71cadd316c41acdc Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 09:26:12 -0700 Subject: [PATCH 43/45] feat(onboard): collect Telegram user ID for DM allowlisting Prompt for the user's Telegram numeric ID during onboard and bake it into openclaw.json at build time via NEMOCLAW_MESSAGING_ALLOWED_IDS_B64. Channels with allowed IDs get dmPolicy=allowlist so the bot responds to DMs immediately without requiring manual pairing. Without this, native OpenClaw channels default to dmPolicy=pairing and silently drop all messages from unpaired users. Signed-off-by: Aaron Erickson --- Dockerfile | 8 +++++- bin/lib/onboard.js | 61 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2c5430a6d..8b44f708c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -59,6 +59,10 @@ ARG NEMOCLAW_WEB_CONFIG_B64=e30= # (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 @@ -78,6 +82,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ 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 @@ -100,9 +105,10 @@ 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}}} for ch in msg_channels if ch in _token_keys}; \ +_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']; \ diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 59ea6bc57..71263d020 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1235,6 +1235,7 @@ function patchStagedDockerfile( preferredInferenceApi = null, webSearchConfig = null, messagingChannels = [], + messagingAllowedIds = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1284,6 +1285,12 @@ function patchStagedDockerfile( `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); } @@ -2635,6 +2642,19 @@ async function createSandbox( 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, @@ -2644,6 +2664,7 @@ async function createSandbox( preferredInferenceApi, webSearchConfig, activeMessagingChannels, + messagingAllowedIds, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -3515,6 +3536,9 @@ const MESSAGING_CHANNELS = [ 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", @@ -3651,17 +3675,34 @@ async function setupMessagingChannels() { } if (getMessagingToken(ch.envKey)) { console.log(` ✓ ${ch.name} — already configured`); - continue; - } - 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)`); + 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(""); From 04dc48d6e6ed4703546b4dc0ce85882e70692a8e Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 09:51:40 -0700 Subject: [PATCH 44/45] test(e2e): verify DM allowlisting in messaging providers test Add M11b/M11c checks to Phase 3: verify Telegram dmPolicy is 'allowlist' and allowFrom contains the expected user IDs baked at build time. Export TELEGRAM_ALLOWED_IDS (defaults to fake ID) so the non-interactive onboard flow bakes it into the image. Signed-off-by: Aaron Erickson --- test/e2e/test-messaging-providers.sh | 45 ++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index ba5920096..6b0103294 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -37,6 +37,7 @@ # 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 @@ -90,8 +91,10 @@ 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() { @@ -364,6 +367,48 @@ print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('enabled', Fa 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 # ══════════════════════════════════════════════════════════════════ From de910ea36dfad2043c8a858c2816a0ae875db3c8 Mon Sep 17 00:00:00 2001 From: Aaron Erickson Date: Sun, 5 Apr 2026 10:04:41 -0700 Subject: [PATCH 45/45] fix(start): add explicit return 0 to configure_messaging_channels The last line ([ -n SLACK_BOT_TOKEN ] && echo ...) returns exit 1 when SLACK_BOT_TOKEN is unset, causing the function to return non-zero. This propagates through the SSH session that openshell sandbox create monitors, breaking sandbox creation when only Telegram/Discord tokens are configured. Signed-off-by: Aaron Erickson --- scripts/nemoclaw-start.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/nemoclaw-start.sh b/scripts/nemoclaw-start.sh index 966015172..823cc5119 100755 --- a/scripts/nemoclaw-start.sh +++ b/scripts/nemoclaw-start.sh @@ -153,6 +153,7 @@ configure_messaging_channels() { [ -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() {