Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
28 changes: 25 additions & 3 deletions docs/manage-sandboxes/enable-channels-during-onboarding.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
title: "Enable Channels During Onboarding"
sidebar-title: "Enable Channels During Onboarding"
description: "Select messaging channels and supply their credentials or pairing inputs during NemoClaw onboarding."
description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels and creating OpenShell bridge providers. Use when enabling channels on a new sandbox."
keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables"]
description-agent: "Explains the interactive and scripted onboarding flows for selecting messaging channels, creating OpenShell bridge providers, and removing a channel by clearing its host inputs. Use when enabling or disabling channels during onboarding."
keywords: ["nemoclaw onboard messaging", "messaging channel picker", "channel environment variables", "disable messaging channel"]
content:
type: "how_to"
agent-variants: ["openclaw", "hermes"]
Expand All @@ -25,7 +25,7 @@ Refer to [Set Up Google Chat](set-up-google-chat) before selecting it.
</AgentOnly>

If you select no channels, pressing **Enter** skips messaging setup.
If a token-based channel token is not already in the environment or credential store, the wizard prompts for it and saves it.
If the current host inputs do not include a token-based channel token, the wizard prompts for it and stages it for the current onboarding process.

If you enable WeChat, the wizard renders a QR code, polls Tencent's iLink gateway, and captures the bot token after you scan the QR with WeChat on your phone.
The login has an eight-minute deadline, refreshes the QR up to three times on expiry, and follows iLink's IDC redirects automatically.
Expand Down Expand Up @@ -78,6 +78,28 @@ $$nemoclaw onboard
Complete the wizard so the blueprint can create OpenShell providers where needed, such as `<sandbox>-telegram-bridge`, `<sandbox>-teams-bridge`, or `<sandbox>-wechat-bridge`.
The wizard writes channel configuration into the image through `NEMOCLAW_MESSAGING_CHANNELS_B64` and starts the sandbox.

## Stop Configuring a Channel

Onboarding reads the host inputs on every run, so clearing a channel's inputs and re-onboarding removes it.
Unset the channel's environment variables.
Run onboarding again.

NemoClaw reports the removal and drops the channel's network policy preset with it, so the sandbox does not keep the wider egress of a channel it no longer serves.

Expected output:

```text
No host inputs configure discord; disabling the channel and its network egress.
[non-interactive] Applying policy presets: npm, pypi
```

Onboarding uses the current host inputs to determine whether a token-based channel remains configured.
`$$nemoclaw credentials reset` takes an OpenShell provider name and does not change those host inputs.

A QR-paired channel such as WhatsApp is exempt.
The host holds no value that reports whether the pairing is still live, so an absent host input is not evidence that you removed the channel.
Use [`channels remove`](manage-messaging-channels) for those.

## Verify the Result

After the sandbox is running, send a message to the configured bot or app.
Expand Down
41 changes: 41 additions & 0 deletions src/lib/onboard/machine/handlers/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,47 @@ describe("handlePoliciesState", () => {
);
});

it("disables a channel whose preset is applied but which no plan still names (#9283)", async () => {
const { deps, calls } = createDeps({
getActiveSandbox: vi.fn(() => ({
messaging: null,
policies: ["npm", "pypi", "discord"],
})),
detectUnconfiguredMessagingChannels: vi.fn(
(planChannels: readonly string[]) => [...planChannels],
),
});

await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: [] });

expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith(["discord"], [], null);
expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ enabledChannels: [], disabledChannels: ["discord"] }),
);
});

it("leaves a still-configured channel enabled when its preset is applied (#9283)", async () => {
const { deps, calls } = createDeps({
getActiveSandbox: vi.fn(() => ({
messaging: null,
policies: ["npm", "discord"],
})),
});

await handlePoliciesState({ ...baseOptions(deps), selectedMessagingChannels: ["discord"] });

expect(deps.detectUnconfiguredMessagingChannels).toHaveBeenCalledWith(
["discord"],
["discord"],
null,
);
expect(calls.setupPolicies).toHaveBeenCalledWith(
"my-assistant",
expect.objectContaining({ enabledChannels: ["discord"], disabledChannels: [] }),
);
});

