fix(channels): keep ownerless config visible but undeliverable

This commit is contained in:
Vincent Koc
2026-06-22 19:01:20 +08:00
parent 482e6cb5cb
commit a641c0d560
9 changed files with 824 additions and 44 deletions
@@ -34,6 +34,7 @@ PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log"
AGENT_LOG="$LOG_DIR/agent.log"
STATUS_JSON="$LOG_DIR/status.json"
STATUS_ERR="$LOG_DIR/status.err"
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
@@ -77,6 +78,7 @@ dump_debug_logs() {
"$PLUGIN_CLI_AFTER_LOG" \
"$AGENT_LOG" \
"$STATUS_JSON" \
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$CLICKCLACK_SERVER_LOG" \
"$GATEWAY_LOG" \
@@ -162,6 +164,10 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCES
openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong"
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
openclaw message send \
@@ -35,6 +35,7 @@ PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
CLICKCLACK_PLUGIN_INSTALL_LOG="$LOG_DIR/clickclack-plugin-install.log"
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
@@ -77,6 +78,7 @@ dump_debug_logs() {
"$PLUGIN_A_UNINSTALL_LOG" \
"$PLUGIN_B_INSTALL_LOG" \
"$PLUGIN_B_CLI_LOG" \
"$CLICKCLACK_PLUGIN_INSTALL_LOG" \
"$CLICKCLACK_SERVER_LOG" \
"$CLICKCLACK_OUTBOUND_JSON" \
"$GATEWAY_1_LOG" \
@@ -215,6 +217,11 @@ openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
echo "Installing ClickClack fixture plugin..."
clickclack_plugin_dir="$(mktemp -d "$scenario_tmp/clickclack-plugin.XXXXXX")"
node scripts/e2e/lib/release-user-journey/write-clickclack-plugin.mjs "$clickclack_plugin_dir"
openclaw plugins install "$clickclack_plugin_dir" >"$CLICKCLACK_PLUGIN_INSTALL_LOG" 2>&1
echo "Configuring ClickClack..."
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
@@ -0,0 +1,427 @@
#!/usr/bin/env node
// Writes the external ClickClack channel fixture used by release journey E2Es.
import fs from "node:fs";
import path from "node:path";
const pluginDir = process.argv[2];
if (!pluginDir) {
console.error("usage: write-clickclack-plugin.mjs <plugin-dir>");
process.exit(2);
}
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(
path.join(pluginDir, "package.json"),
`${JSON.stringify(
{
name: "clickclack",
version: "0.0.1",
type: "module",
openclaw: { extensions: ["./index.mjs"] },
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
`${JSON.stringify(
{
id: "clickclack",
activation: { onStartup: false },
channels: ["clickclack"],
channelEnvVars: { clickclack: ["CLICKCLACK_BOT_TOKEN"] },
channelConfigs: {
clickclack: {
schema: {
type: "object",
additionalProperties: true,
properties: {
enabled: { type: "boolean", default: true },
baseUrl: { type: "string" },
workspace: { type: "string" },
defaultTo: { type: "string" },
token: {},
},
},
},
},
configSchema: {
type: "object",
additionalProperties: false,
properties: {},
},
},
null,
2,
)}\n`,
);
fs.writeFileSync(
path.join(pluginDir, "index.mjs"),
`import crypto from "node:crypto";
import net from "node:net";
const CHANNEL_ID = "clickclack";
const DEFAULT_ACCOUNT_ID = "default";
function configFor(cfg) {
return cfg?.channels?.clickclack ?? {};
}
function readToken(raw) {
if (typeof raw === "string") {
return raw.trim();
}
if (raw && typeof raw === "object" && raw.source === "env" && typeof raw.id === "string") {
return String(process.env[raw.id] ?? "").trim();
}
return String(process.env.CLICKCLACK_BOT_TOKEN ?? "").trim();
}
function resolveAccount(cfg, accountId = DEFAULT_ACCOUNT_ID) {
const config = configFor(cfg);
const token = readToken(config.token);
const baseUrl = typeof config.baseUrl === "string" ? config.baseUrl : "";
return {
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
enabled: config.enabled !== false,
configured: Boolean(baseUrl && token),
baseUrl,
token,
workspace: typeof config.workspace === "string" && config.workspace ? config.workspace : "release",
defaultTo: typeof config.defaultTo === "string" ? config.defaultTo : "channel:general",
reconnectMs: Number.isFinite(config.reconnectMs) ? Math.max(50, Number(config.reconnectMs)) : 250,
};
}
async function requestJson(account, method, pathname, body) {
const response = await fetch(new URL(pathname, account.baseUrl), {
method,
headers: {
authorization: \`Bearer \${account.token}\`,
...(body == null ? {} : { "content-type": "application/json" }),
},
...(body == null ? {} : { body: JSON.stringify(body) }),
});
if (!response.ok) {
throw new Error(\`ClickClack fixture \${response.status}: \${await response.text()}\`);
}
return await response.json();
}
async function resolveWorkspaceId(account) {
const data = await requestJson(account, "GET", "/api/workspaces");
const workspaces = Array.isArray(data.workspaces) ? data.workspaces : [];
const match = workspaces.find((workspace) =>
workspace?.id === account.workspace ||
workspace?.slug === account.workspace ||
workspace?.name === account.workspace
);
if (!match?.id) {
throw new Error(\`ClickClack workspace not found: \${account.workspace}\`);
}
return match.id;
}
async function resolveChannelId(account, workspaceId, rawTarget) {
const target = String(rawTarget ?? "").trim();
const channelName = target.startsWith("channel:") ? target.slice("channel:".length) : target;
const data = await requestJson(account, "GET", \`/api/workspaces/\${encodeURIComponent(workspaceId)}/channels\`);
const channels = Array.isArray(data.channels) ? data.channels : [];
const match = channels.find((channel) => channel?.id === channelName || channel?.name === channelName);
if (!match?.id) {
throw new Error(\`ClickClack channel not found: \${channelName}\`);
}
return match.id;
}
async function sendText(cfg, to, text, accountId, threadId, replyToId) {
const account = resolveAccount(cfg, accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
const rootId = threadId == null ? String(replyToId ?? "") : String(threadId);
if (rootId) {
const data = await requestJson(
account,
"POST",
\`/api/messages/\${encodeURIComponent(rootId)}/thread/replies\`,
{ body: text },
);
return data.message;
}
const channelId = await resolveChannelId(account, workspaceId, to);
const data = await requestJson(account, "POST", \`/api/channels/\${encodeURIComponent(channelId)}/messages\`, {
body: text,
});
return data.message;
}
function decodeFrame(buffer) {
if (buffer.length < 2) {
return null;
}
const opcode = buffer[0] & 0x0f;
let length = buffer[1] & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) {
return null;
}
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) {
return null;
}
length = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
if (buffer.length < offset + length) {
return null;
}
return {
opcode,
text: buffer.subarray(offset, offset + length).toString("utf8"),
rest: buffer.subarray(offset + length),
};
}
function openEventSocket(account, workspaceId, afterCursor, onEvent, signal) {
const base = new URL(account.baseUrl);
const key = crypto.randomBytes(16).toString("base64");
const socket = net.createConnection({
host: base.hostname,
port: Number(base.port || (base.protocol === "https:" ? 443 : 80)),
});
let buffer = Buffer.alloc(0);
let upgraded = false;
const close = () => socket.destroy();
signal.addEventListener("abort", close, { once: true });
socket.on("connect", () => {
const query = new URLSearchParams({ workspace_id: workspaceId });
if (afterCursor) {
query.set("after_cursor", afterCursor);
}
socket.write(
[
\`GET /api/realtime/ws?\${query.toString()} HTTP/1.1\`,
\`Host: \${base.host}\`,
"Upgrade: websocket",
"Connection: Upgrade",
\`Sec-WebSocket-Key: \${key}\`,
"Sec-WebSocket-Version: 13",
\`Authorization: Bearer \${account.token}\`,
"",
"",
].join("\\r\\n"),
);
});
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
if (!upgraded) {
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
if (headerEnd === -1) {
return;
}
const headers = buffer.subarray(0, headerEnd).toString("utf8");
if (!headers.startsWith("HTTP/1.1 101")) {
socket.destroy(new Error(headers.split("\\r\\n")[0] || "websocket upgrade failed"));
return;
}
upgraded = true;
buffer = buffer.subarray(headerEnd + 4);
}
for (;;) {
const frame = decodeFrame(buffer);
if (!frame) {
return;
}
buffer = frame.rest;
if (frame.opcode === 1) {
onEvent(JSON.parse(frame.text));
} else if (frame.opcode === 8) {
socket.end();
return;
}
}
});
socket.on("close", () => signal.removeEventListener("abort", close));
return socket;
}
async function resolveEventMessage(account, event) {
if (event?.type !== "message.created" || !event.channel_id || typeof event.seq !== "number") {
return null;
}
const data = await requestJson(
account,
"GET",
\`/api/channels/\${encodeURIComponent(event.channel_id)}/messages?after_seq=\${Math.max(0, event.seq - 1)}\`,
);
const messages = Array.isArray(data.messages) ? data.messages : [];
return messages.find((message) => message?.id === event.payload?.message_id) ?? null;
}
async function dispatchInbound(ctx, account, message) {
const runtime = ctx.channelRuntime;
if (!runtime) {
throw new Error("ClickClack fixture requires channel runtime");
}
const target = \`channel:\${message.channel_id}\`;
const route = runtime.routing.resolveAgentRoute({
cfg: ctx.cfg,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: { kind: "channel", id: target },
});
const storePath = runtime.session.resolveStorePath(ctx.cfg.session?.store, {
agentId: route.agentId,
});
const previousTimestamp = runtime.session.readSessionUpdatedAt({
storePath,
sessionKey: route.sessionKey,
});
const senderName = message.author?.display_name || message.author_id || "Release User";
const body = runtime.reply.formatAgentEnvelope({
channel: "ClickClack",
from: senderName,
timestamp: new Date(message.created_at),
previousTimestamp,
envelope: runtime.reply.resolveEnvelopeFormatOptions(ctx.cfg),
body: message.body,
});
const ctxPayload = runtime.reply.finalizeInboundContext({
Body: body,
BodyForAgent: message.body,
RawBody: message.body,
CommandBody: message.body,
From: target,
To: target,
SessionKey: route.sessionKey,
AccountId: route.accountId ?? account.accountId,
ChatType: "group",
WasMentioned: true,
ConversationLabel: message.channel_id,
GroupChannel: message.channel_id,
NativeChannelId: message.channel_id,
MessageSid: message.id,
MessageSidFull: message.id,
ReplyToId: message.id,
Timestamp: message.created_at,
OriginatingChannel: CHANNEL_ID,
OriginatingTo: target,
CommandAuthorized: true,
});
await runtime.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: ctx.cfg,
dispatcherOptions: {
deliver: async (payload) => {
const text = payload && typeof payload === "object" ? String(payload.text ?? "") : "";
if (text.trim()) {
await sendText(ctx.cfg, target, text, account.accountId, message.id, message.id);
}
},
onError: (error) => {
throw error instanceof Error ? error : new Error(String(error));
},
},
});
}
const clickclackPlugin = {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "ClickClack",
selectionLabel: "ClickClack",
docsPath: "/channels/clickclack",
blurb: "Release journey ClickClack fixture.",
},
capabilities: { chatTypes: ["group"], threads: true },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
resolveAccount,
isConfigured: (account) => account.configured,
isEnabled: (account) => account.enabled,
resolveDefaultTo: ({ cfg }) => resolveAccount(cfg).defaultTo,
},
status: {
buildChannelSummary: ({ snapshot }) => ({
ok: snapshot.configured === true,
label: snapshot.configured ? "configured" : "missing config",
detail: snapshot.baseUrl ?? "",
}),
buildAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
},
outbound: {
deliveryMode: "direct",
sendText: async (ctx) => {
const message = await sendText(ctx.cfg, ctx.to, ctx.text, ctx.accountId, ctx.threadId, ctx.replyToId);
return { channel: CHANNEL_ID, messageId: message.id };
},
},
gateway: {
startAccount: async (ctx) => {
const account = resolveAccount(ctx.cfg, ctx.account.accountId);
if (!account.configured) {
throw new Error("ClickClack is not configured");
}
const workspaceId = await resolveWorkspaceId(account);
ctx.setStatus({
accountId: account.accountId,
running: true,
configured: true,
enabled: account.enabled,
baseUrl: account.baseUrl,
});
try {
while (!ctx.abortSignal.aborted) {
const socket = openEventSocket(
account,
workspaceId,
"",
(event) => {
void (async () => {
const message = await resolveEventMessage(account, event);
if (message && message.author?.kind !== "bot") {
await dispatchInbound(ctx, account, message);
}
})().catch((error) => {
ctx.log?.error?.(error instanceof Error ? error.message : String(error));
});
},
ctx.abortSignal,
);
await new Promise((resolve) => {
socket.once("close", resolve);
socket.once("error", resolve);
});
if (!ctx.abortSignal.aborted) {
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
}
}
} finally {
ctx.setStatus({ accountId: account.accountId, running: false });
}
},
},
};
export default {
id: CHANNEL_ID,
register(api) {
api.registerChannel({ plugin: clickclackPlugin });
},
};
`,
);
@@ -13,7 +13,7 @@ const mocks = vi.hoisted(() => ({
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
requireValidConfigSnapshot: vi.fn(),
listChannelPlugins: vi.fn(),
listConfiguredChannelIdsForReadOnlyScope: vi.fn((_params: unknown) => ["discord"]),
listConfiguredAnnounceChannelIdsForConfig: vi.fn((_params: unknown) => ["discord"]),
missingOfficialExternalChannels: new Set<string>(),
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
}));
@@ -41,8 +41,8 @@ vi.mock("../config/config.js", () => ({
vi.mock("../plugins/channel-plugin-ids.js", () => ({
listExplicitConfiguredChannelIdsForConfig: (config: { channels?: Record<string, unknown> }) =>
Object.keys(config.channels ?? {}),
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
listConfiguredAnnounceChannelIdsForConfig: (params: unknown) =>
mocks.listConfiguredAnnounceChannelIdsForConfig(params),
}));
vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({
@@ -108,7 +108,8 @@ vi.mock("../channels/plugins/index.js", () => ({
listChannelPlugins: () => mocks.listChannelPlugins(),
getChannelPlugin: (channel: string) =>
(mocks.listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel),
normalizeChannelId: (channel: string) => (channel === "imsg" ? "imessage" : channel),
normalizeChannelId: (channel: string) =>
channel === "clickclack" ? undefined : channel === "imsg" ? "imessage" : channel,
}));
vi.mock("../channels/plugins/read-only.js", () => ({
@@ -207,8 +208,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
mocks.requireValidConfigSnapshot.mockReset();
mocks.listChannelPlugins.mockReset();
mocks.missingOfficialExternalChannels.clear();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
mocks.listConfiguredAnnounceChannelIdsForConfig.mockClear();
mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["discord"]);
mocks.withProgress.mockClear();
mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
});
@@ -355,13 +356,15 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
await channelsStatusCommand({ channel: "imsg", json: true, probe: false }, runtime as never);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledOnce();
const readOnlyScopeRequest = mocks.listConfiguredChannelIdsForReadOnlyScope.mock
.calls[0]?.[0] as
| { config?: { secretResolved?: unknown }; includePersistedAuthState?: unknown }
expect(mocks.listConfiguredAnnounceChannelIdsForConfig).toHaveBeenCalledOnce();
const announceRequest = mocks.listConfiguredAnnounceChannelIdsForConfig.mock.calls[0]?.[0] as
| {
config?: { secretResolved?: unknown };
activationSourceConfig?: { secretResolved?: unknown };
}
| undefined;
expect(readOnlyScopeRequest?.config?.secretResolved).toBe(true);
expect(readOnlyScopeRequest?.includePersistedAuthState).toBe(false);
expect(announceRequest?.config?.secretResolved).toBe(true);
expect(announceRequest?.activationSourceConfig?.secretResolved).toBe(false);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(errors.join("\n")).not.toContain("user:pass");
expect(errors.join("\n")).not.toContain("secret-token");
@@ -378,6 +381,53 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
expect(payload.configuredChannels).toStrictEqual([]);
});
it("includes explicitly configured channels in JSON config-only fallback", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.requireValidConfigSnapshot.mockResolvedValue({
channels: { clickclack: { enabled: true } },
});
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: { channels: { clickclack: { enabled: true } } },
effectiveConfig: { channels: { clickclack: { enabled: true } } },
diagnostics: [],
});
mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["clickclack"]);
const { runtime, logs } = createCapturingTestRuntime();
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload.gatewayReachable).toBe(false);
expect(payload.configOnly).toBe(true);
expect(payload.configuredChannels).toStrictEqual(["clickclack"]);
});
it("filters explicitly configured channels in JSON config-only fallback", async () => {
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.requireValidConfigSnapshot.mockResolvedValue({
channels: { clickclack: { enabled: true }, telegram: { enabled: true } },
});
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: {
channels: { clickclack: { enabled: true }, telegram: { enabled: true } },
},
effectiveConfig: {
channels: { clickclack: { enabled: true }, telegram: { enabled: true } },
},
diagnostics: [],
});
mocks.listConfiguredAnnounceChannelIdsForConfig.mockReturnValue(["clickclack", "telegram"]);
const { runtime, logs } = createCapturingTestRuntime();
await channelsStatusCommand(
{ channel: "clickclack", json: true, probe: false },
runtime as never,
);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload.configuredChannels).toStrictEqual(["clickclack"]);
});
it("rejects invalid timeout before falling back to config-only status", async () => {
const { runtime } = createCapturingTestRuntime();
+6 -4
View File
@@ -1,5 +1,6 @@
// Implements `openclaw channels status` with gateway status and config-only fallback.
import { redactSensitiveUrlLikeString } from "@openclaw/net-policy/redact-sensitive-url";
import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce";
import { formatDocsLink } from "../../../packages/terminal-core/src/links.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { normalizeChannelId } from "../../channels/plugins/index.js";
@@ -14,7 +15,7 @@ import { isGatewaySecretRefUnavailableError } from "../../gateway/credentials.js
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js";
import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import {
appendBaseUrlBit,
@@ -222,7 +223,9 @@ export async function channelsStatusCommand(
const timeoutMs = parseTimeoutMsWithFallback(opts.timeout, opts.probe ? 30_000 : 10_000, {
invalidType: "error",
});
const requestedChannel = opts.channel ? normalizeChannelId(opts.channel) : null;
const requestedChannel = opts.channel
? (normalizeChannelId(opts.channel) ?? normalizeOptionalLowercaseString(opts.channel))
: null;
const statusLabel = opts.probe ? "Checking channel status (probe)…" : "Checking channel status…";
const shouldLogStatus = opts.json !== true && !process.stderr.isTTY;
if (shouldLogStatus) {
@@ -287,11 +290,10 @@ export async function channelsStatusCommand(
path: snapshot.path,
mode,
},
configuredChannels: listConfiguredChannelIdsForReadOnlyScope({
configuredChannels: listConfiguredAnnounceChannelIdsForConfig({
config: resolvedConfig,
activationSourceConfig: cfg,
env: process.env,
includePersistedAuthState: false,
}).filter((channelId) => !requestedChannel || channelId === requestedChannel),
});
return;
+18 -18
View File
@@ -27,11 +27,11 @@ import { isInvalidCronSessionTargetIdError } from "../../cron/session-target.js"
import type { CronDelivery, CronJob, CronJobCreate, CronJobPatch } from "../../cron/types.js";
import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { listConfiguredMessageChannels } from "../../infra/outbound/channel-selection.js";
import {
resolveTargetPrefixedChannel,
validateTargetProviderPrefix,
} from "../../infra/outbound/channel-target-prefix.js";
import { listConfiguredAnnounceChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { parseAgentSessionKey } from "../../sessions/session-key-utils.js";
import { normalizeMessageChannel } from "../../utils/message-channel.js";
@@ -63,14 +63,11 @@ function compactCronListJob(job: CronJob) {
};
}
function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): string[] {
return listConfiguredAnnounceChannelIdsForConfig({
config: cfg,
env: process.env,
});
async function listConfiguredAnnounceChannelIds(cfg: OpenClawConfig): Promise<string[]> {
return await listConfiguredMessageChannels(cfg);
}
function assertConfiguredAnnounceChannel(params: {
async function assertConfiguredAnnounceChannel(params: {
cfg: OpenClawConfig;
channel?: string;
field: "delivery.channel" | "delivery.failureDestination.channel";
@@ -81,7 +78,7 @@ function assertConfiguredAnnounceChannel(params: {
return;
}
const configuredChannels = listConfiguredAnnounceChannelIds(params.cfg).toSorted();
const configuredChannels = (await listConfiguredAnnounceChannelIds(params.cfg)).toSorted();
const normalizedChannel = normalizeMessageChannel(params.channel);
if (!normalizedChannel) {
if (configuredChannels.length <= 1) {
@@ -93,7 +90,7 @@ function assertConfiguredAnnounceChannel(params: {
}
if (configuredChannels.length === 0) {
return;
throw new Error(`${params.field} is not configured: ${normalizedChannel}`);
}
if (configuredChannels.includes(normalizedChannel)) {
@@ -132,14 +129,17 @@ function assertCompatibleAnnounceTarget(params: {
}
}
function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery?: CronDelivery }) {
async function assertValidCronAnnounceDelivery(params: {
cfg: OpenClawConfig;
delivery?: CronDelivery;
}) {
if (params.delivery && (params.delivery.mode ?? "announce") === "announce") {
assertCompatibleAnnounceTarget({
channel: params.delivery.channel,
to: params.delivery.to,
field: "delivery.channel",
});
assertConfiguredAnnounceChannel({
await assertConfiguredAnnounceChannel({
cfg: params.cfg,
channel: resolveAnnounceValidationChannel({
channel: params.delivery.channel,
@@ -164,7 +164,7 @@ function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery
to: failureDestination.to,
field: "delivery.failureDestination.channel",
});
assertConfiguredAnnounceChannel({
await assertConfiguredAnnounceChannel({
cfg: params.cfg,
channel: resolveAnnounceValidationChannel({
channel: failureDestination.channel,
@@ -175,14 +175,14 @@ function assertValidCronAnnounceDelivery(params: { cfg: OpenClawConfig; delivery
}
}
function assertValidCronCreateDelivery(cfg: OpenClawConfig, jobCreate: CronJobCreate) {
assertValidCronAnnounceDelivery({
async function assertValidCronCreateDelivery(cfg: OpenClawConfig, jobCreate: CronJobCreate) {
await assertValidCronAnnounceDelivery({
cfg,
delivery: jobCreate.delivery,
});
}
function assertValidCronUpdatePatch(params: {
async function assertValidCronUpdatePatch(params: {
cfg: OpenClawConfig;
defaultAgentId?: string;
currentJob: CronJob;
@@ -204,7 +204,7 @@ function assertValidCronUpdatePatch(params: {
resolveTargetPrefixedChannel(nextJob.delivery.to) === undefined
? { ...nextJob.delivery, channel: "last" as const }
: nextJob.delivery;
assertValidCronAnnounceDelivery({
await assertValidCronAnnounceDelivery({
cfg: params.cfg,
delivery,
});
@@ -459,7 +459,7 @@ export const cronHandlers: GatewayRequestHandlers = {
return;
}
try {
assertValidCronCreateDelivery(cfg, jobCreate);
await assertValidCronCreateDelivery(cfg, jobCreate);
} catch (err) {
respond(
false,
@@ -564,7 +564,7 @@ export const cronHandlers: GatewayRequestHandlers = {
}
}
try {
assertValidCronUpdatePatch({
await assertValidCronUpdatePatch({
cfg,
defaultAgentId: context.cron.getDefaultAgentId(),
currentJob,
@@ -30,7 +30,15 @@ function createPrefixOnlyChannelPlugin(
targetPrefixes: readonly string[],
aliases?: readonly string[],
): ChannelPlugin {
const base = createChannelTestPluginBase({ id });
const base = createChannelTestPluginBase({
id,
config: {
isConfigured: (_account, cfg) => {
const channelConfig = cfg.channels?.[id];
return Boolean(channelConfig && channelConfig.enabled !== false);
},
},
});
return {
...base,
meta: {
@@ -495,6 +503,78 @@ describe("cron method validation", () => {
expectResponseError(respond, { messageIncludes: "delivery.channel is required" });
});
it("ignores stale ownerless channel config when validating default announce delivery", async () => {
setRuntimeConfig({
session: { mainKey: "main" },
channels: {
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
clickclack: {
token: "stale-token",
},
},
plugins: pluginEntries("slack"),
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
name: "ownerless config is not ambiguous",
delivery: { mode: "announce" },
}),
);
expect(context.cron.add).toHaveBeenCalled();
expectCronSuccess(respond);
});
it("rejects explicit announce delivery to stale ownerless channel config", async () => {
setRuntimeConfig({
channels: {
slack: {
botToken: "xoxb-slack-token",
appToken: "xapp-slack-token",
},
clickclack: {
token: "stale-token",
},
},
plugins: pluginEntries("slack"),
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
name: "ownerless channel is not deliverable",
delivery: { mode: "announce", channel: "clickclack" },
}),
);
expect(context.cron.add).not.toHaveBeenCalled();
expectResponseError(respond, { messageIncludes: "delivery.channel must be one of: slack" });
});
it("rejects explicit announce delivery when only stale ownerless channel config exists", async () => {
setRuntimeConfig({
channels: {
clickclack: {
token: "stale-token",
},
},
plugins: pluginEntries(),
} as OpenClawConfig);
const { context, respond } = await invokeCronAdd(
agentTurnCronParams({
name: "only ownerless channel is not deliverable",
delivery: { mode: "announce", channel: "clickclack" },
}),
);
expect(context.cron.add).not.toHaveBeenCalled();
expectResponseError(respond, { messageIncludes: "delivery.channel is not configured" });
});
it("accepts provider-prefixed announce target without delivery.channel when multiple channels are configured", async () => {
setRuntimeConfig(telegramSlackConfig({ includeMainSession: true }));
+179
View File
@@ -3420,6 +3420,185 @@ describe("listConfiguredChannelIdsForReadOnlyScope", () => {
).toEqual(["demo-other-channel"]);
});
it("announces explicit configured channels without installed owners", () => {
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
channels: {
clickclack: {
token: "configured",
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual(["clickclack"]);
});
it("does not announce ownerless explicit channels suppressed by plugin policy", () => {
const ownerlessChannelConfig = {
channels: {
clickclack: {
token: "configured",
},
},
} as OpenClawConfig;
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...ownerlessChannelConfig,
plugins: {
enabled: false,
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...ownerlessChannelConfig,
plugins: {
deny: ["clickclack"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...ownerlessChannelConfig,
plugins: {
entries: {
clickclack: {
enabled: false,
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...ownerlessChannelConfig,
plugins: {
allow: ["slack"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
});
it("does not announce explicit channels suppressed by plugin policy", () => {
const baseConfig = {
channels: {
"demo-channel": {
token: "configured",
},
},
} as OpenClawConfig;
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...baseConfig,
plugins: {
enabled: false,
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...baseConfig,
plugins: {
deny: ["demo-channel"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
...baseConfig,
plugins: {
entries: {
"demo-channel": {
enabled: false,
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
}),
).toStrictEqual([]);
});
it("keeps announce channels with another effective owner", () => {
expect(
listConfiguredAnnounceChannelIdsForConfig({
config: {
channels: {
shared: {
token: "configured",
},
},
plugins: {
entries: {
"shared-good": {
enabled: true,
},
"shared-disabled": {
enabled: false,
},
},
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {},
manifestRecords: [
{
id: "shared-good",
channels: ["shared"],
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
} as never,
{
id: "shared-disabled",
channels: ["shared"],
origin: "config",
enabledByDefault: undefined,
providers: [],
cliBackends: [],
} as never,
],
}),
).toStrictEqual(["shared"]);
});
it("does not treat activation-only declarations as channel ownership", () => {
listPotentialConfiguredChannelIds.mockReturnValue(["activation-only-channel"]);
listPotentialConfiguredChannelPresenceSignals.mockReturnValue([
+38 -9
View File
@@ -56,6 +56,12 @@ export type ConfiguredChannelPresencePolicyEntry = {
blockedReasons: ConfiguredChannelBlockedReason[];
};
const ANNOUNCE_SUPPRESSING_BLOCKED_REASONS = new Set<ConfiguredChannelBlockedReason>([
"plugins-disabled",
"blocked-by-denylist",
"plugin-disabled",
]);
function normalizeChannelIds(channelIds: Iterable<string>): string[] {
return sortUniqueStrings(
[...channelIds].flatMap((channelId) => {
@@ -437,18 +443,41 @@ export function listConfiguredAnnounceChannelIdsForConfig(params: {
activationSourceConfig?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
manifestRecords?: readonly PluginManifestRecord[];
}): string[] {
const disabledChannelIds = new Set(listExplicitlyDisabledChannelIdsForConfig(params.config));
const trustConfig = params.activationSourceConfig ?? params.config;
const normalizedConfig = normalizePluginsConfig(trustConfig.plugins);
const policy = resolveConfiguredChannelPresencePolicy({
config: params.config,
activationSourceConfig: trustConfig,
workspaceDir: params.workspaceDir,
env: params.env,
includePersistedAuthState: false,
manifestRecords: params.manifestRecords,
});
const policyDisabledChannelIds = new Set(
policy
.filter(
(entry) =>
!entry.effective &&
entry.blockedReasons.some((reason) => ANNOUNCE_SUPPRESSING_BLOCKED_REASONS.has(reason)),
)
.map((entry) => entry.channelId),
);
const explicitChannelIds = listExplicitConfiguredChannelIdsForConfig(params.config).filter(
(channelId) =>
normalizedConfig.enabled &&
!normalizedConfig.deny.includes(channelId) &&
normalizedConfig.entries[channelId]?.enabled !== false &&
(normalizedConfig.allow.length === 0 || normalizedConfig.allow.includes(channelId)),
);
return normalizeChannelIds([
...listExplicitConfiguredChannelIdsForConfig(params.config),
...listConfiguredChannelIdsForReadOnlyScope({
config: params.config,
activationSourceConfig: params.activationSourceConfig,
workspaceDir: params.workspaceDir,
env: params.env,
includePersistedAuthState: false,
}),
]).filter((channelId) => !disabledChannelIds.has(channelId));
...explicitChannelIds,
...policy.filter((entry) => entry.effective).map((entry) => entry.channelId),
]).filter(
(channelId) => !disabledChannelIds.has(channelId) && !policyDisabledChannelIds.has(channelId),
);
}
function resolveScopedChannelOwnerPluginIds(params: {