Skip to content
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
58 commits
Select commit Hold shift + click to select a range
420f96b
fix(messaging): bind OpenClaw WeChat credentials
prekshivyas Aug 29, 2026
2294f33
fix(messaging): refresh sealed WeChat credentials
prekshivyas Aug 29, 2026
57a6c47
Merge branch 'main' into codex/fix-10079-openclaw-wechat-binding
prekshivyas Aug 29, 2026
c6bcc2a
fix(messaging): detect active WeChat accounts
prekshivyas Aug 29, 2026
1f9b57c
fix(messaging): preflight WeChat placeholder refresh
prekshivyas Aug 29, 2026
d0d8334
refactor(messaging): simplify WeChat refresh dispatch
prekshivyas Aug 29, 2026
66429a7
fix(messaging): order WeChat refresh before config writes
prekshivyas Aug 29, 2026
9ac6b97
test(messaging): prove WeChat runtime credential binding
prekshivyas Aug 29, 2026
ece29d1
merge(main): refresh PR validation base
prekshivyas Aug 29, 2026
89d5814
docs(messaging): clarify WeChat placeholder invariant
prekshivyas Aug 29, 2026
c827152
test(e2e): register WeChat runtime proof helpers
prekshivyas Aug 29, 2026
f9918d3
fix(messaging): clear OpenClaw WeChat account state
prekshivyas Aug 29, 2026
0b63a1d
fix(messaging): fail closed on WeChat removal
prekshivyas Aug 29, 2026
a6804dc
test(messaging): execute WeChat refresh boundary
prekshivyas Aug 29, 2026
043dd5a
test(e2e): remove WeChat source-shape assertion
prekshivyas Aug 29, 2026
0756a17
fix(messaging): recover failed WeChat removal
prekshivyas Aug 29, 2026
0f3bf90
fix(messaging): verify offline WeChat state mount
prekshivyas Aug 29, 2026
c95da08
fix(messaging): isolate offline WeChat volume
prekshivyas Aug 29, 2026
4a8b1d9
merge(main): refresh PR validation base
prekshivyas Aug 29, 2026
38efd25
test(messaging): stage WeChat refresh helper
prekshivyas Aug 30, 2026
633f3a1
merge(main): refresh PR validation base
prekshivyas Aug 30, 2026
66bfee3
fix(messaging): tighten WeChat lifecycle boundaries
prekshivyas Aug 30, 2026
11aeecf
test(messaging): pin WeChat cleanup paths
prekshivyas Aug 30, 2026
6c03dc3
refactor(messaging): centralize WeChat cleanup paths
prekshivyas Aug 30, 2026
60307cd
fix(messaging): diagnose stopped WeChat cleanup
prekshivyas Aug 30, 2026
57a2131
refactor(sandbox): invert channel cleanup dependency
prekshivyas Aug 30, 2026
46ffead
test(e2e): route WeChat fixture changes
prekshivyas Aug 30, 2026
38899ef
refactor(messaging): own WeChat cleanup state in manifest
prekshivyas Aug 30, 2026
9f88d07
merge(main): refresh PR validation base
prekshivyas Aug 30, 2026
cfaa17c
chore(runtime): refresh managed startup bundle
prekshivyas Aug 30, 2026
66d8c0f
fix(messaging): gate channel state cleanup by agent
prekshivyas Aug 30, 2026
4412ad9
test(messaging): remove source-shape import assertion
prekshivyas Aug 30, 2026
8595b03
fix(messaging): harden runtime credential refresh
prekshivyas Aug 30, 2026
377778f
merge(main): refresh PR validation base
prekshivyas Aug 30, 2026
0f02e4f
test(runtime): refresh reviewed bundle digest
prekshivyas Aug 30, 2026
fd43fdd
test(messaging): use Hermes credential boundary
prekshivyas Aug 30, 2026
398168b
docs(messaging): clarify stopped cleanup condition
prekshivyas Aug 30, 2026
425c003
test(e2e): bind Google Chat fixture at channel boundary
prekshivyas Aug 30, 2026
6adf351
test(e2e): cover Google Chat rebuild provider boundary
prekshivyas Aug 30, 2026
8dfe6f1
test(e2e): bind Google Chat fixture to startup registration
prekshivyas Aug 30, 2026
a3da32d
test(e2e): unify Google Chat startup boundary
prekshivyas Aug 30, 2026
c41151e
test(e2e): use named Google Chat fixture imports
prekshivyas Aug 30, 2026
6db670e
test(e2e): bind rebuild to Google Chat fixture
prekshivyas Aug 30, 2026
c66efc5
test(e2e): keep messaging fixtures behavior-focused
prekshivyas Aug 30, 2026
d49ed2a
test(cli): move onboarding import outside timeout
prekshivyas Aug 30, 2026
55f9bd7
merge(main): refresh PR validation base
prekshivyas Aug 30, 2026
aeeae73
docs(onboard): clarify policy sync fallback
prekshivyas Aug 30, 2026
47488fa
merge: resolve conflicts with main
github-actions[bot] Aug 30, 2026
07a5b9e
fix(wechat): harden runtime credential cleanup
senthilr-nv Aug 31, 2026
65cbc28
chore(runtime): refresh reviewed startup bundle
senthilr-nv Aug 31, 2026
6dd9805
fix(wechat): clarify cleanup recovery
senthilr-nv Aug 31, 2026
86a7a80
test(runtime): use explicit cleanup image oracle
senthilr-nv Aug 31, 2026
2825fbb
fix(wechat): improve cleanup recovery guidance
senthilr-nv Aug 31, 2026
d26ac5e
docs(wechat): explain stopped cleanup recovery
senthilr-nv Aug 31, 2026
653f269
fix(installer): admit wechat preload files
senthilr-nv Aug 31, 2026
9f6cc4c
fix(wechat): roll back partial account refresh
senthilr-nv Aug 31, 2026
58d904d
docs(wechat): qualify stopped cleanup fallback
senthilr-nv Aug 31, 2026
c3b5f1c
fix(wechat): narrow refresh abort handling
senthilr-nv Aug 31, 2026
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 Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -580,6 +580,7 @@
COPY scripts/lib/openclaw_device_approval_policy.py /usr/local/lib/nemoclaw/openclaw_device_approval_policy.py
COPY scripts/lib/clean_runtime_shell_env_shim.py /usr/local/lib/nemoclaw/clean_runtime_shell_env_shim.py
COPY scripts/lib/normalize_mutable_config_perms.py /usr/local/lib/nemoclaw/normalize_mutable_config_perms.py
COPY scripts/lib/refresh-openclaw-wechat-placeholder.py /usr/local/lib/nemoclaw/refresh-openclaw-wechat-placeholder.py
COPY scripts/state-dir-guard.py /usr/local/lib/nemoclaw/state-dir-guard.py
COPY agents/openclaw/state-lock-plan.json /usr/local/share/nemoclaw/state-lock-plan.json
COPY scripts/openclaw-config-guard.py /usr/local/lib/nemoclaw/openclaw-config-guard.py
Expand Down Expand Up @@ -1584,10 +1585,10 @@
# CHAT_UI_URL is a non-loopback address (Brev Launchable, remote deployments)
# since terminal-based pairing is impossible in those contexts.
# Default: "0" (device auth enabled for local deployments — secure by default).
ARG NEMOCLAW_DISABLE_DEVICE_AUTH=0

