From 8c2f4b15b7e457ff2083e30374280af8af75bb87 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Wed, 8 Apr 2026 21:59:07 -0400 Subject: [PATCH 01/12] fix(discord): tighten native messaging policies and diagnostics --- .../references/troubleshooting.md | 39 ++++++++++ bin/lib/onboard.js | 65 ++++++++++------ docs/reference/troubleshooting.md | 39 ++++++++++ .../policies/presets/discord.yaml | 1 + .../policies/presets/slack.yaml | 1 + .../policies/presets/telegram.yaml | 1 + test/e2e/test-messaging-providers.sh | 77 ++++++++++++++++++- test/onboard.test.js | 25 ++++++ test/policies.test.js | 8 ++ 9 files changed, 230 insertions(+), 26 deletions(-) diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 5ec77a265..30c65e8e0 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -240,6 +240,45 @@ Changing or exporting it later does not rewrite the baked `openclaw.json` inside If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. For the security trade-offs, refer to Security Best Practices (see the `nemoclaw-user-configure-security` skill). +### `openclaw doctor --fix` cannot repair Discord channel config inside the sandbox + +This is expected in NemoClaw-managed sandboxes. +NemoClaw bakes channel entries into `/sandbox/.openclaw/openclaw.json` at image build time, and OpenShell keeps that path read-only at runtime. + +As a result, commands that try to rewrite the baked config from inside the sandbox, including `openclaw doctor --fix`, cannot repair Discord, Telegram, or Slack channel entries in place. + +If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. +Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. + +### Discord bot logs in, but the channel still does not work + +Separate the problem into two parts: + +1. **Baked config and provider wiring** + + Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. + If Discord was skipped during onboarding, rerun onboarding and select Discord again. + +1. **Native Discord gateway path** + + Successful login alone does not prove that Discord works end to end. + Discord also needs a working gateway connection to `gateway.discord.gg`. + If logs show errors such as `getaddrinfo EAI_AGAIN gateway.discord.gg`, repeated reconnect loops, or a `400` response while probing the gateway path, the problem is usually in the native gateway/proxy path rather than in the baked config. + +Common signs of a native gateway-path failure: + +- REST calls to `discord.com` succeed, but the Discord channel never becomes healthy +- `gateway.discord.gg` fails with DNS resolution errors +- the WebSocket path returns `400` instead of opening a tunnel +- native command deployment fails even though the bot token itself is valid + +In that case: + +- keep the Discord policy preset applied +- verify the sandbox was created with the Discord provider attached +- inspect gateway logs and blocked requests with `openshell term` +- treat the failure as a native Discord gateway problem, not as a bridge startup problem + ### Agent cannot reach an external host OpenShell blocks outbound connections to hosts not listed in the network policy. diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 854748e33..44f5fd646 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3517,6 +3517,30 @@ async function setupMessagingChannels() { return selected; } +function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = null } = {}) { + const suggestions = ["pypi", "npm"]; + const usesExplicitMessagingSelection = Array.isArray(enabledChannels); + + const maybeSuggestMessagingPreset = (channel, envKey) => { + if (usesExplicitMessagingSelection) { + if (enabledChannels.includes(channel)) suggestions.push(channel); + return; + } + if (getCredential(envKey) || process.env[envKey]) { + suggestions.push(channel); + console.log(` Auto-detected: ${envKey} -> suggesting ${channel} preset`); + } + }; + + maybeSuggestMessagingPreset("telegram", "TELEGRAM_BOT_TOKEN"); + maybeSuggestMessagingPreset("slack", "SLACK_BOT_TOKEN"); + maybeSuggestMessagingPreset("discord", "DISCORD_BOT_TOKEN"); + + if (webSearchConfig) suggestions.push("brave"); + + return suggestions; +} + // ── Step 7: OpenClaw ───────────────────────────────────────────── async function setupOpenclaw(sandboxName, model, provider) { @@ -3546,24 +3570,9 @@ async function setupOpenclaw(sandboxName, model, provider) { // ── Step 7: Policy presets ─────────────────────────────────────── // eslint-disable-next-line complexity -async function _setupPolicies(sandboxName) { +async function _setupPolicies(sandboxName, options = {}) { step(8, 8, "Policy presets"); - - const suggestions = ["pypi", "npm"]; - - // Auto-detect based on env tokens - if (getCredential("TELEGRAM_BOT_TOKEN")) { - suggestions.push("telegram"); - console.log(" Auto-detected: TELEGRAM_BOT_TOKEN → suggesting telegram preset"); - } - if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) { - suggestions.push("slack"); - console.log(" Auto-detected: SLACK_BOT_TOKEN → suggesting slack preset"); - } - if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) { - suggestions.push("discord"); - console.log(" Auto-detected: DISCORD_BOT_TOKEN → suggesting discord preset"); - } + const suggestions = getSuggestedPolicyPresets(options); const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -3824,15 +3833,11 @@ async function setupPoliciesWithSelection(sandboxName, options = {}) { const selectedPresets = Array.isArray(options.selectedPresets) ? options.selectedPresets : null; const onSelection = typeof options.onSelection === "function" ? options.onSelection : null; const webSearchConfig = options.webSearchConfig || null; + const enabledChannels = Array.isArray(options.enabledChannels) ? options.enabledChannels : null; step(8, 8, "Policy presets"); - const suggestions = ["pypi", "npm"]; - if (getCredential("TELEGRAM_BOT_TOKEN")) suggestions.push("telegram"); - if (getCredential("SLACK_BOT_TOKEN") || process.env.SLACK_BOT_TOKEN) suggestions.push("slack"); - if (getCredential("DISCORD_BOT_TOKEN") || process.env.DISCORD_BOT_TOKEN) - suggestions.push("discord"); - if (webSearchConfig) suggestions.push("brave"); + const suggestions = getSuggestedPolicyPresets({ enabledChannels, webSearchConfig }); const allPresets = policies.listPresets(); const applied = policies.getAppliedPresets(sandboxName); @@ -4379,6 +4384,10 @@ async function onboard(opts = {}) { }); } + let selectedMessagingChannels = Array.isArray(session?.messagingChannels) + ? session.messagingChannels + : null; + const sandboxReuseState = getSandboxReuseState(sandboxName); const resumeSandbox = resume && session?.steps?.sandbox?.status === "complete" && sandboxReuseState === "ready"; @@ -4398,7 +4407,11 @@ async function onboard(opts = {}) { } } } - const enabledChannels = await setupMessagingChannels(); + selectedMessagingChannels = await setupMessagingChannels(); + onboardSession.updateSession((current) => { + current.messagingChannels = selectedMessagingChannels; + return current; + }); startRecordedStep("sandbox", { sandboxName, provider, model }); sandboxName = await createSandbox( @@ -4408,7 +4421,7 @@ async function onboard(opts = {}) { preferredInferenceApi, sandboxName, webSearchConfig, - enabledChannels, + selectedMessagingChannels, fromDockerfile, ); onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer }); @@ -4452,6 +4465,7 @@ async function onboard(opts = {}) { recordedPolicyPresets.length > 0 ? recordedPolicyPresets : null, + enabledChannels: selectedMessagingChannels, webSearchConfig, onSelection: (policyPresets) => { onboardSession.updateSession((current) => { @@ -4524,6 +4538,7 @@ module.exports = { isInferenceRouteReady, isOpenclawReady, arePolicyPresetsApplied, + getSuggestedPolicyPresets, presetsCheckboxSelector, setupPoliciesWithSelection, summarizeCurlFailure, diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 1d91444f9..bc575b150 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -272,6 +272,45 @@ Changing or exporting it later does not rewrite the baked `openclaw.json` inside If you need a different device-auth setting, rerun onboarding so NemoClaw rebuilds the sandbox image with the desired configuration. For the security trade-offs, refer to [Security Best Practices](../security/best-practices.md). +### `openclaw doctor --fix` cannot repair Discord channel config inside the sandbox + +This is expected in NemoClaw-managed sandboxes. +NemoClaw bakes channel entries into `/sandbox/.openclaw/openclaw.json` at image build time, and OpenShell keeps that path read-only at runtime. + +As a result, commands that try to rewrite the baked config from inside the sandbox, including `openclaw doctor --fix`, cannot repair Discord, Telegram, or Slack channel entries in place. + +If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. +Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. + +### Discord bot logs in, but the channel still does not work + +Separate the problem into two parts: + +1. **Baked config and provider wiring** + + Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. + If Discord was skipped during onboarding, rerun onboarding and select Discord again. + +1. **Native Discord gateway path** + + Successful login alone does not prove that Discord works end to end. + Discord also needs a working gateway connection to `gateway.discord.gg`. + If logs show errors such as `getaddrinfo EAI_AGAIN gateway.discord.gg`, repeated reconnect loops, or a `400` response while probing the gateway path, the problem is usually in the native gateway/proxy path rather than in the baked config. + +Common signs of a native gateway-path failure: + +- REST calls to `discord.com` succeed, but the Discord channel never becomes healthy +- `gateway.discord.gg` fails with DNS resolution errors +- the WebSocket path returns `400` instead of opening a tunnel +- native command deployment fails even though the bot token itself is valid + +In that case: + +- keep the Discord policy preset applied +- verify the sandbox was created with the Discord provider attached +- inspect gateway logs and blocked requests with `openshell term` +- treat the failure as a native Discord gateway problem, not as a bridge startup problem + ### Agent cannot reach an external host OpenShell blocks outbound connections to hosts not listed in the network policy. diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index 42a0dd8dd..b67cbd23f 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -47,3 +47,4 @@ network_policies: - allow: { method: GET, path: "/**" } binaries: - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/nemoclaw-blueprint/policies/presets/slack.yaml index e2a7c4706..6af9ab026 100644 --- a/nemoclaw-blueprint/policies/presets/slack.yaml +++ b/nemoclaw-blueprint/policies/presets/slack.yaml @@ -43,3 +43,4 @@ network_policies: access: full binaries: - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/nemoclaw-blueprint/policies/presets/telegram.yaml index 0e733043b..02e90f91a 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -20,3 +20,4 @@ network_policies: - allow: { method: GET, path: "/file/bot*/**" } binaries: - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 6b0103294..8b5d7044e 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -17,7 +17,8 @@ # 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 +# 6. Native Discord gateway path — WebSocket path is probed separately from REST +# 7. 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 @@ -41,6 +42,7 @@ # 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 +# NEMOCLAW_E2E_STRICT_DISCORD_GATEWAY — fail instead of skip on known Discord gateway blockers # # Usage: # NEMOCLAW_NON_INTERACTIVE=1 NEMOCLAW_ACCEPT_THIRD_PARTY_SOFTWARE=1 \ @@ -137,6 +139,7 @@ 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" +STRICT_DISCORD_GATEWAY="${NEMOCLAW_E2E_STRICT_DISCORD_GATEWAY:-0}" # ══════════════════════════════════════════════════════════════════ # Phase 1: Install NemoClaw (non-interactive mode) @@ -454,6 +457,78 @@ else fail "M13: Node.js could not reach discord.com (${dc_reach:0:200})" fi +# M13b: Probe the native Discord gateway path separately from REST. +# This catches failures where REST succeeds but the WebSocket path still fails +# (for example EAI_AGAIN on gateway.discord.gg or proxy misuse returning 400). +dc_gateway=$(sandbox_exec 'node -e " +const url = \"wss://gateway.discord.gg/?v=10&encoding=json\"; +if (typeof WebSocket !== \"function\") { + console.log(\"UNSUPPORTED WebSocket\"); + process.exit(0); +} +const ws = new WebSocket(url); +const done = (msg) => { + console.log(msg); + try { ws.close(); } catch {} + setTimeout(() => process.exit(0), 50); +}; +const timer = setTimeout(() => done(\"TIMEOUT\"), 15000); +ws.addEventListener(\"open\", () => console.log(\"OPEN\")); +ws.addEventListener(\"message\", (event) => { + clearTimeout(timer); + const body = String(event.data || \"\").slice(0, 200).replace(/\\s+/g, \" \"); + done(\"MESSAGE \" + body); +}); +ws.addEventListener(\"error\", (event) => { + clearTimeout(timer); + const msg = event?.message || event?.error?.message || \"websocket_error\"; + done(\"ERROR \" + msg); +}); +ws.addEventListener(\"close\", (event) => { + if (event.code && event.code !== 1000) console.log(\"CLOSE \" + event.code); +}); +"' 2>/dev/null || true) + +info "Discord gateway probe: ${dc_gateway:0:300}" + +if echo "$dc_gateway" | grep -q "MESSAGE "; then + pass "M13b: Native Discord gateway returned a WebSocket message" +elif echo "$dc_gateway" | grep -q "OPEN"; then + pass "M13b: Native Discord gateway opened a WebSocket session" +elif echo "$dc_gateway" | grep -qiE "EAI_AGAIN|getaddrinfo"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway hit DNS resolution failure (${dc_gateway:0:200})" + else + skip "M13b: Native Discord gateway hit DNS resolution failure (${dc_gateway:0:200})" + fi +elif echo "$dc_gateway" | grep -q "400"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway probe returned 400 (${dc_gateway:0:200})" + else + skip "M13b: Native Discord gateway probe returned 400 (${dc_gateway:0:200})" + fi +elif echo "$dc_gateway" | grep -q "UNSUPPORTED"; then + skip "M13b: WebSocket runtime unsupported in sandbox Node.js" +elif echo "$dc_gateway" | grep -q "TIMEOUT"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway probe timed out" + else + skip "M13b: Native Discord gateway probe timed out" + fi +elif echo "$dc_gateway" | grep -q "ERROR"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway probe failed (${dc_gateway:0:200})" + else + skip "M13b: Native Discord gateway probe failed (${dc_gateway:0:200})" + fi +else + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway probe returned an unclassified result (${dc_gateway:0:200})" + else + skip "M13b: Native Discord gateway probe returned an unclassified result (${dc_gateway:0:200})" + fi +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 diff --git a/test/onboard.test.js b/test/onboard.test.js index f4e2bf16a..7f0afa791 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -25,6 +25,7 @@ import { getRequestedModelHint, getRequestedProviderHint, getRequestedSandboxNameHint, + getSuggestedPolicyPresets, getResumeConfigConflicts, getResumeSandboxConflict, getSandboxStateFromOutputs, @@ -102,6 +103,30 @@ describe("onboard helpers", () => { assert.match(script, /^exit$/m); }); + it("uses explicit messaging selections for policy suggestions when provided", () => { + process.env.TELEGRAM_BOT_TOKEN = "telegram-token"; + process.env.DISCORD_BOT_TOKEN = "discord-token"; + process.env.SLACK_BOT_TOKEN = "slack-token"; + try { + expect(getSuggestedPolicyPresets({ enabledChannels: [] })).toEqual(["pypi", "npm"]); + expect(getSuggestedPolicyPresets({ enabledChannels: ["telegram"] })).toEqual([ + "pypi", + "npm", + "telegram", + ]); + expect(getSuggestedPolicyPresets({ enabledChannels: ["discord", "slack"] })).toEqual([ + "pypi", + "npm", + "slack", + "discord", + ]); + } finally { + delete process.env.TELEGRAM_BOT_TOKEN; + delete process.env.DISCORD_BOT_TOKEN; + delete process.env.SLACK_BOT_TOKEN; + } + }); + it("patches the staged Dockerfile with the selected model and chat UI URL", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-")); const dockerfilePath = path.join(tmpDir, "Dockerfile"); diff --git a/test/policies.test.js b/test/policies.test.js index d00ea8cbd..60077984c 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -143,6 +143,14 @@ describe("policies", () => { expect(policies.loadPreset("../../etc/passwd")).toBe(null); expect(policies.loadPreset("../../../etc/shadow")).toBe(null); }); + + it("includes /usr/bin/node in communication presets", () => { + for (const preset of ["discord", "slack", "telegram"]) { + const content = policies.loadPreset(preset); + expect(content).toContain("/usr/local/bin/node"); + expect(content).toContain("/usr/bin/node"); + } + }); }); describe("getPresetEndpoints", () => { From 2c196a66d68f16e9e5a6f154763c1b47f5cde652 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Wed, 8 Apr 2026 22:17:39 -0400 Subject: [PATCH 02/12] fix(discord): bake default account config --- Dockerfile | 2 +- test/e2e/test-messaging-providers.sh | 24 ++++++++++++++++++------ 2 files changed, 19 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index e3aa94ede..c748921d1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -122,7 +122,7 @@ msg_channels = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_CH _allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_ALLOWED_IDS_B64', 'e30=') or 'e30=').decode('utf-8')); \ _token_keys = {'discord': 'token', 'telegram': 'botToken', 'slack': 'botToken'}; \ _env_keys = {'discord': 'DISCORD_BOT_TOKEN', 'telegram': 'TELEGRAM_BOT_TOKEN', 'slack': 'SLACK_BOT_TOKEN'}; \ -_ch_cfg = {ch: {'accounts': {'main': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'dmPolicy': 'allowlist', 'allowFrom': _allowed_ids[ch]} if ch in _allowed_ids and _allowed_ids[ch] else {})}}} for ch in msg_channels if ch in _token_keys}; \ +_ch_cfg = {ch: {'accounts': {'default': {_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/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 8b5d7044e..14e9b2baf 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -305,7 +305,9 @@ else 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', '')) +accounts = d.get('telegram', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('botToken', '')) " 2>/dev/null || true) if [ -n "$tg_token" ]; then @@ -327,7 +329,9 @@ print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('botToken', 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', '')) +accounts = d.get('discord', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('token', '')) " 2>/dev/null || true) if [ -n "$dc_token" ]; then @@ -349,7 +353,9 @@ print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('token', '')) 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)) +accounts = d.get('telegram', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('enabled', False)) " 2>/dev/null || true) if [ "$tg_enabled" = "True" ]; then @@ -362,7 +368,9 @@ print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('enabled', F 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)) +accounts = d.get('discord', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('enabled', False)) " 2>/dev/null || true) if [ "$dc_enabled" = "True" ]; then @@ -375,7 +383,9 @@ print(d.get('discord', {}).get('accounts', {}).get('main', {}).get('enabled', Fa 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', '')) +accounts = d.get('telegram', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('dmPolicy', '')) " 2>/dev/null || true) if [ "$tg_dm_policy" = "allowlist" ]; then @@ -390,7 +400,9 @@ print(d.get('telegram', {}).get('accounts', {}).get('main', {}).get('dmPolicy', 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', []) +accounts = d.get('telegram', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +ids = account.get('allowFrom', []) print(','.join(str(i) for i in ids)) " 2>/dev/null || true) From b274324afb97ee5a4bd34ae1f2686c23a49be349 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Wed, 8 Apr 2026 22:58:47 -0400 Subject: [PATCH 03/12] fix(discord): bake guild workspace config --- Dockerfile | 7 +++++ bin/lib/onboard.js | 66 ++++++++++++++++++++++++++++++++++++++++++-- test/onboard.test.js | 56 +++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c748921d1..418fe757e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -69,6 +69,10 @@ ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10= # (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= +# Base64-encoded JSON map of Discord guild configs keyed by server ID +# (e.g. {"1234567890":{"requireMention":true,"users":["555"]}}). +# Used to enable guild-channel responses for native Discord. Default: empty map. +ARG NEMOCLAW_DISCORD_GUILDS_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 @@ -95,6 +99,7 @@ ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \ 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_DISCORD_GUILDS_B64=${NEMOCLAW_DISCORD_GUILDS_B64} \ NEMOCLAW_DISABLE_DEVICE_AUTH=${NEMOCLAW_DISABLE_DEVICE_AUTH} \ NEMOCLAW_PROXY_HOST=${NEMOCLAW_PROXY_HOST} \ NEMOCLAW_PROXY_PORT=${NEMOCLAW_PROXY_PORT} @@ -120,9 +125,11 @@ inference_compat = json.loads(base64.b64decode(os.environ['NEMOCLAW_INFERENCE_CO 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')); \ +_discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_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': {'default': {_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}; \ +_ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \ 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 44f5fd646..63517be6a 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -960,6 +960,7 @@ function patchStagedDockerfile( webSearchConfig = null, messagingChannels = [], messagingAllowedIds = {}, + discordGuilds = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1035,6 +1036,12 @@ function patchStagedDockerfile( `ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=${encodeDockerJsonArg(messagingAllowedIds)}`, ); } + if (Object.keys(discordGuilds).length > 0) { + dockerfile = dockerfile.replace( + /^ARG NEMOCLAW_DISCORD_GUILDS_B64=.*$/m, + `ARG NEMOCLAW_DISCORD_GUILDS_B64=${encodeDockerJsonArg(discordGuilds)}`, + ); + } fs.writeFileSync(dockerfilePath, dockerfile); } @@ -2409,7 +2416,12 @@ async function createSandbox( const messagingAllowedIds = {}; const enabledTokenEnvKeys = new Set(messagingTokenDefs.map(({ envKey }) => envKey)); for (const ch of MESSAGING_CHANNELS) { - if (enabledTokenEnvKeys.has(ch.envKey) && ch.userIdEnvKey && process.env[ch.userIdEnvKey]) { + if ( + enabledTokenEnvKeys.has(ch.envKey) && + ch.allowIdsMode === "dm" && + ch.userIdEnvKey && + process.env[ch.userIdEnvKey] + ) { const ids = process.env[ch.userIdEnvKey] .split(",") .map((s) => s.trim()) @@ -2417,6 +2429,24 @@ async function createSandbox( if (ids.length > 0) messagingAllowedIds[ch.name] = ids; } } + const discordGuilds = {}; + if (enabledTokenEnvKeys.has("DISCORD_BOT_TOKEN")) { + const serverIds = (process.env.DISCORD_SERVER_IDS || process.env.DISCORD_SERVER_ID || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const userIds = (process.env.DISCORD_ALLOWED_IDS || process.env.DISCORD_USER_ID || "") + .split(",") + .map((s) => s.trim()) + .filter(Boolean); + const requireMention = process.env.DISCORD_REQUIRE_MENTION !== "0"; + for (const serverId of serverIds) { + discordGuilds[serverId] = { + requireMention, + ...(userIds.length > 0 ? { users: userIds } : {}), + }; + } + } patchStagedDockerfile( stagedDockerfile, model, @@ -2427,6 +2457,7 @@ async function createSandbox( webSearchConfig, activeMessagingChannels, messagingAllowedIds, + discordGuilds, ); // Only pass non-sensitive env vars to the sandbox. Credentials flow through // OpenShell providers — the gateway injects them as placeholders and the L7 @@ -3347,6 +3378,7 @@ const MESSAGING_CHANNELS = [ userIdEnvKey: "TELEGRAM_ALLOWED_IDS", userIdHelp: "Send /start to @userinfobot on Telegram to get your numeric user ID.", userIdLabel: "Telegram User ID (for DM access)", + allowIdsMode: "dm", }, { name: "discord", @@ -3354,6 +3386,15 @@ const MESSAGING_CHANNELS = [ description: "Discord bot messaging", help: "Discord Developer Portal → Applications → Bot → Reset/Copy Token.", label: "Discord Bot Token", + serverIdEnvKey: "DISCORD_SERVER_ID", + serverIdHelp: + "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", + serverIdLabel: "Discord Server ID (for guild workspace access)", + userIdEnvKey: "DISCORD_USER_ID", + userIdHelp: + "Enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID.", + userIdLabel: "Discord User ID (for guild allowlist)", + allowIdsMode: "guild", }, { name: "slack", @@ -3496,7 +3537,22 @@ async function setupMessagingChannels() { continue; } } - // Prompt for user/sender ID if the channel supports DM allowlisting + if (ch.serverIdEnvKey) { + const existingServerIds = process.env[ch.serverIdEnvKey] || ""; + if (existingServerIds) { + console.log(` ✓ ${ch.name} — server ID already set: ${existingServerIds}`); + } else { + console.log(` ${ch.serverIdHelp}`); + const serverId = (await prompt(` ${ch.serverIdLabel}: `)).trim(); + if (serverId) { + process.env[ch.serverIdEnvKey] = serverId; + console.log(` ✓ ${ch.name} server ID saved`); + } else { + console.log(` Skipped ${ch.name} server ID (guild channels stay disabled)`); + } + } + } + // Prompt for user/sender ID when the channel supports allowlisting if (ch.userIdEnvKey) { const existingIds = process.env[ch.userIdEnvKey] || ""; if (existingIds) { @@ -3508,7 +3564,11 @@ async function setupMessagingChannels() { 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)`); + const skippedReason = + ch.allowIdsMode === "guild" + ? "guild access stays restricted until you configure it later" + : "bot will require manual pairing"; + console.log(` Skipped ${ch.name} user ID (${skippedReason})`); } } } diff --git a/test/onboard.test.js b/test/onboard.test.js index 7f0afa791..b6c24dde0 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -162,6 +162,62 @@ describe("onboard helpers", () => { } }); + it("patches the staged Dockerfile with Discord guild config for server workspaces", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-discord-")); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-discord-guild", + "openai-api", + null, + null, + ["discord"], + {}, + { + "1491590992753590594": { + requireMention: true, + users: ["1005536447329222676"], + }, + }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + assert.match(patched, /^ARG NEMOCLAW_MESSAGING_CHANNELS_B64=/m); + const guildLine = patched + .split("\n") + .find((line) => line.startsWith("ARG NEMOCLAW_DISCORD_GUILDS_B64=")); + assert.ok(guildLine, "expected discord guild build arg"); + const encoded = guildLine.split("=")[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded, { + "1491590992753590594": { + requireMention: true, + users: ["1005536447329222676"], + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps NVIDIA Endpoints to the routed inference provider", () => { assert.deepEqual( getSandboxInferenceConfig("qwen/qwen3.5-397b-a17b", "nvidia-prod", "openai-completions"), From 49ba96e137186c56221244be3ca0cceaca38e070 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Wed, 8 Apr 2026 23:10:46 -0400 Subject: [PATCH 04/12] docs(onboard): fix brave search link --- .agents/skills/nemoclaw-user-reference/references/commands.md | 4 ++++ bin/lib/onboard.js | 2 +- docs/reference/commands.md | 4 ++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 814cd8326..763181b2e 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -82,6 +82,10 @@ The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. Uppercase letters are automatically lowercased. +If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID and User ID. +NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. +Guild responses remain mention-gated by default. + Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on untested runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 63517be6a..2be6c0f1a 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -108,7 +108,7 @@ const BUILD_ENDPOINT_URL = "https://integrate.api.nvidia.com/v1"; const OPENAI_ENDPOINT_URL = "https://api.openai.com/v1"; const ANTHROPIC_ENDPOINT_URL = "https://api.anthropic.com"; const GEMINI_ENDPOINT_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"; -const BRAVE_SEARCH_HELP_URL = "https://api-dashboard.search.brave.com/app/keys"; +const BRAVE_SEARCH_HELP_URL = "https://brave.com/search/api/"; const REMOTE_PROVIDER_CONFIG = { build: { diff --git a/docs/reference/commands.md b/docs/reference/commands.md index aafde7699..2055fb5d0 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -106,6 +106,10 @@ The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. Uppercase letters are automatically lowercased. +If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID and User ID. +NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. +Guild responses remain mention-gated by default. + Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on untested runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. From fbaf3724deb67df40674abb798b3b1127f9e2e79 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Wed, 8 Apr 2026 23:41:52 -0400 Subject: [PATCH 05/12] feat(discord): add guild reply mode prompt --- .../references/commands.md | 5 +- bin/lib/onboard.js | 28 ++++++++-- docs/reference/commands.md | 5 +- test/onboard.test.js | 55 +++++++++++++++++++ 4 files changed, 85 insertions(+), 8 deletions(-) diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index 763181b2e..9b1eb917d 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -82,9 +82,10 @@ The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. Uppercase letters are automatically lowercased. -If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID and User ID. +If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. -Guild responses remain mention-gated by default. +If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. +Guild responses remain mention-gated by default unless you opt into all-message replies. Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on untested runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 2be6c0f1a..48cd549bc 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -3390,10 +3390,13 @@ const MESSAGING_CHANNELS = [ serverIdHelp: "Enable Developer Mode in Discord, then right-click your server and copy the Server ID.", serverIdLabel: "Discord Server ID (for guild workspace access)", + requireMentionEnvKey: "DISCORD_REQUIRE_MENTION", + requireMentionHelp: + "Choose whether the bot should reply only when @mentioned or to all messages in this server.", userIdEnvKey: "DISCORD_USER_ID", userIdHelp: - "Enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID.", - userIdLabel: "Discord User ID (for guild allowlist)", + "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of this server to message the bot.", + userIdLabel: "Discord User ID (optional guild allowlist)", allowIdsMode: "guild", }, { @@ -3552,8 +3555,25 @@ async function setupMessagingChannels() { } } } + if (ch.requireMentionEnvKey && process.env[ch.serverIdEnvKey || ""]) { + const existingRequireMention = process.env[ch.requireMentionEnvKey]; + if (existingRequireMention === "0" || existingRequireMention === "1") { + const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; + console.log(` ✓ ${ch.name} — reply mode already set: ${mode}`); + } else { + console.log(` ${ch.requireMentionHelp}`); + const answer = ( + await prompt(" Reply only when @mentioned? [Y/n]: ") + ) + .trim() + .toLowerCase(); + process.env[ch.requireMentionEnvKey] = answer === "n" || answer === "no" ? "0" : "1"; + const mode = process.env[ch.requireMentionEnvKey] === "0" ? "all messages" : "@mentions only"; + console.log(` ✓ ${ch.name} reply mode saved: ${mode}`); + } + } // Prompt for user/sender ID when the channel supports allowlisting - if (ch.userIdEnvKey) { + if (ch.userIdEnvKey && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { const existingIds = process.env[ch.userIdEnvKey] || ""; if (existingIds) { console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); @@ -3566,7 +3586,7 @@ async function setupMessagingChannels() { } else { const skippedReason = ch.allowIdsMode === "guild" - ? "guild access stays restricted until you configure it later" + ? "any member in the configured server can message the bot" : "bot will require manual pairing"; console.log(` Skipped ${ch.name} user ID (${skippedReason})`); } diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 2055fb5d0..778e13568 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -106,9 +106,10 @@ The wizard prompts for a sandbox name. Names must follow RFC 1123 subdomain rules: lowercase alphanumeric characters and hyphens only, and must start and end with an alphanumeric character. Uppercase letters are automatically lowercased. -If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID and User ID. +If you enable Discord during onboarding, the wizard can also prompt for a Discord Server ID, whether the bot should reply only to `@mentions` or to all messages in that server, and an optional Discord User ID. NemoClaw bakes those values into the sandbox image as Discord guild workspace config so the bot can respond in the selected server, not just in DMs. -Guild responses remain mention-gated by default. +If you leave the Discord User ID blank, the guild config omits the user allowlist and any member of the configured server can message the bot. +Guild responses remain mention-gated by default unless you opt into all-message replies. Before creating the gateway, the wizard runs preflight checks. It verifies that Docker is reachable, warns on untested runtimes such as Podman, and prints host remediation guidance when prerequisites are missing. diff --git a/test/onboard.test.js b/test/onboard.test.js index b6c24dde0..033a7cf33 100644 --- a/test/onboard.test.js +++ b/test/onboard.test.js @@ -218,6 +218,61 @@ describe("onboard helpers", () => { } }); + it("patches the staged Dockerfile with Discord guild config that allows all server members", () => { + const tmpDir = fs.mkdtempSync( + path.join(os.tmpdir(), "nemoclaw-onboard-dockerfile-discord-open-"), + ); + const dockerfilePath = path.join(tmpDir, "Dockerfile"); + fs.writeFileSync( + dockerfilePath, + [ + "ARG NEMOCLAW_MODEL=nvidia/nemotron-3-super-120b-a12b", + "ARG NEMOCLAW_PROVIDER_KEY=nvidia", + "ARG NEMOCLAW_PRIMARY_MODEL_REF=nvidia/nemotron-3-super-120b-a12b", + "ARG CHAT_UI_URL=http://127.0.0.1:18789", + "ARG NEMOCLAW_INFERENCE_COMPAT_B64=e30=", + "ARG NEMOCLAW_WEB_CONFIG_B64=e30=", + "ARG NEMOCLAW_MESSAGING_CHANNELS_B64=W10=", + "ARG NEMOCLAW_MESSAGING_ALLOWED_IDS_B64=e30=", + "ARG NEMOCLAW_DISCORD_GUILDS_B64=e30=", + "ARG NEMOCLAW_BUILD_ID=default", + ].join("\n"), + ); + + try { + patchStagedDockerfile( + dockerfilePath, + "gpt-5.4", + "http://127.0.0.1:19999", + "build-discord-open", + "openai-api", + null, + null, + ["discord"], + {}, + { + "1491590992753590594": { + requireMention: false, + }, + }, + ); + const patched = fs.readFileSync(dockerfilePath, "utf8"); + const guildLine = patched + .split("\n") + .find((line) => line.startsWith("ARG NEMOCLAW_DISCORD_GUILDS_B64=")); + assert.ok(guildLine, "expected discord guild build arg"); + const encoded = guildLine.split("=")[1]; + const decoded = JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); + assert.deepEqual(decoded, { + "1491590992753590594": { + requireMention: false, + }, + }); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("maps NVIDIA Endpoints to the routed inference provider", () => { assert.deepEqual( getSandboxInferenceConfig("qwen/qwen3.5-397b-a17b", "nvidia-prod", "openai-completions"), From b25aba53b170048c5ae0ad66e2cec977d5732bde Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 14:07:54 -0400 Subject: [PATCH 06/12] fix(telegram): align baked channel config with doctor --- .../skills/nemoclaw-user-deploy-remote/SKILL.md | 1 + .../references/troubleshooting.md | 3 +++ Dockerfile | 2 +- docs/deployment/set-up-telegram-bridge.md | 1 + docs/reference/troubleshooting.md | 3 +++ test/e2e/test-messaging-providers.sh | 17 +++++++++++++++++ 6 files changed, 26 insertions(+), 1 deletion(-) diff --git a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md index 37e4d8c80..89ddbf553 100644 --- a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md @@ -149,6 +149,7 @@ When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Press **1** to toggle Telegram on or off, then **Enter** when done. If the token is not already in the environment or credential store, the wizard prompts for it and saves it to the store. If `TELEGRAM_ALLOWED_IDS` is not set, the wizard can prompt for allowed sender IDs for Telegram DMs (you can leave this blank and rely on OpenClaw pairing instead). +NemoClaw applies that allowlist to Telegram DMs only. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. ## Step 10: Run `nemoclaw onboard` diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 30c65e8e0..a2c252b5e 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -250,6 +250,9 @@ As a result, commands that try to rewrite the baked config from inside the sandb If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. +If `openclaw doctor` reports that it moved Telegram single-account values under `channels.telegram.accounts.default`, rerun onboarding and rebuild the sandbox rather than trying to patch `openclaw.json` in place. +Current NemoClaw rebuilds bake Telegram in the account-based layout and set Telegram group chats to `groupPolicy: open`, which avoids the empty `groupAllowFrom` warning path for default group-chat access. + ### Discord bot logs in, but the channel still does not work Separate the problem into two parts: diff --git a/Dockerfile b/Dockerfile index 418fe757e..165e17af2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -128,7 +128,7 @@ _allowed_ids = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_MESSAGING_AL _discord_guilds = json.loads(base64.b64decode(os.environ.get('NEMOCLAW_DISCORD_GUILDS_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': {'default': {_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}; \ +_ch_cfg = {ch: {'accounts': {'default': {_token_keys[ch]: f'openshell:resolve:env:{_env_keys[ch]}', 'enabled': True, **({'groupPolicy': 'open'} if ch == 'telegram' else {}), **({'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}; \ _ch_cfg['discord'].update({'groupPolicy': 'allowlist', 'guilds': _discord_guilds}) if 'discord' in _ch_cfg and _discord_guilds else None; \ 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'; \ diff --git a/docs/deployment/set-up-telegram-bridge.md b/docs/deployment/set-up-telegram-bridge.md index f3e2e08c8..3ad0ef9f1 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -60,6 +60,7 @@ When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Press **1** to toggle Telegram on or off, then **Enter** when done. If the token is not already in the environment or credential store, the wizard prompts for it and saves it to the store. If `TELEGRAM_ALLOWED_IDS` is not set, the wizard can prompt for allowed sender IDs for Telegram DMs (you can leave this blank and rely on OpenClaw pairing instead). +NemoClaw applies that allowlist to Telegram DMs only. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. ## Run `nemoclaw onboard` diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index bc575b150..07a8942d0 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -282,6 +282,9 @@ As a result, commands that try to rewrite the baked config from inside the sandb If your Discord channel config is wrong, rerun onboarding so NemoClaw rebuilds the sandbox image with the correct messaging selection. Do not treat a failed `doctor --fix` run as proof that the Discord gateway path itself is broken. +If `openclaw doctor` reports that it moved Telegram single-account values under `channels.telegram.accounts.default`, rerun onboarding and rebuild the sandbox rather than trying to patch `openclaw.json` in place. +Current NemoClaw rebuilds bake Telegram in the account-based layout and set Telegram group chats to `groupPolicy: open`, which avoids the empty `groupAllowFrom` warning path for default group-chat access. + ### Discord bot logs in, but the channel still does not work Separate the problem into two parts: diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 14e9b2baf..0e62fb749 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -424,6 +424,23 @@ print(','.join(str(i) for i in ids)) else skip "M11c: Telegram allowFrom not set (channel may not be configured)" fi + + # M11d: Telegram groupPolicy defaults to open so group chats are not silently dropped + tg_group_policy=$(echo "$channel_json" | python3 -c " +import json, sys +d = json.load(sys.stdin) +accounts = d.get('telegram', {}).get('accounts', {}) +account = accounts.get('default') or accounts.get('main') or {} +print(account.get('groupPolicy', '')) +" 2>/dev/null || true) + + if [ "$tg_group_policy" = "open" ]; then + pass "M11d: Telegram groupPolicy is 'open'" + elif [ -n "$tg_group_policy" ]; then + fail "M11d: Telegram groupPolicy is '$tg_group_policy' (expected 'open')" + else + skip "M11d: Telegram groupPolicy not set (channel may not be configured)" + fi fi # ══════════════════════════════════════════════════════════════════ From 778bda96e16841c84c23522907451d6757527294 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 20:09:33 -0400 Subject: [PATCH 07/12] fix(policy): drop deprecated tls termination from messaging presets --- nemoclaw-blueprint/policies/presets/discord.yaml | 3 --- nemoclaw-blueprint/policies/presets/slack.yaml | 3 --- nemoclaw-blueprint/policies/presets/telegram.yaml | 1 - test/policies.test.js | 8 ++++++++ 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index b67cbd23f..f3fc0bcb5 100644 --- a/nemoclaw-blueprint/policies/presets/discord.yaml +++ b/nemoclaw-blueprint/policies/presets/discord.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -34,7 +33,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } # Media/attachment access (read-only, proxied through Discord CDN) @@ -42,7 +40,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } binaries: diff --git a/nemoclaw-blueprint/policies/presets/slack.yaml b/nemoclaw-blueprint/policies/presets/slack.yaml index 6af9ab026..49297a182 100644 --- a/nemoclaw-blueprint/policies/presets/slack.yaml +++ b/nemoclaw-blueprint/policies/presets/slack.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -21,7 +20,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } @@ -29,7 +27,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/**" } - allow: { method: POST, path: "/**" } diff --git a/nemoclaw-blueprint/policies/presets/telegram.yaml b/nemoclaw-blueprint/policies/presets/telegram.yaml index 02e90f91a..55a2fdfb0 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -13,7 +13,6 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } diff --git a/test/policies.test.js b/test/policies.test.js index 60077984c..76fb6c18a 100644 --- a/test/policies.test.js +++ b/test/policies.test.js @@ -584,6 +584,14 @@ describe("policies", () => { } }); + it("messaging REST presets do not pin deprecated tls termination", () => { + for (const name of ["discord", "slack", "telegram"]) { + const content = policies.loadPreset(name); + expect(content).toBeTruthy(); + expect(content.includes("tls: terminate")).toBe(false); + } + }); + it("package-manager presets include binaries section", () => { // Without binaries, the proxy can't match pip/npm traffic to the policy // and returns 403. From 46b2e9317aca4531c2289f48e9f143ef3982cf0a Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 20:29:24 -0400 Subject: [PATCH 08/12] test(onboard): avoid legacy shim edits after TS migration --- bin/lib/onboard.js | 5 ----- test/onboard.test.ts | 7 +++++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index d2f1eab9e..805426674 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1,10 +1,6 @@ // @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// -// Thin re-export shim — the implementation lives in src/lib/onboard.ts, -// compiled to dist/lib/onboard.js. - const mod = require("../../dist/lib/onboard"); module.exports = { buildProviderArgs: mod.buildProviderArgs, @@ -54,7 +50,6 @@ module.exports = { isInferenceRouteReady: mod.isInferenceRouteReady, isOpenclawReady: mod.isOpenclawReady, arePolicyPresetsApplied: mod.arePolicyPresetsApplied, - getSuggestedPolicyPresets: mod.getSuggestedPolicyPresets, presetsCheckboxSelector: mod.presetsCheckboxSelector, setupPoliciesWithSelection: mod.setupPoliciesWithSelection, summarizeCurlFailure: mod.summarizeCurlFailure, diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 05dc4853f..3c73cc456 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import { createRequire } from "node:module"; import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; @@ -26,7 +27,6 @@ import { getRequestedModelHint, getRequestedProviderHint, getRequestedSandboxNameHint, - getSuggestedPolicyPresets, getResumeConfigConflicts, getResumeSandboxConflict, getSandboxStateFromOutputs, @@ -48,6 +48,9 @@ import { import { stageOptimizedSandboxBuildContext } from "../bin/lib/sandbox-build-context"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +const require = createRequire(import.meta.url); +const { getSuggestedPolicyPresets } = require("../src/lib/onboard.ts"); + describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( @@ -1766,7 +1769,7 @@ const { setupInference } = require(${onboardPath}); assert.match( source, - /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*enabledChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, + /startRecordedStep\("sandbox", \{ sandboxName, provider, model \}\);\s*selectedMessagingChannels = await setupMessagingChannels\(\);\s*onboardSession\.updateSession\(\(current\) => \{\s*current\.messagingChannels = selectedMessagingChannels;\s*return current;\s*\}\);\s*sandboxName = await createSandbox\(\s*gpu,\s*model,\s*provider,\s*preferredInferenceApi,\s*sandboxName,\s*webSearchConfig,\s*selectedMessagingChannels,\s*fromDockerfile,\s*agent,\s*dangerouslySkipPermissions,\s*\);/, ); }); From 676a69ab492475db6afd8393d5d13efce68ae387 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 20:30:57 -0400 Subject: [PATCH 09/12] chore(onboard): restore legacy shim parity with main --- bin/lib/onboard.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/lib/onboard.js b/bin/lib/onboard.js index 805426674..f02ff14e2 100644 --- a/bin/lib/onboard.js +++ b/bin/lib/onboard.js @@ -1,6 +1,7 @@ // @ts-nocheck // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 + const mod = require("../../dist/lib/onboard"); module.exports = { buildProviderArgs: mod.buildProviderArgs, From c1e1348296ac543d826e238becc649c956b9d2d3 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 20:36:53 -0400 Subject: [PATCH 10/12] fix(onboard): tighten discord messaging remediation --- .../nemoclaw-user-deploy-remote/SKILL.md | 3 ++- .../references/troubleshooting.md | 4 ++-- docs/deployment/set-up-telegram-bridge.md | 3 ++- docs/reference/troubleshooting.md | 4 ++-- src/lib/onboard.ts | 18 ++++++++++++++++-- test/e2e/test-messaging-providers.sh | 10 ++++++++-- test/onboard.test.ts | 12 +++++++++--- 7 files changed, 41 insertions(+), 13 deletions(-) diff --git a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md index 2bb0442fb..486cb70da 100644 --- a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md @@ -168,7 +168,8 @@ When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Press **1** to toggle Telegram on or off, then **Enter** when done. If the token is not already in the environment or credential store, the wizard prompts for it and saves it to the store. If `TELEGRAM_ALLOWED_IDS` is not set, the wizard can prompt for allowed sender IDs for Telegram DMs (you can leave this blank and rely on OpenClaw pairing instead). -NemoClaw applies that allowlist to Telegram DMs only. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. +NemoClaw applies that allowlist to Telegram DMs only. +Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. ## Step 11: Run `nemoclaw onboard` diff --git a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 23a90491e..8e58e618d 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -279,12 +279,12 @@ Current NemoClaw rebuilds bake Telegram in the account-based layout and set Tele Separate the problem into two parts: -1. **Baked config and provider wiring** +1. Baked config and provider wiring Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. If Discord was skipped during onboarding, rerun onboarding and select Discord again. -1. **Native Discord gateway path** +1. Native Discord gateway path Successful login alone does not prove that Discord works end to end. Discord also needs a working gateway connection to `gateway.discord.gg`. diff --git a/docs/deployment/set-up-telegram-bridge.md b/docs/deployment/set-up-telegram-bridge.md index 3ad0ef9f1..5e3345b8e 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -60,7 +60,8 @@ When the wizard reaches **Messaging channels**, it lists Telegram, Discord, and Press **1** to toggle Telegram on or off, then **Enter** when done. If the token is not already in the environment or credential store, the wizard prompts for it and saves it to the store. If `TELEGRAM_ALLOWED_IDS` is not set, the wizard can prompt for allowed sender IDs for Telegram DMs (you can leave this blank and rely on OpenClaw pairing instead). -NemoClaw applies that allowlist to Telegram DMs only. Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. +NemoClaw applies that allowlist to Telegram DMs only. +Group chats stay open by default so rebuilt sandboxes do not silently drop Telegram group messages because of an empty group allowlist. ## Run `nemoclaw onboard` diff --git a/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 0927f7c5b..94f6c6e7f 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -309,12 +309,12 @@ Current NemoClaw rebuilds bake Telegram in the account-based layout and set Tele Separate the problem into two parts: -1. **Baked config and provider wiring** +1. Baked config and provider wiring Check that onboarding selected Discord and that the sandbox was created with the Discord messaging provider attached. If Discord was skipped during onboarding, rerun onboarding and select Discord again. -1. **Native Discord gateway path** +1. Native Discord gateway path Successful login alone does not prove that Discord works end to end. Discord also needs a working gateway connection to `gateway.discord.gg`. diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ef5012858..ae16f26fb 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -179,6 +179,8 @@ const REMOTE_PROVIDER_CONFIG = { }, }; +const DISCORD_SNOWFLAKE_RE = /^[0-9]{17,19}$/; + // Non-interactive mode: set by --non-interactive flag or env var. // When active, all prompts use env var overrides or sensible defaults. let NON_INTERACTIVE = false; @@ -2507,6 +2509,16 @@ async function createSandbox( .split(",") .map((s) => s.trim()) .filter(Boolean); + for (const serverId of serverIds) { + if (!DISCORD_SNOWFLAKE_RE.test(serverId)) { + console.warn(` Warning: Discord server ID '${serverId}' does not look like a snowflake.`); + } + } + for (const userId of userIds) { + if (!DISCORD_SNOWFLAKE_RE.test(userId)) { + console.warn(` Warning: Discord user ID '${userId}' does not look like a snowflake.`); + } + } const requireMention = process.env.DISCORD_REQUIRE_MENTION !== "0"; for (const serverId of serverIds) { discordGuilds[serverId] = { @@ -3627,7 +3639,7 @@ async function setupMessagingChannels() { } } } - if (ch.requireMentionEnvKey && process.env[ch.serverIdEnvKey || ""]) { + if (ch.requireMentionEnvKey && ch.serverIdEnvKey && process.env[ch.serverIdEnvKey]) { const existingRequireMention = process.env[ch.requireMentionEnvKey]; if (existingRequireMention === "0" || existingRequireMention === "1") { const mode = existingRequireMention === "0" ? "all messages" : "@mentions only"; @@ -3679,7 +3691,9 @@ function getSuggestedPolicyPresets({ enabledChannels = null, webSearchConfig = n } if (getCredential(envKey) || process.env[envKey]) { suggestions.push(channel); - console.log(` Auto-detected: ${envKey} -> suggesting ${channel} preset`); + if (process.stdout.isTTY && !isNonInteractive() && process.env.CI !== "true") { + console.log(` Auto-detected: ${envKey} -> suggesting ${channel} preset`); + } } }; diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 0e62fb749..d725b5fd3 100755 --- a/test/e2e/test-messaging-providers.sh +++ b/test/e2e/test-messaging-providers.sh @@ -522,8 +522,6 @@ info "Discord gateway probe: ${dc_gateway:0:300}" if echo "$dc_gateway" | grep -q "MESSAGE "; then pass "M13b: Native Discord gateway returned a WebSocket message" -elif echo "$dc_gateway" | grep -q "OPEN"; then - pass "M13b: Native Discord gateway opened a WebSocket session" elif echo "$dc_gateway" | grep -qiE "EAI_AGAIN|getaddrinfo"; then if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then fail "M13b: Native Discord gateway hit DNS resolution failure (${dc_gateway:0:200})" @@ -550,6 +548,14 @@ elif echo "$dc_gateway" | grep -q "ERROR"; then else skip "M13b: Native Discord gateway probe failed (${dc_gateway:0:200})" fi +elif echo "$dc_gateway" | grep -q "CLOSE"; then + if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then + fail "M13b: Native Discord gateway probe closed abnormally (${dc_gateway:0:200})" + else + skip "M13b: Native Discord gateway probe closed abnormally (${dc_gateway:0:200})" + fi +elif echo "$dc_gateway" | grep -q "OPEN"; then + pass "M13b: Native Discord gateway opened a WebSocket session" else if [ "$STRICT_DISCORD_GATEWAY" = "1" ]; then fail "M13b: Native Discord gateway probe returned an unclassified result (${dc_gateway:0:200})" diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 3c73cc456..cd156498b 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -108,6 +108,9 @@ describe("onboard helpers", () => { }); it("uses explicit messaging selections for policy suggestions when provided", () => { + const originalTelegramBotToken = process.env.TELEGRAM_BOT_TOKEN; + const originalDiscordBotToken = process.env.DISCORD_BOT_TOKEN; + const originalSlackBotToken = process.env.SLACK_BOT_TOKEN; process.env.TELEGRAM_BOT_TOKEN = "telegram-token"; process.env.DISCORD_BOT_TOKEN = "discord-token"; process.env.SLACK_BOT_TOKEN = "slack-token"; @@ -125,9 +128,12 @@ describe("onboard helpers", () => { "discord", ]); } finally { - delete process.env.TELEGRAM_BOT_TOKEN; - delete process.env.DISCORD_BOT_TOKEN; - delete process.env.SLACK_BOT_TOKEN; + if (originalTelegramBotToken === undefined) delete process.env.TELEGRAM_BOT_TOKEN; + else process.env.TELEGRAM_BOT_TOKEN = originalTelegramBotToken; + if (originalDiscordBotToken === undefined) delete process.env.DISCORD_BOT_TOKEN; + else process.env.DISCORD_BOT_TOKEN = originalDiscordBotToken; + if (originalSlackBotToken === undefined) delete process.env.SLACK_BOT_TOKEN; + else process.env.SLACK_BOT_TOKEN = originalSlackBotToken; } }); From 1a1bf043e75109c4d8a12226046c36cbb3eb7ac8 Mon Sep 17 00:00:00 2001 From: Kevin Jones Date: Thu, 9 Apr 2026 20:41:56 -0400 Subject: [PATCH 11/12] fix(onboard): restore messaging channel state --- src/lib/onboard.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index ae16f26fb..60e8ead7f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -4330,6 +4330,7 @@ async function onboard(opts = {}) { try { let session; + let selectedMessagingChannels = []; // Merged, absolute fromDockerfile: explicit flag/env takes precedence; on // resume falls back to what the original session recorded so the same image // is used even when --from is omitted from the resume invocation. @@ -4594,13 +4595,12 @@ async function onboard(opts = {}) { } } } + startRecordedStep("sandbox", { sandboxName, provider, model }); selectedMessagingChannels = await setupMessagingChannels(); onboardSession.updateSession((current) => { current.messagingChannels = selectedMessagingChannels; return current; }); - - startRecordedStep("sandbox", { sandboxName, provider, model }); sandboxName = await createSandbox( gpu, model, From 6977f4e18dc07d8dfd37ab4023f8c1033f5e6b37 Mon Sep 17 00:00:00 2001 From: Test User Date: Thu, 9 Apr 2026 23:43:59 -0400 Subject: [PATCH 12/12] test(onboard): use built module for policy preset helper --- test/onboard.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/onboard.test.ts b/test/onboard.test.ts index 57f70a95d..aeff24842 100644 --- a/test/onboard.test.ts +++ b/test/onboard.test.ts @@ -49,7 +49,7 @@ import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-con import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; const require = createRequire(import.meta.url); -const { getSuggestedPolicyPresets } = require("../src/lib/onboard.ts"); +const { getSuggestedPolicyPresets } = require("../dist/lib/onboard.js"); describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => {