Skip to content
Merged
10 changes: 10 additions & 0 deletions docs/manage-sandboxes/messaging-channels.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,16 @@ To remove a channel and clear its stored credentials, run:
$ nemoclaw my-assistant channels remove telegram
```

For QR-paired channels (today: WhatsApp), `channels remove` destructively clears the in-sandbox session directory before the rebuild so the next rebuild does not restore stale auth files and reconnect the channel.
The cleanup targets `/sandbox/.openclaw/<channel>/` for OpenClaw and `/sandbox/.hermes/platforms/<channel>/` for Hermes.
The cleanup tries `openshell sandbox exec` and falls back to SSH if that does not produce the success sentinel.
If neither transport can reach a running sandbox for a QR-paired channel, the command exits non-zero and asks you to start the sandbox and re-run.
NemoClaw deliberately leaves the registry, policy preset, and `session.policyPresets` unchanged on that failure path, so a follow-up re-run completes the removal cleanly.

`channels remove whatsapp` clears the client-side Baileys session inside the sandbox; it cannot deregister the linked device with WhatsApp's servers because that requires an active Baileys connection to issue the logout RPC, which we no longer have once the session files are gone.
The phone account will continue to list the sandbox as a Linked Device until you remove it manually from your phone (Settings → Linked Devices → tap the entry → Log out) or until WhatsApp's 14-day inactivity timeout expires.
Removing the entry from the phone is recommended if you plan to re-pair the same phone with a different sandbox.

Use `channels stop` when you want to pause a bridge without deleting credentials:

```console
Expand Down
8 changes: 8 additions & 0 deletions docs/reference/commands.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,14 @@ Clear the stored credentials for a messaging channel and rebuild the sandbox so
Running `remove` for a channel that was never configured is a no-op against the credentials file and still triggers the rebuild prompt.
When the bridge provider is attached to a live sandbox, NemoClaw detaches it before deleting the provider from the OpenShell gateway.
If the matching built-in policy preset is applied, such as `telegram`, `discord`, `slack`, or `whatsapp`, NemoClaw also removes that preset so the upstream API is no longer allow-listed after the channel is gone.
NemoClaw also strips the channel from `session.policyPresets` so a subsequent `onboard --resume` does not re-apply the preset on the next rebuild.

For QR-paired channels (today: WhatsApp), NemoClaw destructively clears the in-sandbox session directory before the rebuild so the `state_dirs` backup does not restore the auth blob and let the channel reconnect:

- OpenClaw: `/sandbox/.openclaw/<channel>/` (for example `/sandbox/.openclaw/whatsapp/`).
- Hermes: `/sandbox/.hermes/platforms/<channel>/` (for example `/sandbox/.hermes/platforms/whatsapp/`).

The cleanup tries `openshell sandbox exec` first and falls back to SSH if the exec wrapper does not return the success sentinel. If both transports fail (the sandbox is stopped, the gateway is down, or SSH cannot reach it) the command refuses to proceed to the rebuild and asks you to start the sandbox and re-run, so a half-removed state cannot leave stale Baileys auth files behind for the next rebuild to restore.