Check warning on line 1588 in Dockerfile

View workflow job for this annotation

GitHub Actions / PR build and direct managed startup (OpenClaw)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NEMOCLAW_DISABLE_DEVICE_AUTH") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
# Internal audit provenance for the opt-out above. Standard onboarding rewrites
# this to managed-onboard; direct image builders retain operator provenance.
ARG NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE=operator

Check warning on line 1591 in Dockerfile

View workflow job for this annotation

GitHub Actions / PR build and direct managed startup (OpenClaw)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ARG "NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
# Compatibility build arg for older custom Dockerfiles and rebuild tooling.
# NemoClaw-managed images intentionally do not consume it; gateway auth tokens
# are generated at container startup and are never baked into image layers.
Expand Down Expand Up @@ -1621,7 +1622,7 @@
# NEMOCLAW_MESSAGING_PLAN_B64 intentionally remains ARG-only: Docker exposes it
# to build RUN processes without retaining the full plan in the final image env.
# Direct ARG interpolation into inline source is a code injection vector (C-2).
ENV NEMOCLAW_MODEL=${NEMOCLAW_MODEL} \

Check warning on line 1625 in Dockerfile

View workflow job for this annotation

GitHub Actions / PR build and direct managed startup (OpenClaw)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "NEMOCLAW_DEVICE_AUTH_OPT_OUT_SOURCE") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/

