diff --git a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md index 0df3511e8..486cb70da 100644 --- a/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md +++ b/.agents/skills/nemoclaw-user-deploy-remote/SKILL.md @@ -168,6 +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. ## Step 11: Run `nemoclaw onboard` diff --git a/.agents/skills/nemoclaw-user-reference/references/commands.md b/.agents/skills/nemoclaw-user-reference/references/commands.md index e828fc435..475178afe 100644 --- a/.agents/skills/nemoclaw-user-reference/references/commands.md +++ b/.agents/skills/nemoclaw-user-reference/references/commands.md @@ -85,6 +85,11 @@ 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, 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. +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/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md index 509794d99..8e58e618d 100644 --- a/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md +++ b/.agents/skills/nemoclaw-user-reference/references/troubleshooting.md @@ -262,6 +262,48 @@ 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. + +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: + +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 + ### Sandbox lost after gateway restart Sandboxes created with OpenShell versions older than 0.0.24 can become unreachable after a gateway restart because SSH secrets were not persisted. diff --git a/Dockerfile b/Dockerfile index e3aa94ede..165e17af2 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': {'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, **({'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'; \ origins = ['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..5e3345b8e 100644 --- a/docs/deployment/set-up-telegram-bridge.md +++ b/docs/deployment/set-up-telegram-bridge.md @@ -60,6 +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. ## Run `nemoclaw onboard` diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 6a70a0b54..b9d142b07 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -107,6 +107,11 @@ 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, 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. +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/docs/reference/troubleshooting.md b/docs/reference/troubleshooting.md index 996e3d605..94f6c6e7f 100644 --- a/docs/reference/troubleshooting.md +++ b/docs/reference/troubleshooting.md @@ -292,6 +292,48 @@ 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. + +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: + +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 + ### Sandbox lost after gateway restart Sandboxes created with OpenShell versions older than 0.0.24 can become unreachable after a gateway restart because SSH secrets were not persisted. diff --git a/nemoclaw-blueprint/policies/presets/discord.yaml b/nemoclaw-blueprint/policies/presets/discord.yaml index 42a0dd8dd..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,8 +40,8 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - 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..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: "/**" } @@ -43,3 +40,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..55a2fdfb0 100644 --- a/nemoclaw-blueprint/policies/presets/telegram.yaml +++ b/nemoclaw-blueprint/policies/presets/telegram.yaml @@ -13,10 +13,10 @@ network_policies: port: 443 protocol: rest enforcement: enforce - tls: terminate rules: - allow: { method: GET, path: "/bot*/**" } - allow: { method: POST, path: "/bot*/**" } - allow: { method: GET, path: "/file/bot*/**" } binaries: - { path: /usr/local/bin/node } + - { path: /usr/bin/node } diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 0a89ad657..b28bf162f 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -109,7 +109,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: { @@ -178,6 +178,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; @@ -964,6 +966,7 @@ function patchStagedDockerfile( webSearchConfig = null, messagingChannels = [], messagingAllowedIds = {}, + discordGuilds = {}, ) { const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } = getSandboxInferenceConfig(model, provider, preferredInferenceApi); @@ -1039,6 +1042,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); } @@ -2476,7 +2485,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()) @@ -2484,6 +2498,34 @@ 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); + 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] = { + requireMention, + ...(userIds.length > 0 ? { users: userIds } : {}), + }; + } + } patchStagedDockerfile( stagedDockerfile, model, @@ -2494,6 +2536,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 @@ -3418,6 +3461,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", @@ -3425,6 +3469,18 @@ 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)", + 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: + "Optional: enable Developer Mode in Discord, then right-click your user/avatar and copy the User ID. Leave blank to allow any member of the configured server to message the bot.", + userIdLabel: "Discord User ID (optional guild allowlist)", + allowIdsMode: "guild", }, { name: "slack", @@ -3567,8 +3623,39 @@ async function setupMessagingChannels() { continue; } } - // Prompt for user/sender ID if the channel supports DM allowlisting - if (ch.userIdEnvKey) { + 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)`); + } + } + } + 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"; + 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 && (!ch.serverIdEnvKey || process.env[ch.serverIdEnvKey])) { const existingIds = process.env[ch.userIdEnvKey] || ""; if (existingIds) { console.log(` ✓ ${ch.name} — allowed IDs already set: ${existingIds}`); @@ -3579,7 +3666,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" + ? "any member in the configured server can message the bot" + : "bot will require manual pairing"; + console.log(` Skipped ${ch.name} user ID (${skippedReason})`); } } } @@ -3588,6 +3679,32 @@ 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); + if (process.stdout.isTTY && !isNonInteractive() && process.env.CI !== "true") { + 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) { @@ -3617,24 +3734,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); @@ -3895,15 +3997,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); @@ -4231,6 +4329,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. @@ -4495,9 +4594,12 @@ async function onboard(opts = {}) { } } } - const enabledChannels = await setupMessagingChannels(); - startRecordedStep("sandbox", { sandboxName, provider, model }); + selectedMessagingChannels = await setupMessagingChannels(); + onboardSession.updateSession((current) => { + current.messagingChannels = selectedMessagingChannels; + return current; + }); sandboxName = await createSandbox( gpu, model, @@ -4505,7 +4607,7 @@ async function onboard(opts = {}) { preferredInferenceApi, sandboxName, webSearchConfig, - enabledChannels, + selectedMessagingChannels, fromDockerfile, agent, dangerouslySkipPermissions, @@ -4574,6 +4676,7 @@ async function onboard(opts = {}) { recordedPolicyPresets.length > 0 ? recordedPolicyPresets : null, + enabledChannels: selectedMessagingChannels, webSearchConfig, onSelection: (policyPresets) => { onboardSession.updateSession((current) => { @@ -4647,6 +4750,7 @@ module.exports = { isInferenceRouteReady, isOpenclawReady, arePolicyPresetsApplied, + getSuggestedPolicyPresets, presetsCheckboxSelector, setupPoliciesWithSelection, summarizeCurlFailure, diff --git a/test/e2e/test-messaging-providers.sh b/test/e2e/test-messaging-providers.sh index 6b0103294..d725b5fd3 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) @@ -302,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 @@ -324,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 @@ -346,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 @@ -359,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 @@ -372,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 @@ -387,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) @@ -409,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 # ══════════════════════════════════════════════════════════════════ @@ -454,6 +486,84 @@ 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 -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 +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})" + 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.ts b/test/onboard.test.ts index 4758d9d3b..aeff24842 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"; @@ -47,6 +48,9 @@ import { import { stageOptimizedSandboxBuildContext } from "../dist/lib/sandbox-build-context"; import { buildWebSearchDockerConfig } from "../dist/lib/web-search"; +const require = createRequire(import.meta.url); +const { getSuggestedPolicyPresets } = require("../dist/lib/onboard.js"); + describe("onboard helpers", () => { it("classifies sandbox create timeout failures and tracks upload progress", () => { expect( @@ -103,6 +107,36 @@ describe("onboard helpers", () => { assert.match(script, /^exit$/m); }); + 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"; + 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 { + 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; + } + }); + 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"); @@ -138,6 +172,117 @@ 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("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"), @@ -1630,7 +1775,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*\);/, ); }); diff --git a/test/policies.test.ts b/test/policies.test.ts index 8f392392a..3dee4b2bb 100644 --- a/test/policies.test.ts +++ b/test/policies.test.ts @@ -145,6 +145,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", () => { @@ -583,6 +591,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("pypi preset allows HEAD for pip lazy-wheel metadata checks", () => { // pip and uv use HEAD requests for lazy wheel downloads and // range-request support. GET-only would break pip install.