it("keeps a still-configured channel enabled", async () => {
const { deps, calls } = createDeps({
getActiveSandbox: vi.fn(() => ({
Expand Down
13 changes: 12 additions & 1 deletion src/lib/onboard/machine/handlers/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getActiveChannelsFromPlan,
getDisabledChannelsFromPlan,
} from "../../messaging-plan-session";
import { messagingChannelsForPolicyPresets } from "../../messaging-policy-presets";
import type { HostLocalInferenceSandboxProofAuthority } from "../../runtime-provider/host-local-inference-routing";
import { advanceTo, type OnboardStateTransitionResult } from "../result";

Expand All @@ -26,6 +27,8 @@ export interface PolicyPresetEntry {
export interface ActiveSandboxPolicyState {
messaging?: { plan: SandboxMessagingPlan } | null;
policyTier?: string | null;
/** Preset names already applied to the sandbox, as recorded in the registry. */
policies?: string[] | null;
}

export interface PolicyResumeSelection {
Expand Down Expand Up @@ -177,8 +180,16 @@ export async function handlePoliciesState<Agent, WebSearchConfig>({
// run re-applies its egress preset. Adding it to `disabledChannels` here lets
// the existing disabled-channel pruning drop the preset from both the merged
// selection and the previously-applied set.
//
// The applied preset list is the third candidate source because it outlives
// the plans: a sandbox can carry a channel's egress in `policies` after every
// plan that named the channel is gone, and only a candidate here can retire
// it.
const appliedPresetMessagingChannels = messagingChannelsForPolicyPresets(
activeSandbox?.policies,
);
const unconfiguredMessagingChannels = deps.detectUnconfiguredMessagingChannels(
[...recordedMessagingChannels, ...activeMessagingChannels],
[...recordedMessagingChannels, ...activeMessagingChannels, ...appliedPresetMessagingChannels],
selectedMessagingChannels,
agent,
);
Expand Down
140 changes: 128 additions & 12 deletions src/lib/onboard/machine/handlers/sandbox-messaging.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
} from "../../../state/onboard-checkpoint-types";
import { createSession, type Session } from "../../../state/onboard-session";
import { setupMessagingChannels } from "../../messaging-channel-setup";
import { getActiveChannelsFromPlan } from "../../messaging-plan-session";
import {
hasMessagingCredentialDrift,
reconcileReusedSandboxMessaging,
Expand Down Expand Up @@ -186,6 +187,21 @@ function discordPlan(credentialHash: string): SandboxMessagingPlan {
};
}

function withChannelDisabled(
plan: SandboxMessagingPlan,
channelId: string,
): SandboxMessagingPlan {
return {
...plan,
channels: plan.channels.map((channel) =>
channel.channelId === channelId
? { ...channel, active: false, selected: false, disabled: true }
: channel,
),
disabledChannels: [...new Set([...plan.disabledChannels, channelId])],
};
}

function whatsappPlan(): SandboxMessagingPlan {
return {
...telegramPlan(""),
Expand Down Expand Up @@ -359,14 +375,12 @@ describe("reconcileReusedSandboxMessaging", () => {
it("does not clear an equal recorded plan from a different authority", () => {
const plan = telegramPlan(hashCredential("123456:registry-token") ?? "");
const clearPlanEnv = vi.fn();
// Keep the channel host-configured so this case stays about plan equality,
// not the #9283 unconfigured-channel selection filter.
vi.stubEnv("TELEGRAM_BOT_TOKEN", "123456:registry-token");

const result = reconcileReusedSandboxMessaging(
structuredClone(plan),
{ name: "openclaw" },
{ clearPlanEnv },
{ clearPlanEnv, note: vi.fn(), writePlanToEnv: vi.fn() },
plan,
);

Expand All @@ -386,10 +400,13 @@ describe("reconcileReusedSandboxMessaging", () => {
plan,
);

// The plan still records the channel — only the reported selection drops
// it, so the policies handler classifies it as unconfigured and prunes its
// egress preset instead of re-applying it on every later onboarding run.
expect(result).toEqual({ plan, selectedChannels: [], changed: false });
// Persist the removal so later readers cannot re-enable the channel and
// re-apply its egress preset.
expect(result).toEqual({
plan: withChannelDisabled(plan, "discord"),
selectedChannels: [],
changed: true,
});
expect(clearPlanEnv).not.toHaveBeenCalled();
});

Expand Down Expand Up @@ -425,13 +442,11 @@ describe("reconcileReusedSandboxMessaging", () => {
});

it("removes every unsupported channel artifact from a reused plan", () => {
// Keep the channel host-configured so this case stays about unsupported
// artifact removal, not the #9283 unconfigured-channel selection filter.
vi.stubEnv("TELEGRAM_BOT_TOKEN", "123456:registry-token");
const result = reconcileReusedSandboxMessaging(
mixedChannelPlan(),
{ name: "openclaw" },
{ clearPlanEnv() {} },
{ clearPlanEnv() {}, note() {}, writePlanToEnv() {} },
);
const filtered = result.plan;

Expand Down Expand Up @@ -466,6 +481,25 @@ describe("reconcileReusedSandboxMessaging", () => {
healthChecks: ["telegram"],
});
});

it("disables and stages an unconfigured host-backed channel for Ready sandbox reuse (#9283)", () => {
const plan = discordPlan(hashCredential("previous-discord-token") ?? "");
const deps = reconcileDeps([]);
vi.stubEnv("DISCORD_BOT_TOKEN", "");

const result = reconcileReusedSandboxMessaging(
plan,
{ name: "openclaw" },
deps,
structuredClone(plan),
);
const disabledPlan = withChannelDisabled(plan, "discord");

expect(result).toEqual({ plan: disabledPlan, selectedChannels: [], changed: true });
expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan);
expect(deps.clearPlanEnv).not.toHaveBeenCalled();
expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure"));
});
});

describe("reconcileSandboxMessaging plan authority", () => {
Expand Down Expand Up @@ -536,7 +570,86 @@ describe("reconcileSandboxMessaging plan authority", () => {
});

expect(deps.setupMessagingChannels).not.toHaveBeenCalled();
expect(result).toEqual({ plan: registryPlan, selectedChannels: [] });
expect(result).toEqual({
plan: withChannelDisabled(registryPlan, "discord"),
selectedChannels: [],
});
});

it("records the removal in the plan so a later reader cannot re-enable it (#9283)", async () => {
const registryPlan = discordPlan(hashCredential("previous-discord-token") ?? "");
const disabledPlan = withChannelDisabled(registryPlan, "discord");
const deps = reconcileDeps([]);
deps.getRegistrySandboxMessagingAuthority.mockReturnValue({
authoritative: true,
plan: registryPlan,
});
vi.stubEnv("DISCORD_BOT_TOKEN", "");

const result = await reconcileSandboxMessaging({
resume: false,
session: null,
sandboxName: "alpha",
agent: { name: "openclaw" },
deps,
});

expect(getActiveChannelsFromPlan(result.plan)).toEqual([]);
expect(result.plan?.disabledChannels).toEqual(["discord"]);
expect(deps.writePlanToEnv).toHaveBeenLastCalledWith(disabledPlan);
expect(deps.note).toHaveBeenCalledWith(expect.stringContaining("No host inputs configure"));
});

it("omits a removed host-backed channel from a lifecycle-workflow registry plan (#9283)", async () => {
const registryPlan = {
...discordPlan(hashCredential("previous-discord-token") ?? ""),
workflow: "add-channel" as const,
};
const deps = reconcileDeps([]);
deps.getRegistrySandboxMessagingAuthority.mockReturnValue({
authoritative: true,
plan: registryPlan,
});
vi.stubEnv("DISCORD_BOT_TOKEN", "");

const result = await reconcileSandboxMessaging({
resume: false,
session: null,
sandboxName: "alpha",
agent: { name: "openclaw" },
deps,
});

expect(deps.setupMessagingChannels).not.toHaveBeenCalled();
expect(result).toEqual({
plan: withChannelDisabled(registryPlan, "discord"),
selectedChannels: [],
});
});

it("keeps a still-configured channel in a lifecycle-workflow registry plan (#9283)", async () => {
const token = "still-configured-discord-token";
const registryPlan = {
...discordPlan(hashCredential(token) ?? ""),
workflow: "add-channel" as const,
};
const deps = reconcileDeps([]);
deps.getRegistrySandboxMessagingAuthority.mockReturnValue({
authoritative: true,
plan: registryPlan,
});
vi.stubEnv("DISCORD_BOT_TOKEN", token);

const result = await reconcileSandboxMessaging({
resume: false,
session: null,
sandboxName: "alpha",
agent: { name: "openclaw" },
deps,
});

expect(result.selectedChannels).toEqual(["discord"]);
expect(result.plan?.disabledChannels).toEqual([]);
});

it("omits a removed host-backed channel from a completed registry resume (#9109)", async () => {
Expand All @@ -557,7 +670,10 @@ describe("reconcileSandboxMessaging plan authority", () => {
});

expect(deps.setupMessagingChannels).not.toHaveBeenCalled();
expect(result).toEqual({ plan: registryPlan, selectedChannels: [] });
expect(result).toEqual({
plan: withChannelDisabled(registryPlan, "discord"),
selectedChannels: [],
});
});

it("omits a retired host-backed channel from recorded resume channels (#9283)", async () => {
Expand Down
Loading
Loading