Check warning on line 1625 in Dockerfile

View workflow job for this annotation

GitHub Actions / PR build and direct managed startup (OpenClaw)

Sensitive data should not be used in the ARG or ENV commands

SecretsUsedInArgOrEnv: Do not use ARG or ENV instructions for sensitive data (ENV "NEMOCLAW_DISABLE_DEVICE_AUTH") More info: https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
NEMOCLAW_INFERENCE_PROVIDER_ID=${NEMOCLAW_INFERENCE_PROVIDER_ID} \
NEMOCLAW_UPSTREAM_PROVIDER=${NEMOCLAW_UPSTREAM_PROVIDER} \
NEMOCLAW_PRIMARY_MODEL_REF=${NEMOCLAW_PRIMARY_MODEL_REF} \
Expand Down
181 changes: 181 additions & 0 deletions scripts/lib/refresh-openclaw-wechat-placeholder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#!/usr/bin/env python3
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
# SPDX-License-Identifier: Apache-2.0

import json
import os
import re
import secrets
import stat
import sys

config_file = os.path.abspath(sys.argv[1])
openclaw_dir = os.path.dirname(config_file)
env_key = "WECHAT_BOT_TOKEN"
canonical = f"openshell:resolve:env:{env_key}"
scoped_re = re.compile(rf"^openshell:resolve:env:v[0-9]+_{env_key}$")


def fail(message):
print(f"[SECURITY] Refusing WeChat provider placeholder refresh — {message}", file=sys.stderr)
raise SystemExit(1)


def safe_account_id(value):
return (
isinstance(value, str)
and value
and value == value.strip()
and value not in {".", ".."}
and ".." not in value
and "/" not in value
and "\\" not in value
and not any(ord(char) < 32 or ord(char) == 127 for char in value)
)


if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
fail("the platform cannot enforce no-follow account traversal")

close_on_exec = getattr(os, "O_CLOEXEC", 0)
directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | close_on_exec
file_flags = os.O_RDONLY | os.O_NOFOLLOW | close_on_exec

descriptors = []
try:
try:
root_fd = os.open(openclaw_dir, directory_flags)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
descriptors.append(root_fd)
config_fd = os.open("openclaw.json", file_flags, dir_fd=root_fd)
try:
config_metadata = os.fstat(config_fd)
if not stat.S_ISREG(config_metadata.st_mode) or config_metadata.st_nlink != 1:
fail("openclaw.json is not a single regular file")
with os.fdopen(config_fd, "r", encoding="utf-8") as stream:
config_fd = -1
config = json.load(stream)
finally:
if config_fd >= 0:
os.close(config_fd)
except (OSError, ValueError, json.JSONDecodeError):
fail("openclaw.json is unreadable or unsafe")

channels = config.get("channels") if isinstance(config, dict) else None
channel = channels.get("openclaw-weixin") if isinstance(channels, dict) else None
if not isinstance(channel, dict) or channel.get("enabled") is False:
raise SystemExit(0)

accounts = channel.get("accounts")
if not isinstance(accounts, dict):
raise SystemExit(0)

account_ids = []
for account_id, account in accounts.items():
if not isinstance(account, dict) or account.get("enabled") is False:
continue
if not safe_account_id(account_id):
fail("active WeChat configuration contains an unsafe account id")
account_ids.append(account_id)

if not account_ids:
raise SystemExit(0)

runtime_placeholder = os.environ.get(env_key, "")
if not scoped_re.fullmatch(runtime_placeholder):
if not runtime_placeholder:
fail(f"{env_key} is missing from the runtime environment")
if not runtime_placeholder.startswith("openshell:resolve:env:"):
fail(f"{env_key} is not an OpenShell placeholder; raw credentials stay out of account files")
fail(f"{env_key} is not the required revision-scoped OpenShell placeholder")

