fix: stop channel runtime before WhatsApp removal

This commit is contained in:
Peter Steinberger
2026-05-01 22:36:58 +01:00
parent 4373103c22
commit fe8966b4ea
15 changed files with 344 additions and 4 deletions

View File

@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Slack/setup: print the generated app manifest as plain JSON instead of embedding it inside the framed setup note, so it can be copied into Slack without deleting border characters. Fixes #65751. Thanks @theDanielJLewis.
- Channels/WhatsApp: route CLI logout through the live Gateway and stop runtime-backed listeners before channel removal, so removing a WhatsApp account does not leave the old socket replying until restart. Fixes #67746. Thanks @123Mismail.
- Agents/Codex: stop prompting message-tool-only source turns to finish with `NO_REPLY`, so quiet turns are represented by not calling the visible message tool instead of conflicting final-text instructions. Thanks @pashpashpash.
- Gateway/config: report failed backup restores as failed in logs and config observe audit records instead of marking them valid. (#70515) Thanks @davidangularme.
- Compaction: use the active session model fallback chain for implicit summarization failures without persisting fallback model selection, so Azure content-filter 400s can recover. Fixes #64960. (#74470) Thanks @jalehman and @OpenCodeEngineer.

View File

@@ -492,6 +492,8 @@ Behavior notes:
<Accordion title="Logout behavior">
`openclaw channels logout --channel whatsapp [--account <id>]` clears WhatsApp auth state for that account.
When a Gateway is reachable, logout first stops the live WhatsApp listener for the selected account so the linked session does not keep receiving messages until the next restart. `openclaw channels remove --channel whatsapp` also stops the live listener before disabling or deleting account config.
In legacy auth directories, `oauth.json` is preserved while Baileys auth files are removed.
</Accordion>

View File

@@ -52,6 +52,7 @@ openclaw channels remove --channel telegram --delete
</Tip>
`channels remove` only operates on installed/configured channel plugins. Use `channels add` first for installable catalog channels.
For runtime-backed channel plugins, `channels remove` also asks the running Gateway to stop the selected account before it updates config, so disabling or deleting an account does not leave the old listener active until restart.
Common non-interactive add surfaces include:
@@ -94,6 +95,7 @@ openclaw channels logout --channel whatsapp
- `channels login` supports `--verbose`.
- `channels login` and `logout` can infer the channel when only one supported login target is configured.
- `channels logout` prefers the live Gateway path when reachable, so logout stops any active listener before clearing channel auth state. If a local Gateway is not reachable, it falls back to local auth cleanup.
- Run `channels login` from a terminal on the gateway host. Agent `exec` blocks this interactive login flow; channel-native agent login tools, such as `whatsapp_login`, should be used from chat when available.
## Troubleshooting

View File

@@ -260,9 +260,10 @@ describe("channel-auth", () => {
await runChannelLogout({}, runtime);
expect(mocks.logoutAccount).toHaveBeenCalledWith(
expect(mocks.callGateway).toHaveBeenCalledWith(
expect.objectContaining({
cfg: autoEnabledCfg,
config: autoEnabledCfg,
method: "channels.logout",
}),
);
expect(mocks.replaceConfigFile).toHaveBeenCalledWith({
@@ -518,7 +519,28 @@ describe("channel-auth", () => {
);
});
it("runs logout with resolved account and explicit account id", async () => {
it("runs logout through the live gateway with resolved account and explicit account id", async () => {
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);
expect(mocks.callGateway).toHaveBeenCalledWith({
config: { channels: { whatsapp: {} } },
method: "channels.logout",
params: {
channel: "whatsapp",
accountId: "acct-2",
},
mode: "backend",
clientName: "gateway-client",
deviceIdentity: null,
});
expect(mocks.resolveAccount).not.toHaveBeenCalled();
expect(mocks.logoutAccount).not.toHaveBeenCalled();
expect(mocks.setVerbose).not.toHaveBeenCalled();
});
it("falls back to local auth cleanup when a local gateway logout is unreachable", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway unreachable"));
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);
expect(mocks.resolveAccount).toHaveBeenCalledWith({ channels: { whatsapp: {} } }, "acct-2");
@@ -528,6 +550,9 @@ describe("channel-auth", () => {
account: { id: "resolved-account" },
runtime,
});
expect(runtime.log).toHaveBeenCalledWith(
expect.stringContaining("running gateway did not stop it: gateway unreachable"),
);
expect(mocks.setVerbose).not.toHaveBeenCalled();
});

View File

@@ -168,6 +168,36 @@ async function reconcileGatewayRuntimeAfterLocalLogin(params: {
}
}
async function logoutViaGatewayRuntime(params: {
cfg: OpenClawConfig;
channelId: string;
accountId: string;
runtime: RuntimeEnv;
}): Promise<boolean> {
try {
await callGateway({
config: params.cfg,
method: "channels.logout",
params: {
channel: params.channelId,
accountId: params.accountId,
},
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
return true;
} catch (error) {
if (params.cfg.gateway?.mode === "remote") {
throw error;
}
params.runtime.log(
`Local logout will clear auth for ${params.channelId}/${params.accountId}, but the running gateway did not stop it: ${formatErrorMessage(error)}`,
);
return false;
}
}
export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -235,8 +265,18 @@ export async function runChannelLogout(
if (!logoutAccount) {
throw new Error(`Channel ${channelInput} does not support logout`);
}
// Auth-only flow: resolve account + clear session state only.
// Prefer the live gateway so logout also stops any active channel runtime.
const { accountId } = resolveAccountContext(plugin, opts, cfg);
if (
await logoutViaGatewayRuntime({
cfg,
channelId: plugin.id,
accountId,
runtime,
})
) {
return;
}
const account = plugin.config.resolveAccount(cfg, accountId);
await logoutAccount({
cfg,

View File

@@ -1,5 +1,6 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import {
@@ -23,6 +24,10 @@ const registryRefreshMocks = vi.hoisted(() => ({
refreshPluginRegistryAfterConfigMutation: vi.fn(async () => undefined),
}));
const gatewayMocks = vi.hoisted(() => ({
callGateway: vi.fn(async () => ({ stopped: true })),
}));
vi.mock("../channels/plugins/catalog.js", async () => {
const actual = await vi.importActual<typeof import("../channels/plugins/catalog.js")>(
"../channels/plugins/catalog.js",
@@ -54,6 +59,10 @@ vi.mock("./channel-setup/plugin-install.js", async () => {
vi.mock("../cli/plugins-registry-refresh.js", () => registryRefreshMocks);
vi.mock("../gateway/call.js", () => ({
callGateway: gatewayMocks.callGateway,
}));
const runtime = createTestRuntime();
describe("channelsRemoveCommand", () => {
@@ -86,6 +95,8 @@ describe("channelsRemoveCommand", () => {
createTestRegistry(),
);
registryRefreshMocks.refreshPluginRegistryAfterConfigMutation.mockClear();
gatewayMocks.callGateway.mockClear();
gatewayMocks.callGateway.mockResolvedValue({ stopped: true });
setActivePluginRegistry(createTestRegistry());
});
@@ -170,4 +181,71 @@ describe("channelsRemoveCommand", () => {
expect(runtime.error).not.toHaveBeenCalled();
expect(runtime.exit).not.toHaveBeenCalled();
});
it("stops an active gateway channel runtime before deleting a runtime-backed account", async () => {
configMocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
"external-chat": {
enabled: true,
token: "token-1",
},
},
},
});
const catalogEntry: ChannelPluginCatalogEntry = createExternalChatCatalogEntry();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
const scopedPlugin = {
...createExternalChatDeletePlugin(),
gateway: {
startAccount: vi.fn(),
},
} as ChannelPlugin;
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
createTestRegistry([
{
pluginId: "@vendor/external-chat-plugin",
plugin: scopedPlugin,
source: "test",
},
]),
);
await channelsRemoveCommand(
{
channel: "external-chat",
account: "default",
delete: true,
},
runtime,
{ hasFlags: true },
);
expect(gatewayMocks.callGateway).toHaveBeenCalledWith({
config: {
channels: {
"external-chat": {
enabled: true,
token: "token-1",
},
},
},
method: "channels.stop",
params: {
channel: "external-chat",
accountId: "default",
},
mode: "backend",
clientName: "gateway-client",
deviceIdentity: null,
});
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
expect.not.objectContaining({
channels: expect.objectContaining({
"external-chat": expect.anything(),
}),
}),
);
});
});

View File

@@ -5,9 +5,12 @@ import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import { commitConfigWithPendingPluginInstalls } from "../../cli/plugins-install-record-commit.js";
import { refreshPluginRegistryAfterConfigMutation } from "../../cli/plugins-registry-refresh.js";
import { replaceConfigFile, type OpenClawConfig } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { normalizeOptionalString } from "../../shared/string-coerce.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { channelLabel } from "./runtime-label.js";
import { type ChatChannel, requireValidConfigFileSnapshot, shouldUseWizard } from "./shared.js";
@@ -30,6 +33,35 @@ function listAccountIds(
return plugin.config.listAccountIds(cfg);
}
async function stopGatewayRuntimeBeforeRemove(params: {
cfg: OpenClawConfig;
channel: ChatChannel;
accountId: string;
plugin: ChannelPlugin;
runtime: RuntimeEnv;
}) {
if (!params.plugin.gateway?.startAccount && !params.plugin.gateway?.logoutAccount) {
return;
}
try {
await callGateway({
config: params.cfg,
method: "channels.stop",
params: {
channel: params.channel,
accountId: params.accountId,
},
mode: GATEWAY_CLIENT_MODES.BACKEND,
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
deviceIdentity: null,
});
} catch (error) {
params.runtime.log(
`Could not stop running ${channelLabel(params.channel)} account "${params.accountId}" before removing it: ${formatErrorMessage(error)}`,
);
}
}
export async function channelsRemoveCommand(
opts: ChannelsRemoveOptions,
runtime: RuntimeEnv = defaultRuntime,
@@ -147,6 +179,14 @@ export async function channelsRemoveCommand(
normalizeAccountId(accountId) ?? resolveChannelDefaultAccountId({ plugin, cfg });
const accountKey = resolvedAccountId || DEFAULT_ACCOUNT_ID;
await stopGatewayRuntimeBeforeRemove({
cfg,
channel: resolvedChannelId,
accountId: accountKey,
plugin,
runtime,
});
let next = { ...cfg };
const prevCfg = cfg;
if (deleteConfig) {

View File

@@ -169,6 +169,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
],
[ADMIN_SCOPE]: [
"channels.start",
"channels.stop",
"channels.logout",
"agents.create",
"agents.update",

View File

@@ -57,6 +57,8 @@ import {
AgentWaitParamsSchema,
type ChannelsStartParams,
ChannelsStartParamsSchema,
type ChannelsStopParams,
ChannelsStopParamsSchema,
type ChannelsLogoutParams,
ChannelsLogoutParamsSchema,
type TalkConfigParams,
@@ -528,6 +530,7 @@ export const validateChannelsStatusParams = ajv.compile<ChannelsStatusParams>(
);
export const validateChannelsStartParams =
ajv.compile<ChannelsStartParams>(ChannelsStartParamsSchema);
export const validateChannelsStopParams = ajv.compile<ChannelsStopParams>(ChannelsStopParamsSchema);
export const validateChannelsLogoutParams = ajv.compile<ChannelsLogoutParams>(
ChannelsLogoutParamsSchema,
);
@@ -740,6 +743,7 @@ export {
ChannelsStatusParamsSchema,
ChannelsStatusResultSchema,
ChannelsStartParamsSchema,
ChannelsStopParamsSchema,
ChannelsLogoutParamsSchema,
WebLoginStartParamsSchema,
WebLoginWaitParamsSchema,
@@ -854,6 +858,7 @@ export type {
ChannelsStatusParams,
ChannelsStatusResult,
ChannelsStartParams,
ChannelsStopParams,
ChannelsLogoutParams,
WebLoginStartParams,
WebLoginWaitParams,

View File

@@ -310,6 +310,14 @@ export const ChannelsLogoutParamsSchema = Type.Object(
{ additionalProperties: false },
);
export const ChannelsStopParamsSchema = Type.Object(
{
channel: NonEmptyString,
accountId: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const ChannelsStartParamsSchema = Type.Object(
{
channel: NonEmptyString,

View File

@@ -63,6 +63,7 @@ import {
} from "./artifacts.js";
import {
ChannelsStartParamsSchema,
ChannelsStopParamsSchema,
ChannelsLogoutParamsSchema,
TalkConfigParamsSchema,
TalkConfigResultSchema,
@@ -328,6 +329,7 @@ export const ProtocolSchemas = {
ChannelsStatusParams: ChannelsStatusParamsSchema,
ChannelsStatusResult: ChannelsStatusResultSchema,
ChannelsStartParams: ChannelsStartParamsSchema,
ChannelsStopParams: ChannelsStopParamsSchema,
ChannelsLogoutParams: ChannelsLogoutParamsSchema,
WebLoginStartParams: WebLoginStartParamsSchema,
WebLoginWaitParams: WebLoginWaitParamsSchema,

View File

@@ -99,6 +99,7 @@ export type TalkSpeakResult = SchemaType<"TalkSpeakResult">;
export type ChannelsStatusParams = SchemaType<"ChannelsStatusParams">;
export type ChannelsStatusResult = SchemaType<"ChannelsStatusResult">;
export type ChannelsStartParams = SchemaType<"ChannelsStartParams">;
export type ChannelsStopParams = SchemaType<"ChannelsStopParams">;
export type ChannelsLogoutParams = SchemaType<"ChannelsLogoutParams">;
export type WebLoginStartParams = SchemaType<"WebLoginStartParams">;
export type WebLoginWaitParams = SchemaType<"WebLoginWaitParams">;

View File

@@ -15,6 +15,7 @@ const BASE_METHODS = [
"logs.tail",
"channels.status",
"channels.start",
"channels.stop",
"channels.logout",
"status",
"usage.status",

View File

@@ -40,6 +40,7 @@ function createOptions(
getRuntimeConfig: mocks.getRuntimeConfig,
startChannel: vi.fn(),
stopChannel: vi.fn(),
markChannelLoggedOut: vi.fn(),
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {
@@ -178,6 +179,63 @@ describe("channelsHandlers channels.start", () => {
});
});
describe("channelsHandlers channels.stop", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getRuntimeConfig.mockReturnValue({});
mocks.getChannelPlugin.mockReturnValue({
id: "whatsapp",
config: {
defaultAccountId: () => "default-account",
listAccountIds: () => ["default-account"],
resolveAccount: () => ({}),
},
});
});
it("stops a channel account without clearing auth state", async () => {
const stopChannel = vi.fn(async () => undefined);
const respond = vi.fn();
await channelsHandlers["channels.stop"](
createOptions(
{ channel: "whatsapp" },
{
respond,
context: {
getRuntimeConfig: mocks.getRuntimeConfig,
stopChannel,
getRuntimeSnapshot: vi.fn(
(): ChannelRuntimeSnapshot => ({
channels: {},
channelAccounts: {
whatsapp: {
"default-account": {
accountId: "default-account",
running: false,
},
},
},
}),
),
} as unknown as GatewayRequestHandlerOptions["context"],
},
),
);
expect(stopChannel).toHaveBeenCalledWith("whatsapp", "default-account");
expect(respond).toHaveBeenCalledWith(
true,
{
channel: "whatsapp",
accountId: "default-account",
stopped: true,
},
undefined,
);
});
});
describe("channelsHandlers channels.logout", () => {
beforeEach(() => {
vi.clearAllMocks();

View File

@@ -22,6 +22,7 @@ import {
errorShape,
formatValidationErrors,
validateChannelsStartParams,
validateChannelsStopParams,
validateChannelsLogoutParams,
validateChannelsStatusParams,
} from "../protocol/index.js";
@@ -42,6 +43,12 @@ type ChannelStartPayload = {
started: boolean;
};
type ChannelStopPayload = {
channel: ChannelId;
accountId: string;
stopped: boolean;
};
const CHANNEL_STATUS_MAX_TIMEOUT_MS = 30_000;
const CHANNEL_STATUS_PROBE_CONCURRENCY = 5;
@@ -138,6 +145,29 @@ export async function startChannelAccount(params: {
};
}
export async function stopChannelAccount(params: {
channelId: ChannelId;
accountId?: string | null;
cfg: OpenClawConfig;
context: GatewayRequestContext;
plugin: ChannelPlugin;
}): Promise<ChannelStopPayload> {
const resolvedAccountId = resolveChannelGatewayAccountId(params);
await params.context.stopChannel(params.channelId, resolvedAccountId);
const runtime = params.context.getRuntimeSnapshot();
const stopped =
resolveRuntimeAccountSnapshot({
runtime,
channelId: params.channelId,
accountId: resolvedAccountId,
})?.running !== true;
return {
channel: params.channelId,
accountId: resolvedAccountId,
stopped,
};
}
export const channelsHandlers: GatewayRequestHandlers = {
"channels.status": async ({ params, respond, context }) => {
if (!validateChannelsStatusParams(params)) {
@@ -391,6 +421,52 @@ export const channelsHandlers: GatewayRequestHandlers = {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
}
},
"channels.stop": async ({ params, respond, context }) => {
if (!validateChannelsStopParams(params)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`invalid channels.stop params: ${formatValidationErrors(validateChannelsStopParams.errors)}`,
),
);
return;
}
const rawChannel = (params as { channel?: unknown }).channel;
const channelId = typeof rawChannel === "string" ? normalizeChannelId(rawChannel) : null;
if (!channelId) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, "invalid channels.stop channel"),
);
return;
}
const plugin = getChannelPlugin(channelId);
if (!plugin) {
respond(
false,
undefined,
errorShape(ErrorCodes.INVALID_REQUEST, `unknown channel ${channelId}`),
);
return;
}
const accountIdRaw = (params as { accountId?: unknown }).accountId;
const accountId = normalizeOptionalString(accountIdRaw);
try {
const payload = await stopChannelAccount({
channelId,
accountId,
cfg: context.getRuntimeConfig(),
context,
plugin,
});
respond(true, payload, undefined);
} catch (error) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, formatForLog(error)));
}
},
"channels.logout": async ({ params, respond, context }) => {
if (!validateChannelsLogoutParams(params)) {
respond(