Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .agents/skills/nemoclaw-user-deploy-remote/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down
5 changes: 5 additions & 0 deletions .agents/skills/nemoclaw-user-reference/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,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.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,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

### Agent cannot reach an external host

OpenShell blocks outbound connections to hosts not listed in the network policy.
Expand Down
9 changes: 8 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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}
Expand All @@ -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']; \
Expand Down
155 changes: 125 additions & 30 deletions bin/lib/onboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -960,6 +960,7 @@ function patchStagedDockerfile(
webSearchConfig = null,
messagingChannels = [],
messagingAllowedIds = {},
discordGuilds = {},
) {
const { providerKey, primaryModelRef, inferenceBaseUrl, inferenceApi, inferenceCompat } =
getSandboxInferenceConfig(model, provider, preferredInferenceApi);
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -2409,14 +2416,37 @@ 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())
.filter(Boolean);
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,
Expand All @@ -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
Expand Down Expand Up @@ -3347,13 +3378,26 @@ 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",
envKey: "DISCORD_BOT_TOKEN",
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 this server to message the bot.",
userIdLabel: "Discord User ID (optional guild allowlist)",
allowIdsMode: "guild",
},
{
name: "slack",
Expand Down Expand Up @@ -3496,8 +3540,40 @@ 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 && 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}`);
Expand All @@ -3508,7 +3584,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})`);
}
}
}
Expand All @@ -3517,6 +3597,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) {
Expand Down Expand Up @@ -3546,24 +3650,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);
Expand Down Expand Up @@ -3824,15 +3913,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);
Expand Down Expand Up @@ -4379,6 +4464,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";
Expand All @@ -4398,7 +4487,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(
Expand All @@ -4408,7 +4501,7 @@ async function onboard(opts = {}) {
preferredInferenceApi,
sandboxName,
webSearchConfig,
enabledChannels,
selectedMessagingChannels,
fromDockerfile,
);
onboardSession.markStepComplete("sandbox", { sandboxName, provider, model, nimContainer });
Expand Down Expand Up @@ -4452,6 +4545,7 @@ async function onboard(opts = {}) {
recordedPolicyPresets.length > 0
? recordedPolicyPresets
: null,
enabledChannels: selectedMessagingChannels,
webSearchConfig,
onSelection: (policyPresets) => {
onboardSession.updateSession((current) => {
Expand Down Expand Up @@ -4524,6 +4618,7 @@ module.exports = {
isInferenceRouteReady,
isOpenclawReady,
arePolicyPresetsApplied,
getSuggestedPolicyPresets,
presetsCheckboxSelector,
setupPoliciesWithSelection,
summarizeCurlFailure,
Expand Down
Loading