try:
plugin_fd = os.open("openclaw-weixin", directory_flags, dir_fd=root_fd)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
descriptors.append(plugin_fd)
accounts_fd = os.open("accounts", directory_flags, dir_fd=plugin_fd)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
descriptors.append(accounts_fd)
except OSError:
fail("the managed account directory is missing or unsafe")

pending = []
for account_id in sorted(account_ids):
filename = f"{account_id}.json"
try:
account_fd = os.open(filename, file_flags, dir_fd=accounts_fd)
except OSError:
fail("a managed account file is missing or unsafe")
try:
metadata = os.fstat(account_fd)
if not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1:
fail("a managed account file is not a single regular file")
if stat.S_IMODE(metadata.st_mode) & 0o077:
Comment thread
senthilr-nv marked this conversation as resolved.
Outdated
fail("a managed account file is accessible outside its owner")
try:
with os.fdopen(os.dup(account_fd), "r", encoding="utf-8") as stream:
account_data = json.load(stream)
except Exception:
fail("a managed account file is unreadable")
finally:
os.close(account_fd)

if not isinstance(account_data, dict) or not isinstance(account_data.get("token"), str):
fail("a managed account file has no valid token field")
current = account_data["token"]
if current == runtime_placeholder:
continue
if current != canonical and not scoped_re.fullmatch(current):
fail("a managed account token is neither canonical nor revision-scoped")
account_data["token"] = runtime_placeholder
payload = (json.dumps(account_data, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
pending.append((filename, metadata, payload))

for filename, metadata, payload in pending:
temporary = f".{filename}.nemoclaw-{os.getpid()}-{secrets.token_hex(8)}.tmp"
temporary_created = False
try:
create_flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW
temporary_fd = os.open(temporary, create_flags, 0o600, dir_fd=accounts_fd)
temporary_created = True
try:
os.fchmod(temporary_fd, 0o600)
if os.geteuid() == 0:
os.fchown(temporary_fd, metadata.st_uid, metadata.st_gid)
offset = 0
while offset < len(payload):
offset += os.write(temporary_fd, payload[offset:])
os.fsync(temporary_fd)
finally:
os.close(temporary_fd)

current_metadata = os.stat(filename, dir_fd=accounts_fd, follow_symlinks=False)
before = (metadata.st_dev, metadata.st_ino, metadata.st_mtime_ns, metadata.st_size)
after = (
current_metadata.st_dev,
current_metadata.st_ino,
current_metadata.st_mtime_ns,
current_metadata.st_size,
)
if before != after or current_metadata.st_nlink != 1:
fail("a managed account file changed during refresh")
os.replace(
temporary,
filename,
src_dir_fd=accounts_fd,
dst_dir_fd=accounts_fd,
)
temporary_created = False
os.fsync(accounts_fd)
finally:
if temporary_created:
try:
os.unlink(temporary, dir_fd=accounts_fd)
except OSError:
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
pass

if pending:
print(
f"[config] Refreshed WeChat account provider placeholder from OpenShell runtime env: {env_key}",
file=sys.stderr,
)
finally:
for descriptor in reversed(descriptors):
os.close(descriptor)
28 changes: 24 additions & 4 deletions scripts/nemoclaw-start.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1535,16 +1535,35 @@ PYCORS

# OpenShell provider snapshots can expose revision-scoped placeholders such as
# openshell:resolve:env:v11_<ENV_KEY> in the child environment. Refresh
# baked canonical placeholders in openclaw.json after the integrity check so
# token egress keeps working across provider attach/refresh generations without
# ever writing a raw credential to disk.
# baked canonical placeholders after the integrity check so token egress keeps
# working across provider attach/refresh generations without ever writing a raw
# credential to disk.
refresh_openclaw_wechat_account_placeholder() {
Comment thread
senthilr-nv marked this conversation as resolved.
Outdated
local config_file="/sandbox/.openclaw/openclaw.json"
[ -f "$config_file" ] || return 0

# The Tencent WeChat plugin reads its credential from a separate account file.
# Store the revision-scoped OpenShell placeholder in that file through
# descriptor-relative, no-follow operations. Do not write the raw bot token
# to disk.
local helper="/usr/local/lib/nemoclaw/refresh-openclaw-wechat-placeholder.py"
if [ ! -f "$helper" ]; then
helper="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/refresh-openclaw-wechat-placeholder.py"
fi
python3 -I "$helper" "$config_file"
}

refresh_openclaw_provider_placeholders() {
local config_file="/sandbox/.openclaw/openclaw.json"
local hash_file="/sandbox/.openclaw/.config-hash"
[ -f "$config_file" ] || return 0

# Refresh the separate WeChat account registry before generic provider files
# so an invalid placeholder or unsafe account leaves those files unchanged.
refresh_openclaw_wechat_account_placeholder || return $?

if [ "$(openclaw_config_dir_owner "$(dirname "$config_file")")" = "root" ]; then
printf '[config] Shields are up; preserving sealed provider placeholders unchanged\n' >&2
printf '[config] Shields are up; preserving sealed openclaw.json provider placeholders unchanged\n' >&2
return 0
fi

Expand Down Expand Up @@ -1874,6 +1893,7 @@ PYPLACEHOLDERS

restore_openclaw_config_after_write "$config_file" "$hash_file"
[ "$_write_rc" -eq 0 ] || return "$_write_rc"
return 0
}

# ── Messaging runtime setup from manifest metadata ───────────────
Expand Down
8 changes: 8 additions & 0 deletions src/lib/actions/sandbox/policy-channel-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ type LegacyOnboardProvidersModule = {
};

type RebuildModule = typeof import("./rebuild");
type PrivilegedExecModule = typeof import("../../sandbox/privileged-exec");
type SetupInferenceModule = typeof import("../../onboard/setup-inference");
type SandboxProviderCleanupModule = typeof import("../../onboard/sandbox-provider-cleanup");
type PolicyModule = typeof import("../../policy");
Expand Down Expand Up @@ -64,6 +65,13 @@ function gatewayRunner(gatewayName: string): typeof runOpenshell {
* onboarding and rebuild modules at policy-channel import time.
*/
export const policyChannelDependencies = {
/** Use a stopped Docker volume only when the sandbox entrypoint blocks normal cleanup. */
clearStoppedDockerSandboxChannelState(
sandboxName: string,
): ReturnType<PrivilegedExecModule["clearStoppedDockerSandboxChannelState"]> {
const cleanup = require("../../sandbox/privileged-exec") as PrivilegedExecModule;
return cleanup.clearStoppedDockerSandboxChannelState(sandboxName);
},
deleteMessagingProviderWithRecovery(
providerName: string,
sandboxName: string,
Expand Down
73 changes: 53 additions & 20 deletions src/lib/actions/sandbox/policy-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type SandboxMessagingPlan,
toMessagingAgentId,
tryGetMessagingAgentId,
WECHAT_OPENCLAW_STATE_PATHS,
} from "../../messaging";
import { findChannelConflicts } from "../../messaging/applier/conflict-detection/registry";
import type { GooglechatNonInteractiveAudienceCapability } from "../../messaging/channels/googlechat/hooks/tunnel-audience-gate";
Expand Down Expand Up @@ -1685,8 +1686,14 @@ export function applyChannelPresetIfAvailable(
}
}

function getSandboxChannelStatePaths(agent: AgentDefinition, channelName: string): string[] {
function getSandboxChannelStatePaths(
agent: AgentDefinition,
channelName: string,
): readonly string[] {
const configDir = agent.configPaths.dir;
if (agent.name === "openclaw" && channelName === "wechat") {
return WECHAT_OPENCLAW_STATE_PATHS;
}
const stateDirs = new Set(agent.stateDirs);
const paths: string[] = [];
const isHermesWhatsapp = agent.name === "hermes" && channelName === "whatsapp";
Expand Down Expand Up @@ -1715,14 +1722,25 @@ function isSafeChannelStatePath(p: string): boolean {
}

const CHANNEL_CLEAR_SENTINEL = "NEMOCLAW_CHANNEL_CLEAR_OK";
const STOPPED_WECHAT_CLEANUP_FAILURE_GUIDANCE = {
"sandbox-registry-unavailable": "Restore the NemoClaw sandbox registry entry.",
"driver-not-docker": "Restore normal OpenShell lifecycle access for this non-Docker sandbox.",
"docker-discovery-failed": "Start Docker or restore access to its daemon.",
"no-eligible-stopped-container": "Restore the registered stopped OpenShell container.",
"container-ownership-invalid": "Reconcile the sandbox registry and Docker container identity.",
"container-inspection-failed": "Restore Docker inspection access for the stopped container.",
"container-not-stopped": "Stop the registered sandbox container before retrying removal.",
"sandbox-volume-unavailable": "Restore a single writable Docker volume at /sandbox.",
"cleanup-command-failed": "Repair the stopped sandbox image or volume permissions.",
"container-revalidation-failed": "Reconcile the stopped container identity and state.",
"lifecycle-authority-unavailable": "Finish the active lifecycle transition or repair its lock.",
} as const;

// 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.
/**
* Wipe durable channel state before rebuild can preserve an obsolete auth blob.
* OpenShell exec runs first, followed by SSH and the stopped WeChat Docker fallback.
* Fixes #3998.
*/
function clearSandboxChannelDurableState(sandboxName: string, channelName: string): boolean {
const agent = resolveAgentForSandbox(sandboxName);
const paths = getSandboxChannelStatePaths(agent, channelName).filter(isSafeChannelStatePath);
Expand All @@ -1737,6 +1755,18 @@ function clearSandboxChannelDurableState(sandboxName: string, channelName: strin
if (!sentinelSeen(result)) {
result = executeSandboxCommand(sandboxName, cmd);
}
if (!sentinelSeen(result) && agent.name === "openclaw" && channelName === "wechat") {
const stoppedCleanup =
policyChannelDependencies.clearStoppedDockerSandboxChannelState(sandboxName);
if (stoppedCleanup.cleared) {
console.log(` ${G}✓${R} Cleared stopped-sandbox '${channelName}' channel state.`);
return true;
}
console.error(
` ${YW}⚠${R} Stopped-Docker cleanup failed (${stoppedCleanup.failure}). ` +
`${STOPPED_WECHAT_CLEANUP_FAILURE_GUIDANCE[stoppedCleanup.failure]} Then retry removal.`,
);
}
if (!sentinelSeen(result)) {
console.error(
` ${YW}⚠${R} Could not clear in-sandbox '${channelName}' channel state at ${paths.join(", ")}.`,
Expand Down Expand Up @@ -1868,7 +1898,8 @@ async function removeSandboxChannelUnlocked(
}

const tokenKeys = getChannelTokenKeys(channel);
const isQrChannel = channelUsesInSandboxQrPairing(channel);
const requiresStateCleanupBeforeTeardown =
channelUsesInSandboxQrPairing(channel) || canonical === "wechat";

const registryEntry = registry.getSandbox(sandboxName);
let sessionForSandbox: ReturnType<typeof onboardSession.loadSession> = null;
Expand Down Expand Up @@ -1905,24 +1936,26 @@ async function removeSandboxChannelUnlocked(
}
}

// 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).
// Channels with durable account or session state 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.
// OpenClaw WeChat can additionally recover through a stopped Docker volume
// helper because the same missing account file may block its entrypoint.
// 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. Removing a
// never-configured/already-clean channel must remain a quiet no-op even when
// the sandbox is stopped (#4001 review).
if (
isQrChannel &&
requiresStateCleanupBeforeTeardown &&
Comment thread
senthilr-nv marked this conversation as resolved.
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}`,
` Restore sandbox lifecycle access or follow the cleanup diagnostic above, then re-run: ${CLI_NAME} ${sandboxName} channels remove ${canonical}`,
);
process.exit(1);
}
Expand Down Expand Up @@ -1976,7 +2009,7 @@ async function removeSandboxChannelUnlocked(
// 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) {
if (!requiresStateCleanupBeforeTeardown) {
clearSandboxChannelDurableState(sandboxName, canonical);
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/messaging/channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,4 @@ export * from "./metadata";
export * from "./policy";
export * from "./rendered-config-parser";
export { createBuiltInRenderTemplateResolver } from "./template-resolver";
export { WECHAT_OPENCLAW_STATE_PATHS } from "./wechat/contract";
Loading
Loading