diff --git a/docs/manage-sandboxes/messaging-channels.mdx b/docs/manage-sandboxes/messaging-channels.mdx index 9f4e853ef85..7afb009ea4b 100644 --- a/docs/manage-sandboxes/messaging-channels.mdx +++ b/docs/manage-sandboxes/messaging-channels.mdx @@ -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//` for OpenClaw and `/sandbox/.hermes/platforms//` 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 diff --git a/docs/reference/commands.mdx b/docs/reference/commands.mdx index e229aac40f6..269d7eedf77 100644 --- a/docs/reference/commands.mdx +++ b/docs/reference/commands.mdx @@ -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//` (for example `/sandbox/.openclaw/whatsapp/`). +- Hermes: `/sandbox/.hermes/platforms//` (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 diff --git a/src/lib/actions/sandbox/policy-channel.ts b/src/lib/actions/sandbox/policy-channel.ts index 686cd2942a2..db33e0b2e67 100644 --- a/src/lib/actions/sandbox/policy-channel.ts +++ b/src/lib/actions/sandbox/policy-channel.ts @@ -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, @@ -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 @@ -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.`); @@ -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}'`); } diff --git a/src/lib/agent/onboard.ts b/src/lib/agent/onboard.ts index 2446108910a..2418ed38313 100644 --- a/src/lib/agent/onboard.ts +++ b/src/lib/agent/onboard.ts @@ -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, @@ -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; } @@ -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) { @@ -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; } @@ -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) { diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 4d9374e3b36..bcde96f4eb8 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -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"); @@ -7930,28 +7931,21 @@ async function setupMessagingChannels( // ── Step 7: OpenClaw ───────────────────────────────────────────── -async function setupOpenclaw(sandboxName: string, model: string, provider: string): Promise { - 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 { + step(7, 8, `Setting up ${agentProductName()} inside sandbox`); + syncNemoClawConfigInSandbox(sandboxName, provider, model); console.log(` ✓ ${agentProductName()} gateway launched inside sandbox`); } @@ -9920,9 +9914,6 @@ async function onboard(opts: OnboardOptions = {}): Promise { runCaptureOpenshell, openshellShellCommand, openshellBinary: getOpenshellBinary(), - buildSandboxConfigSyncScript, - writeSandboxConfigSyncFile, - cleanupTempDir, startRecordedStep, skippedStepMessage, }); @@ -9932,6 +9923,10 @@ async function onboard(opts: OnboardOptions = {}): Promise { 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 }), diff --git a/src/lib/onboard/config-sync.ts b/src/lib/onboard/config-sync.ts index 2ec8cfcde28..55aa87ee17e 100644 --- a/src/lib/onboard/config-sync.ts +++ b/src/lib/onboard/config-sync.ts @@ -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 diff --git a/test/channels-remove-full-teardown.test.ts b/test/channels-remove-full-teardown.test.ts new file mode 100644 index 00000000000..1bbb8679fc7 --- /dev/null +++ b/test/channels-remove-full-teardown.test.ts @@ -0,0 +1,523 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 +// +// Regression test for #3998 — `nemoclaw channels remove ` +// must (1) strip the channel from session.policyPresets so onboard --resume +// does not re-apply the preset on rebuild, (2) wipe the channel's durable +// state inside the sandbox so the rebuild's state_dirs backup does not +// restore stale auth files, and (3) refuse to proceed to rebuild when the +// in-sandbox cleanup for a QR-paired channel fails — otherwise the backup +// would re-capture the auth blob and the channel would reconnect after +// the rebuild. + +import assert from "node:assert/strict"; +import { spawnSync, type SpawnSyncReturns } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "vitest"; + +const repoRoot = path.join(import.meta.dirname, ".."); + +// Strip messaging-channel env vars from the parent process before spawning +// the test subprocess so local/CI ambient values (e.g. TELEGRAM_BOT_TOKEN +// in a developer shell) cannot perturb the channel cleanup paths the test +// is asserting against. +const MESSAGING_ENV_PREFIXES = ["TELEGRAM_", "DISCORD_", "SLACK_", "WECHAT_", "WEIXIN_", "WHATSAPP_"]; + +function buildCleanEnv(extraEnv: Record, home: string): NodeJS.ProcessEnv { + const filtered: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (MESSAGING_ENV_PREFIXES.some((prefix) => key.startsWith(prefix))) continue; + filtered[key] = value; + } + return { + ...filtered, + HOME: home, + NEMOCLAW_NON_INTERACTIVE: "1", + ...extraEnv, + }; +} + +function runScript(scriptBody: string, extraEnv: Record = {}): SpawnSyncReturns { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-3998-")); + const scriptPath = path.join(tmpDir, "script.js"); + fs.writeFileSync(scriptPath, scriptBody); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: repoRoot, + encoding: "utf-8", + env: buildCleanEnv(extraEnv, tmpDir), + timeout: 15000, + }); + fs.rmSync(tmpDir, { recursive: true, force: true }); + return result; +} + +function buildPreamble({ + presetNamesApplied = ["npm", "pypi", "huggingface", "brew", "whatsapp"], + sandboxAgent = "openclaw", + channelInRegistry = "whatsapp", + sandboxExecResult = { status: 0, stdout: "NEMOCLAW_CHANNEL_CLEAR_OK", stderr: "" }, + sshFallbackResult = null as { status: number; stdout: string; stderr: string } | null, +}: { + presetNamesApplied?: string[]; + sandboxAgent?: string; + channelInRegistry?: string; + sandboxExecResult?: { status: number; stdout: string; stderr: string } | null; + sshFallbackResult?: { status: number; stdout: string; stderr: string } | null; +} = {}): string { + const j = (p: string) => JSON.stringify(path.join(repoRoot, "dist", "lib", p)); + return String.raw` +const resolver = require(${j("adapters/openshell/resolve.js")}); +resolver.resolveOpenshell = () => "/fake/openshell"; + +const runner = require(${j("runner.js")}); +runner.run = () => ({ status: 0, stdout: "", stderr: "" }); +runner.runCapture = () => ""; + +const adapterRuntime = require(${j("adapters/openshell/runtime.js")}); +adapterRuntime.runOpenshell = () => ({ status: 0, stdout: "", stderr: "" }); + +const processRecovery = require(${j("actions/sandbox/process-recovery.js")}); +const sandboxExecCalls = []; +const sandboxSshCalls = []; +processRecovery.executeSandboxExecCommand = (sandboxName, command) => { + sandboxExecCalls.push({ sandboxName, command }); + return ${JSON.stringify(sandboxExecResult)}; +}; +processRecovery.executeSandboxCommand = (sandboxName, command) => { + sandboxSshCalls.push({ sandboxName, command }); + return ${JSON.stringify(sshFallbackResult)}; +}; + +const gatewayRuntime = require(${j("gateway-runtime-action.js")}); +gatewayRuntime.recoverNamedGatewayRuntime = async () => ({ recovered: true }); + +const credentials = require(${j("credentials/store.js")}); +credentials.getCredential = () => null; +credentials.saveCredential = () => true; +credentials.deleteCredential = () => true; +credentials.prompt = async (msg) => { throw new Error("unexpected prompt: " + msg); }; + +const onboard = require(${j("onboard.js")}); +onboard.isNonInteractive = () => true; + +const onboardSession = require(${j("state/onboard-session.js")}); +const sessionStore = { + sandboxName: "test-sb", + policyPresets: ${JSON.stringify(presetNamesApplied)}, + resumable: false, + status: "complete", + agent: ${JSON.stringify(sandboxAgent)}, + provider: null, + model: null, + endpointUrl: null, + credentialEnv: null, + hermesAuthMethod: null, + preferredInferenceApi: null, + nimContainer: null, + routerPid: null, + routerCredentialHash: null, + policyTier: null, + messagingChannels: [${JSON.stringify(channelInRegistry)}], + messagingChannelConfig: null, + disabledChannels: [], + hermesToolGateways: [], + wechatConfig: null, +}; +onboardSession.loadSession = () => sessionStore; +onboardSession.updateSession = (mutate) => { mutate(sessionStore); }; + +const registry = require(${j("state/registry.js")}); +const registryUpdates = []; +registry.getSandbox = () => ({ + name: "test-sb", + agent: ${JSON.stringify(sandboxAgent)}, + messagingChannels: [${JSON.stringify(channelInRegistry)}], + disabledChannels: [], + providerCredentialHashes: {}, + policies: ${JSON.stringify(presetNamesApplied)}, +}); +registry.updateSandbox = (name, updates) => { + registryUpdates.push({ name, updates }); + return true; +}; + +const policies = require(${j("policy/index.js")}); +const removedPresets = []; +policies.listPresets = () => ${JSON.stringify(presetNamesApplied.map((name) => ({ name })))}; +policies.getAppliedPresets = () => ${JSON.stringify(presetNamesApplied)}; +policies.removePreset = (sandboxName, presetName) => { + removedPresets.push({ sandboxName, presetName }); + return true; +}; + +const callOrder = []; +const origLog = console.log; +console.log = (...args) => { + const line = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" "); + if (line.includes("Change queued")) callOrder.push("promptAndRebuild"); + if (line.includes("Cleared in-sandbox")) callOrder.push("clearedSandboxState"); + origLog.call(console, ...args); +}; +const origExit = process.exit; +let exitCode = null; +process.exit = (code) => { + if (exitCode === null) exitCode = code; + throw new Error("__PROCESS_EXIT__:" + code); +}; + +const channelModule = require(${j("actions/sandbox/policy-channel.js")}); + +module.exports = { + channelModule, + sandboxExecCalls, + sandboxSshCalls, + removedPresets, + registryUpdates, + sessionStore, + callOrder, + getExitCode: () => exitCode, +}; +`; +} + +describe("channels remove full teardown (issue #3998)", () => { + for (const sandboxAgent of ["openclaw", "hermes"] as const) { + it(`strips '${sandboxAgent}' session.policyPresets and clears the in-sandbox whatsapp state dir`, () => { + const script = `${buildPreamble({ sandboxAgent })} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + sandboxExecCalls: ctx.sandboxExecCalls, + sessionPolicyPresets: ctx.sessionStore.policyPresets, + removedPresets: ctx.removedPresets, + callOrder: ctx.callOrder, + exitCode: ctx.getExitCode(), + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + assert.equal(payload.exitCode, null, `must not exit on success path; got exitCode=${payload.exitCode}`); + + assert.deepEqual( + payload.removedPresets, + [{ sandboxName: "test-sb", presetName: "whatsapp" }], + `expected one removePreset('whatsapp') call; got ${JSON.stringify(payload.removedPresets)}`, + ); + + assert.ok( + !payload.sessionPolicyPresets.includes("whatsapp"), + `session.policyPresets must not contain 'whatsapp' after remove (resume would reapply it). Got: ${JSON.stringify(payload.sessionPolicyPresets)}`, + ); + assert.deepEqual( + payload.sessionPolicyPresets, + ["npm", "pypi", "huggingface", "brew"], + "non-channel presets must stay in session.policyPresets", + ); + + const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => + c.command.startsWith("rm -rf"), + ); + assert.equal( + cleanupCalls.length, + 1, + `expected one rm -rf sandbox-exec call; got ${cleanupCalls.length}`, + ); + const expectedPath = + sandboxAgent === "openclaw" + ? "/sandbox/.openclaw/whatsapp" + : "/sandbox/.hermes/platforms/whatsapp"; + assert.ok( + cleanupCalls[0].command.includes(expectedPath), + `expected cleanup to target '${expectedPath}'; got ${cleanupCalls[0].command}`, + ); + + const rebuildIdx = payload.callOrder.indexOf("promptAndRebuild"); + const clearIdx = payload.callOrder.indexOf("clearedSandboxState"); + assert.ok(rebuildIdx >= 0, `promptAndRebuild was never called: ${JSON.stringify(payload.callOrder)}`); + assert.ok(clearIdx >= 0, `clearedSandboxState marker was never logged: ${JSON.stringify(payload.callOrder)}`); + assert.ok( + clearIdx < rebuildIdx, + `sandbox state must be cleared before rebuild so the backup excludes the auth files: ${JSON.stringify(payload.callOrder)}`, + ); + }); + } + + it("falls back to SSH when sandbox-exec wrapper does not return the sentinel", () => { + const script = `${buildPreamble({ + sandboxAgent: "openclaw", + sandboxExecResult: null, + sshFallbackResult: { status: 0, stdout: "NEMOCLAW_CHANNEL_CLEAR_OK", stderr: "" }, + })} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + sandboxExecCalls: ctx.sandboxExecCalls, + sandboxSshCalls: ctx.sandboxSshCalls, + removedPresets: ctx.removedPresets, + callOrder: ctx.callOrder, + exitCode: ctx.getExitCode(), + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.equal(payload.exitCode, null, `must not exit when SSH fallback recovers; got exitCode=${payload.exitCode}`); + assert.equal(payload.sandboxExecCalls.length, 1, "exec attempt must run first"); + assert.equal(payload.sandboxSshCalls.length, 1, "SSH fallback must run once when exec returns null"); + assert.deepEqual( + payload.removedPresets, + [{ sandboxName: "test-sb", presetName: "whatsapp" }], + "remove flow must continue after SSH-recovered cleanup", + ); + assert.ok( + payload.callOrder.includes("promptAndRebuild"), + `rebuild must be queued after SSH-recovered cleanup; callOrder=${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("aborts before rebuild when both exec and SSH cleanup fail for a QR channel", () => { + const script = `${buildPreamble({ + sandboxAgent: "openclaw", + sandboxExecResult: { status: 1, stdout: "", stderr: "sandbox is not running" }, + sshFallbackResult: { status: 255, stdout: "", stderr: "ssh: connect to host ... failed" }, + })} +const ctx = module.exports; +(async () => { + const dumpState = () => ({ + sandboxExecCalls: ctx.sandboxExecCalls, + sandboxSshCalls: ctx.sandboxSshCalls, + sessionPolicyPresets: ctx.sessionStore.policyPresets, + removedPresets: ctx.removedPresets, + registryUpdates: ctx.registryUpdates, + callOrder: ctx.callOrder, + exitCode: ctx.getExitCode(), + }); + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify(dumpState()) + "\\n"); + } catch (err) { + if (typeof err.message === "string" && err.message.startsWith("__PROCESS_EXIT__")) { + process.stdout.write("\\n__RESULT__" + JSON.stringify(dumpState()) + "\\n"); + return; + } + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.equal(payload.exitCode, 1, "QR channel cleanup failure must exit non-zero"); + assert.ok( + !payload.callOrder.includes("promptAndRebuild"), + `rebuild must NOT be queued on cleanup failure; callOrder=${JSON.stringify(payload.callOrder)}`, + ); + assert.deepEqual( + payload.removedPresets, + [], + "policy preset must NOT be un-applied when we bail early on cleanup failure", + ); + assert.deepEqual( + payload.registryUpdates, + [], + "registry must NOT be mutated when we bail early on cleanup failure", + ); + assert.deepEqual( + payload.sessionPolicyPresets, + ["npm", "pypi", "huggingface", "brew", "whatsapp"], + "session.policyPresets must be unchanged on early-bail", + ); + + const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => + c.command.startsWith("rm -rf"), + ); + assert.equal(cleanupCalls.length, 1, "expected the rm -rf attempt that failed"); + assert.equal( + payload.sandboxSshCalls.length, + 1, + `SSH fallback must be attempted before aborting; sandboxSshCalls=${JSON.stringify(payload.sandboxSshCalls)}`, + ); + assert.ok( + payload.sandboxSshCalls[0].command.startsWith("rm -rf"), + `SSH fallback must invoke the rm -rf cleanup; got ${payload.sandboxSshCalls[0].command}`, + ); + }); + + it("treats a leftover session.policyPresets entry as residue and runs cleanup", () => { + const script = `${buildPreamble({ + presetNamesApplied: ["npm", "pypi", "whatsapp"], + sandboxAgent: "openclaw", + channelInRegistry: "telegram", + })} +const ctx = module.exports; +const registryOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "state/registry.js"))}); +registryOverride.getSandbox = () => ({ + name: "test-sb", + agent: "openclaw", + messagingChannels: [], + disabledChannels: [], + providerCredentialHashes: {}, + policies: [], +}); +const policiesOverride = require(${JSON.stringify(path.join(repoRoot, "dist", "lib", "policy/index.js"))}); +policiesOverride.getAppliedPresets = () => []; +(async () => { + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + sandboxExecCalls: ctx.sandboxExecCalls, + sessionPolicyPresets: ctx.sessionStore.policyPresets, + callOrder: ctx.callOrder, + exitCode: ctx.getExitCode(), + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + const cleanupCalls = payload.sandboxExecCalls.filter((c: { command: string }) => + c.command.startsWith("rm -rf"), + ); + assert.equal( + cleanupCalls.length, + 1, + `cleanup must run when only session.policyPresets has residue; got ${JSON.stringify(payload.sandboxExecCalls)}`, + ); + assert.ok( + !payload.sessionPolicyPresets.includes("whatsapp"), + `session.policyPresets must be stripped after the residue-driven cleanup`, + ); + assert.equal(payload.exitCode, null, "must not abort when sandbox-exec succeeds"); + }); + + it("does not abort when removing a never-configured QR channel even if sandbox is unreachable", () => { + const script = `${buildPreamble({ + presetNamesApplied: ["npm", "pypi"], + sandboxAgent: "openclaw", + channelInRegistry: "telegram", + sandboxExecResult: { status: 1, stdout: "", stderr: "sandbox is not running" }, + sshFallbackResult: { status: 255, stdout: "", stderr: "ssh: connect to host ... failed" }, + })} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "whatsapp" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + sandboxExecCalls: ctx.sandboxExecCalls, + sandboxSshCalls: ctx.sandboxSshCalls, + callOrder: ctx.callOrder, + exitCode: ctx.getExitCode(), + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.equal(payload.exitCode, null, "must remain a no-op when registry shows no channel residue"); + assert.equal( + payload.sandboxExecCalls.length, + 0, + `sandbox-exec cleanup must NOT run when channel was never configured; got ${JSON.stringify(payload.sandboxExecCalls)}`, + ); + assert.equal( + payload.sandboxSshCalls.length, + 0, + "SSH fallback must NOT run when channel was never configured", + ); + assert.ok( + payload.callOrder.includes("promptAndRebuild"), + `rebuild prompt must still fire on the no-op remove path; callOrder=${JSON.stringify(payload.callOrder)}`, + ); + }); + + it("leaves non-whatsapp presets in session.policyPresets untouched when removing a token-based channel", () => { + const script = `${buildPreamble({ + presetNamesApplied: ["npm", "pypi", "telegram", "brew"], + sandboxAgent: "openclaw", + channelInRegistry: "telegram", + })} +const ctx = module.exports; +(async () => { + try { + await ctx.channelModule.removeSandboxChannel("test-sb", { channel: "telegram" }); + process.stdout.write("\\n__RESULT__" + JSON.stringify({ + sessionPolicyPresets: ctx.sessionStore.policyPresets, + registryUpdates: ctx.registryUpdates, + }) + "\\n"); + } catch (err) { + process.stdout.write("\\n__RESULT__" + JSON.stringify({ error: err.message, stack: err.stack }) + "\\n"); + } +})(); +`; + const result = runScript(script, { TELEGRAM_BOT_TOKEN: "stub" }); + assert.equal(result.status, 0, `script failed: ${result.stderr}\n${result.stdout}`); + const marker = result.stdout.lastIndexOf("__RESULT__"); + assert.ok(marker >= 0, `no __RESULT__ marker:\n${result.stdout}`); + const payload = JSON.parse(result.stdout.slice(marker + "__RESULT__".length).trim()); + assert.ok(!payload.error, `unexpected error: ${payload.error}\n${payload.stack || ""}`); + + assert.ok( + !payload.sessionPolicyPresets.includes("telegram"), + `session.policyPresets must drop 'telegram' after channel remove. Got: ${JSON.stringify(payload.sessionPolicyPresets)}`, + ); + assert.deepEqual( + payload.sessionPolicyPresets, + ["npm", "pypi", "brew"], + "other presets must remain after removing a token-based channel", + ); + + const messagingChannelsUpdate = payload.registryUpdates.find( + (u: { updates: { messagingChannels?: string[] } }) => + u.updates.messagingChannels !== undefined, + ); + assert.ok( + messagingChannelsUpdate, + `expected an updateSandbox call that writes messagingChannels; got ${JSON.stringify(payload.registryUpdates)}`, + ); + assert.deepEqual( + messagingChannelsUpdate.updates.messagingChannels, + [], + "messagingChannels must be empty after removing telegram", + ); + }); +});