diff --git a/src/lib/messaging/channels/discord/template-resolver.ts b/src/lib/messaging/channels/discord/template-resolver.ts index e866a4e3b2..9569a3d1d3 100644 --- a/src/lib/messaging/channels/discord/template-resolver.ts +++ b/src/lib/messaging/channels/discord/template-resolver.ts @@ -8,12 +8,16 @@ import { nonEmptyArray, nonEmptyCsv, nonEmptyObject, + nonEmptyString, parseBoolean, parseList, resolvedRenderTemplateReference, stateValue, } from "../template-resolver-utils"; +const DEFAULT_PROXY_HOST = "10.200.0.1"; +const DEFAULT_PROXY_PORT = "3128"; + type DiscordGuildConfig = { readonly requireMention?: boolean; readonly users?: readonly string[]; @@ -23,7 +27,8 @@ export const resolveDiscordTemplateReference: BuiltInRenderTemplateResolver = ( reference, context, ) => { - if (reference === "discordProxyUrl") return resolvedRenderTemplateReference(undefined); + if (reference === "discordProxyUrl") + return resolvedRenderTemplateReference(proxyUrl(context.env)); switch (reference) { case "discord.guilds": @@ -83,3 +88,9 @@ function discordRequireMention(context: RenderTemplateContext): boolean { } return true; } + +function proxyUrl(env: RenderTemplateContext["env"]): string { + const host = nonEmptyString(env?.NEMOCLAW_PROXY_HOST) ?? DEFAULT_PROXY_HOST; + const port = nonEmptyString(env?.NEMOCLAW_PROXY_PORT) ?? DEFAULT_PROXY_PORT; + return `http://${host}:${port}`; +} diff --git a/test/discord-template-resolver-proxy.test.ts b/test/discord-template-resolver-proxy.test.ts new file mode 100644 index 0000000000..95f0e318e2 --- /dev/null +++ b/test/discord-template-resolver-proxy.test.ts @@ -0,0 +1,30 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it } from "vitest"; + +import { resolveDiscordTemplateReference } from "../dist/lib/messaging/channels/discord/template-resolver.js"; + +// The Discord gateway client honors only the per-account proxy (it ignores the +// managed env proxy), so channels.discord.accounts.default.proxy must resolve to +// the sandbox proxy or the gateway WebSocket cannot egress the deny-by-default +// network namespace. Telegram already resolves its proxy this way; #5075. +const ctx = (env: Record) => ({ inputs: [], env }); + +describe("discord template-resolver: discordProxyUrl", () => { + it("resolves discordProxyUrl to the default sandbox proxy (was previously undefined, #5075)", () => { + expect(resolveDiscordTemplateReference("discordProxyUrl", ctx({}))).toEqual({ + matched: true, + value: "http://10.200.0.1:3128", + }); + }); + + it("honors NEMOCLAW_PROXY_HOST / NEMOCLAW_PROXY_PORT overrides", () => { + expect( + resolveDiscordTemplateReference( + "discordProxyUrl", + ctx({ NEMOCLAW_PROXY_HOST: "10.201.0.9", NEMOCLAW_PROXY_PORT: "43128" }), + ), + ).toEqual({ matched: true, value: "http://10.201.0.9:43128" }); + }); +}); diff --git a/test/generate-openclaw-config.test.ts b/test/generate-openclaw-config.test.ts index 02826f6f80..e5492b15e9 100644 --- a/test/generate-openclaw-config.test.ts +++ b/test/generate-openclaw-config.test.ts @@ -6,11 +6,11 @@ // Runs the actual TypeScript script with controlled env vars and asserts on // the generated openclaw.json output. -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { spawnSync } from "node:child_process"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { buildConfig, main } from "../scripts/generate-openclaw-config.mts"; import { @@ -613,10 +613,10 @@ describe("generate-openclaw-config.mts: config generation", () => { "openshell:resolve:env:DISCORD_BOT_TOKEN", ); expect(config.channels.telegram.accounts.default.proxy).toBe("http://10.200.0.1:3128"); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.200.0.1:3128"); }); - it("#3894: routes Discord gateway traffic through OpenClaw's managed proxy", () => { + it("#3894: routes Discord gateway traffic through the per-account proxy", () => { const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, @@ -633,10 +633,10 @@ describe("generate-openclaw-config.mts: config generation", () => { token: "openshell:resolve:env:DISCORD_BOT_TOKEN", enabled: true, }); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.201.0.9:43128"); }); - it("does not write a Discord account proxy when the managed proxy is configured", () => { + it("writes the Discord account proxy alongside the managed proxy", () => { const channels = Buffer.from(JSON.stringify(["discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, @@ -644,7 +644,7 @@ describe("generate-openclaw-config.mts: config generation", () => { }); expect(config.proxy.proxyUrl).toBe("http://10.200.0.1:43128"); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.200.0.1:43128"); }); it("can defer OpenClaw managed proxy config for build-time doctor", () => { @@ -655,7 +655,7 @@ describe("generate-openclaw-config.mts: config generation", () => { }); expect(config.proxy).toBeUndefined(); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.200.0.1:3128"); }); it("ignores the OpenShell loopback proxy env var when using OpenClaw managed proxy", () => { @@ -667,10 +667,10 @@ describe("generate-openclaw-config.mts: config generation", () => { }); expect(config.proxy.proxyUrl).toBe("http://10.200.0.1:3128"); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.200.0.1:3128"); }); - it("keeps Telegram on the OpenShell proxy while Discord relies on the managed proxy", () => { + it("routes both Telegram and Discord through the per-account proxy", () => { const channels = Buffer.from(JSON.stringify(["telegram", "discord"])).toString("base64"); const config = runConfigScript({ NEMOCLAW_MESSAGING_CHANNELS_B64: channels, @@ -680,7 +680,7 @@ describe("generate-openclaw-config.mts: config generation", () => { expect(config.proxy.proxyUrl).toBe("http://10.201.0.9:43128"); expect(config.channels.telegram.accounts.default.proxy).toBe("http://10.201.0.9:43128"); - expect(config.channels.discord.accounts.default.proxy).toBeUndefined(); + expect(config.channels.discord.accounts.default.proxy).toBe("http://10.201.0.9:43128"); }); it("emits Bolt-shape placeholders for Slack so the SDK's prefix regex passes", () => {