```console
$ nemoclaw my-assistant channels remove telegram
Expand Down
111 changes: 107 additions & 4 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
} from "../../domain/policy-channel";
import * as registry from "../../state/registry";
import { runOpenshell } from "../../adapters/openshell/runtime";
import { shellQuote } from "../../runner";
import { executeSandboxCommand, executeSandboxExecCommand } from "./process-recovery";
import { rebuildSandbox } from "./rebuild";
import {
type ChannelDef,
Expand Down Expand Up @@ -732,6 +734,71 @@ function applyChannelPresetIfAvailable(sandboxName: string, channelName: string)
}
}

function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string): string[] {
const configDir = agent.configPaths.dir;
const stateDirs = new Set(agent.stateDirs);
if (stateDirs.has("platforms")) {
return [`${configDir}/platforms/${channelName}`];
}
if (stateDirs.has(channelName)) {
return [`${configDir}/${channelName}`];
}
return [];
}

function isSafeChannelStatePath(p: string): boolean {
if (!p.startsWith("/sandbox/.")) return false;
if (p.includes("..")) return false;
return /^\/sandbox\/\.[A-Za-z0-9_./-]+$/.test(p);
}

const CHANNEL_CLEAR_SENTINEL = "NEMOCLAW_CHANNEL_CLEAR_OK";

// Wipe the durable per-channel state inside the sandbox before rebuild so
// the state_dirs backup does not restore an auth blob the operator just
// asked NemoClaw to forget. Returns true when no cleanup was needed OR
// when the in-sandbox rm produced our success sentinel; false otherwise.
// Tries `openshell sandbox exec` first and falls back to SSH for transient
// wrapper hiccups (mirrors the pattern in process-recovery.ts:286-296).
// Fixes #3998.
function clearSandboxChannelDurableState(sandboxName: string, channelName: string): boolean {
const agent = resolveAgentForSandbox(sandboxName);
const paths = getSandboxChannelStatePaths(agent, channelName).filter(isSafeChannelStatePath);
if (paths.length === 0) return true;

const quoted = paths.map((p) => shellQuote(p)).join(" ");
const cmd = `rm -rf -- ${quoted} && printf '%s\\n' ${shellQuote(CHANNEL_CLEAR_SENTINEL)}`;
const sentinelSeen = (result: { stdout?: string | null } | null): boolean =>
!!result && typeof result.stdout === "string" && result.stdout.includes(CHANNEL_CLEAR_SENTINEL);

let result = executeSandboxExecCommand(sandboxName, cmd);
if (!sentinelSeen(result)) {
result = executeSandboxCommand(sandboxName, cmd);
}
if (!sentinelSeen(result)) {
console.error(
` ${YW}⚠${R} Could not clear in-sandbox '${channelName}' channel state at ${paths.join(", ")}.`,
);
return false;
}
console.log(` ${G}✓${R} Cleared in-sandbox '${channelName}' channel state.`);
return true;
}

// Drop the channel name from session.policyPresets so onboard --resume's
// preset reconciliation does not re-apply the preset we just removed (#3998).
function dropChannelFromSessionPolicyPresets(channelName: string): void {
onboardSession.updateSession((current) => {
if (Array.isArray(current.policyPresets)) {
const filtered = current.policyPresets.filter((preset) => preset !== channelName);
if (filtered.length !== current.policyPresets.length) {
current.policyPresets = filtered;
}
}
return current;
});
}

// Mirror of applyChannelPresetIfAvailable. When the channel-named built-in
// preset is currently applied to the sandbox, un-apply it so `policy-list`
// no longer reports it active and the L7 proxy stops allow-listing the
Expand Down Expand Up @@ -793,10 +860,39 @@ export async function removeSandboxChannel(

clearChannelTokens(channel);
const tokenKeys = getChannelTokenKeys(channel);
// Same rationale as channels-add: tear down the gateway providers and
// drop the channel from the registry NOW so a deferred rebuild does
// not leave a stale bridge running against a token NemoClaw has
// already "removed" from the user's perspective.
const isQrChannel = channelUsesInSandboxQrPairing(channel);

const registryEntry = registry.getSandbox(sandboxName);
const sessionForSandbox = onboardSession.loadSession();
const sessionPolicyPresets =
sessionForSandbox?.sandboxName === sandboxName &&
Array.isArray(sessionForSandbox.policyPresets)
? sessionForSandbox.policyPresets
: [];
const hasChannelResidue =
(registryEntry?.messagingChannels || []).includes(canonical) ||
(registryEntry?.policies || []).includes(canonical) ||
sessionPolicyPresets.includes(canonical) ||
policies.getAppliedPresets(sandboxName).includes(canonical);

// QR-paired channels store auth blobs inside the sandbox that survive a
// rebuild via the state_dirs backup. Tear those down FIRST so a cleanup
// failure leaves the registry/policy untouched — the operator can re-run
// after starting the sandbox. Bailing here is the only way to keep
// #3998 from recurring on cleanup error. Skip the cleanup attempt entirely
// when the registry/policy show no residue — `channels remove` on a
// never-configured/already-clean sandbox must remain a quiet no-op even
// when the sandbox is stopped (#4001 review).
if (isQrChannel && hasChannelResidue && !clearSandboxChannelDurableState(sandboxName, canonical)) {
console.error(
` Refusing to proceed: '${canonical}' session state is still inside the sandbox.`,
);
console.error(
` Start the sandbox, then re-run: ${CLI_NAME} ${sandboxName} channels remove ${canonical}`,
);
process.exit(1);
}

await applyChannelRemoveToGatewayAndRegistry(sandboxName, canonical, tokenKeys);
if (tokenKeys.length > 0) {
console.log(` ${G}✓${R} Removed ${canonical} bridge from the OpenShell gateway.`);
Expand All @@ -805,7 +901,14 @@ export async function removeSandboxChannel(
}

removeChannelPresetIfPresent(sandboxName, canonical);
dropChannelFromSessionPolicyPresets(canonical);

// Token-based channels: best-effort tidy of any leftover dir. Token
// revocation already prevents the bot from authenticating, so a
// failure here is a warning, not a bail.
if (!isQrChannel) {
clearSandboxChannelDurableState(sandboxName, canonical);
}

await promptAndRebuild(sandboxName, `remove '${canonical}'`);
}
Expand Down
47 changes: 22 additions & 25 deletions src/lib/agent/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { getAgentBranding } from "../cli/branding";
import { getProviderSelectionConfig } from "../inference/config";
import type { JsonObject as LooseObject } from "../core/json-types";
import * as onboardSession from "../state/onboard-session";
import { runSandboxConfigSync } from "../onboard/config-sync";
import { ROOT, redact, run, shellQuote } from "../runner";
import {
buildLocalBaseTag,
Expand All @@ -28,9 +29,6 @@ export interface OnboardContext {
runCaptureOpenshell: (args: string[], opts?: { ignoreError?: boolean }) => string | null;
openshellShellCommand: (args: string[], options?: { openshellBinary?: string }) => string;
openshellBinary: string;
buildSandboxConfigSyncScript: (config: LooseObject) => string;
writeSandboxConfigSyncFile: (script: string) => string;
cleanupTempDir: (file: string, prefix: string) => void;
startRecordedStep: (stepName: string, updates: LooseObject) => void;
skippedStepMessage: (stepName: string, sandboxName: string) => void;
}
Expand Down Expand Up @@ -402,13 +400,25 @@ export async function handleAgentSetup(
step,
runCaptureOpenshell,
openshellBinary: openshellBin,
buildSandboxConfigSyncScript,
writeSandboxConfigSyncFile,
cleanupTempDir,
startRecordedStep,
skippedStepMessage,
} = ctx;

const syncNemoClawConfig = (): void => {
runSandboxConfigSync(sandboxName, {
getSelectionConfig: () => {
const cfg = getProviderSelectionConfig(provider, model);
return cfg ? { ...cfg, agent: agent.name } : null;
},
runConnectScript: (name, scriptContent) => {
run([openshellBin, "sandbox", "connect", name], {
stdio: ["pipe", "ignore", "inherit"],
input: scriptContent,
});
},
});
};

if (resume && sandboxName) {
const probe = agent.healthProbe;
if (probe?.url) {
Expand All @@ -418,6 +428,11 @@ export async function handleAgentSetup(
);
if (isHealthProbeOk(result)) {
skippedStepMessage("agent_setup", sandboxName);
// Re-sync `~/.nemoclaw/config.json` even on the resume skip path —
// a rebuild destroys/recreates the container and the file reverts
// to the Dockerfile's zero-byte placeholder. Mirrors the OpenClaw
// path in src/lib/onboard.ts. Fixes #3999 for non-OpenClaw agents.
syncNemoClawConfig();
onboardSession.markStepComplete("agent_setup", { sandboxName, provider, model });
return;
}
Expand All @@ -436,25 +451,7 @@ export async function handleAgentSetup(
);
}

const selectionConfig = getProviderSelectionConfig(provider, model);
if (selectionConfig) {
const sandboxConfig = {
...selectionConfig,
agent: agent.name,
onboardedAt: new Date().toISOString(),
};
const script = buildSandboxConfigSyncScript(sandboxConfig);
const scriptFile = writeSandboxConfigSyncFile(script);
try {
const scriptContent = fs.readFileSync(scriptFile, "utf-8");
run([openshellBin, "sandbox", "connect", sandboxName], {
stdio: ["pipe", "ignore", "inherit"],
input: scriptContent,
});
} finally {
cleanupTempDir(scriptFile, "nemoclaw-sync");
}
}
syncNemoClawConfig();

const probe = agent.healthProbe;
if (probe?.url) {
Expand Down
37 changes: 16 additions & 21 deletions src/lib/onboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const {
const {
buildSandboxConfigSyncScript,
writeSandboxConfigSyncFile,
runSandboxConfigSync,
}: typeof import("./onboard/config-sync") = require("./onboard/config-sync");
const dockerGpuPatch: typeof import("./onboard/docker-gpu-patch") = require("./onboard/docker-gpu-patch");
const dockerGpuLocalInference: typeof import("./onboard/docker-gpu-local-inference") = require("./onboard/docker-gpu-local-inference");
Expand Down Expand Up @@ -7930,28 +7931,21 @@ async function setupMessagingChannels(

// ── Step 7: OpenClaw ─────────────────────────────────────────────

async function setupOpenclaw(sandboxName: string, model: string, provider: string): Promise<void> {
step(7, 8, `Setting up ${agentProductName()} inside sandbox`);

const selectionConfig = getProviderSelectionConfig(provider, model);
if (selectionConfig) {
const sandboxConfig = {
...selectionConfig,
onboardedAt: new Date().toISOString(),
};
const script = buildSandboxConfigSyncScript(sandboxConfig);
const scriptFile = writeSandboxConfigSyncFile(script);
try {
const scriptContent = fs.readFileSync(scriptFile, "utf-8");
run(openshellArgv(["sandbox", "connect", sandboxName]), {
function syncNemoClawConfigInSandbox(sandboxName: string, provider: string, model: string): void {
runSandboxConfigSync(sandboxName, {
getSelectionConfig: () => getProviderSelectionConfig(provider, model),
runConnectScript: (name, scriptContent) => {
run(openshellArgv(["sandbox", "connect", name]), {
stdio: ["pipe", "ignore", "inherit"],
input: scriptContent,
});
} finally {
cleanupTempDir(scriptFile, "nemoclaw-sync");
}
}
},
});
}

async function setupOpenclaw(sandboxName: string, model: string, provider: string): Promise<void> {
step(7, 8, `Setting up ${agentProductName()} inside sandbox`);
syncNemoClawConfigInSandbox(sandboxName, provider, model);
console.log(` ✓ ${agentProductName()} gateway launched inside sandbox`);
}

Expand Down Expand Up @@ -9920,9 +9914,6 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
runCaptureOpenshell,
openshellShellCommand,
openshellBinary: getOpenshellBinary(),
buildSandboxConfigSyncScript,
writeSandboxConfigSyncFile,
cleanupTempDir,
startRecordedStep,
skippedStepMessage,
});
Expand All @@ -9932,6 +9923,10 @@ async function onboard(opts: OnboardOptions = {}): Promise<void> {
const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName);
if (resumeOpenclaw) {
skippedStepMessage("openclaw", sandboxName);
// Rebuild leaves /sandbox/.nemoclaw/config.json as Dockerfile's
// zero-byte placeholder; re-sync to avoid loadOnboardConfig
// SyntaxError. Fixes #3999.
syncNemoClawConfigInSandbox(sandboxName, provider, model);
onboardSession.markStepComplete(
"openclaw",
toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }),
Expand Down
25 changes: 24 additions & 1 deletion src/lib/onboard/config-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,30 @@
import fs from "node:fs";

import type { ProviderSelectionConfig } from "../inference/config";
import { secureTempFile } from "./temp-files";
import { cleanupTempDir, secureTempFile } from "./temp-files";

export interface RunSandboxConfigSyncDeps {
getSelectionConfig: () => ProviderSelectionConfig | null;
runConnectScript: (sandboxName: string, scriptContent: string) => void;
}

// Write `~/.nemoclaw/config.json` and normalize OpenClaw config-dir perms
// inside the sandbox. Idempotent — safe to invoke from the rebuild resume
// path where the Dockerfile leaves config.json as a zero-byte placeholder
// that crashes the OpenClaw nemoclaw plugin's loadOnboardConfig. Fixes #3999.
export function runSandboxConfigSync(sandboxName: string, deps: RunSandboxConfigSyncDeps): void {
const selectionConfig = deps.getSelectionConfig();
if (!selectionConfig) return;
const sandboxConfig = { ...selectionConfig, onboardedAt: new Date().toISOString() };
const script = buildSandboxConfigSyncScript(sandboxConfig);
const scriptFile = writeSandboxConfigSyncFile(script);
try {
const scriptContent = fs.readFileSync(scriptFile, "utf-8");
deps.runConnectScript(sandboxName, scriptContent);
} finally {
cleanupTempDir(scriptFile, "nemoclaw-sync");
}
}

export function buildSandboxConfigSyncScript(selectionConfig: ProviderSelectionConfig): string {
// Do not rewrite openclaw.json at runtime. Model routing is handled by the
Expand Down
Loading
Loading