fix: route read-only channel detection

This commit is contained in:
Gustavo Madeira Santana
2026-04-20 21:02:28 -04:00
parent 4f9a201476
commit d493973eae
12 changed files with 323 additions and 25 deletions

View File

@@ -1,7 +1,7 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
listConfiguredChannelIdsForPluginScope,
listConfiguredChannelIdsForReadOnlyScope,
resolveDiscoverableScopedChannelPluginIds,
} from "../../plugins/channel-plugin-ids.js";
import { loadOpenClawPlugins } from "../../plugins/loader.js";
@@ -141,7 +141,7 @@ export function listReadOnlyChannelPluginsForConfig(
});
const configuredChannelIds = [
...new Set(
listConfiguredChannelIdsForPluginScope({
listConfiguredChannelIdsForReadOnlyScope({
config: cfg,
workspaceDir,
env,

View File

@@ -10,6 +10,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"]),
withProgress: vi.fn(async (_opts: unknown, run: () => Promise<unknown>) => await run()),
}));
@@ -33,8 +34,9 @@ vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
}));
vi.mock("../channels/config-presence.js", () => ({
listPotentialConfiguredChannelIds: () => ["discord"],
vi.mock("../plugins/channel-plugin-ids.js", () => ({
listConfiguredChannelIdsForReadOnlyScope: (params: unknown) =>
mocks.listConfiguredChannelIdsForReadOnlyScope(params),
}));
vi.mock("./channels/shared.js", () => ({
@@ -192,6 +194,8 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
mocks.readConfigFileSnapshot.mockClear();
mocks.requireValidConfigSnapshot.mockReset();
mocks.listChannelPlugins.mockReset();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockClear();
mocks.listConfiguredChannelIdsForReadOnlyScope.mockReturnValue(["discord"]);
mocks.withProgress.mockClear();
mocks.listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]);
});
@@ -259,6 +263,12 @@ describe("channelsStatusCommand SecretRef fallback flow", () => {
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled();
expect(mocks.listConfiguredChannelIdsForReadOnlyScope).toHaveBeenCalledWith(
expect.objectContaining({
config: expect.objectContaining({ secretResolved: true }),
includePersistedAuthState: false,
}),
);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload).toEqual(
expect.objectContaining({

View File

@@ -0,0 +1,150 @@
import fs from "node:fs";
import path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
cleanupPluginLoaderFixturesForTest,
EMPTY_PLUGIN_SCHEMA,
makeTempDir,
resetPluginLoaderTestStateForTest,
useNoBundledPlugins,
} from "../plugins/loader.test-fixtures.js";
import { withEnvAsync } from "../test-utils/env.js";
import { channelsStatusCommand } from "./channels/status.js";
const mocks = vi.hoisted(() => ({
callGateway: vi.fn(),
readConfigFileSnapshot: vi.fn(async () => ({ path: "/tmp/openclaw.json" })),
requireValidConfigSnapshot: vi.fn(),
resolveCommandConfigWithSecrets: vi.fn(),
}));
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => mocks.callGateway(opts),
}));
vi.mock("../config/config.js", () => ({
readConfigFileSnapshot: () => mocks.readConfigFileSnapshot(),
}));
vi.mock("../cli/command-config-resolution.js", () => ({
resolveCommandConfigWithSecrets: (opts: unknown) => mocks.resolveCommandConfigWithSecrets(opts),
}));
vi.mock("./channels/shared.js", () => ({
requireValidConfigSnapshot: (runtime: unknown) => mocks.requireValidConfigSnapshot(runtime),
formatChannelAccountLabel: ({ channel, accountId }: { channel: string; accountId: string }) =>
`${channel} ${accountId}`,
appendBaseUrlBit: () => undefined,
appendEnabledConfiguredLinkedBits: () => undefined,
appendModeBit: () => undefined,
appendTokenSourceBits: () => undefined,
buildChannelAccountLine: () => "",
}));
vi.mock("../cli/progress.js", () => ({
withProgress: async (_opts: unknown, run: () => Promise<unknown>) => await run(),
}));
function writeExternalEnvChannelPlugin() {
useNoBundledPlugins();
const pluginDir = makeTempDir();
const fullMarker = path.join(pluginDir, "full-loaded.txt");
fs.writeFileSync(
path.join(pluginDir, "package.json"),
JSON.stringify(
{
name: "@example/openclaw-external-env-channel",
version: "1.0.0",
openclaw: {
extensions: ["./index.cjs"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "openclaw.plugin.json"),
JSON.stringify(
{
id: "external-env-channel-plugin",
configSchema: EMPTY_PLUGIN_SCHEMA,
channels: ["external-env-channel"],
channelEnvVars: {
"external-env-channel": ["EXTERNAL_ENV_CHANNEL_TOKEN"],
},
},
null,
2,
),
"utf-8",
);
fs.writeFileSync(
path.join(pluginDir, "index.cjs"),
`require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8");`,
"utf-8",
);
return { pluginDir, fullMarker };
}
function createRuntimeCapture() {
const logs: string[] = [];
const errors: string[] = [];
const runtime = {
log: (message: unknown) => logs.push(String(message)),
error: (message: unknown) => errors.push(String(message)),
exit: (_code?: number) => undefined,
};
return { runtime, logs, errors };
}
describe("channelsStatusCommand external env-only channel fallback", () => {
beforeEach(() => {
mocks.callGateway.mockReset();
mocks.callGateway.mockRejectedValue(new Error("gateway closed"));
mocks.readConfigFileSnapshot.mockClear();
mocks.requireValidConfigSnapshot.mockReset();
mocks.resolveCommandConfigWithSecrets.mockReset();
});
afterEach(() => {
resetPluginLoaderTestStateForTest();
});
it("reports env-only external manifest channels in JSON fallback without full runtime load", async () => {
const { pluginDir, fullMarker } = writeExternalEnvChannelPlugin();
const config = {
plugins: {
load: { paths: [pluginDir] },
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig;
mocks.requireValidConfigSnapshot.mockResolvedValue(config);
mocks.resolveCommandConfigWithSecrets.mockResolvedValue({
resolvedConfig: config,
effectiveConfig: config,
diagnostics: [],
});
const { runtime, logs } = createRuntimeCapture();
await withEnvAsync({ EXTERNAL_ENV_CHANNEL_TOKEN: "token" }, async () => {
await channelsStatusCommand({ json: true, probe: false }, runtime as never);
});
expect(fs.existsSync(fullMarker)).toBe(false);
const payload = JSON.parse(logs.at(-1) ?? "{}");
expect(payload).toEqual(
expect.objectContaining({
gatewayReachable: false,
configOnly: true,
configuredChannels: ["external-env-channel"],
}),
);
});
});
afterAll(() => {
cleanupPluginLoaderFixturesForTest();
});

View File

@@ -1,4 +1,3 @@
import { listPotentialConfiguredChannelIds } from "../../channels/config-presence.js";
import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { getConfiguredChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
@@ -7,6 +6,7 @@ import { readConfigFileSnapshot } from "../../config/config.js";
import { callGateway } from "../../gateway/call.js";
import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js";
import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
@@ -196,9 +196,11 @@ export async function channelsStatusCommand(
path: snapshot.path,
mode,
},
configuredChannels: listPotentialConfiguredChannelIds(resolvedConfig, process.env, {
configuredChannels: listConfiguredChannelIdsForReadOnlyScope({
config: resolvedConfig,
env: process.env,
includePersistedAuthState: false,
}).toSorted(),
}),
});
return;
}

View File

@@ -1,8 +1,8 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.js";
import type { collectChannelStatusIssues as collectChannelStatusIssuesFn } from "../infra/channels-status-issues.js";
import { resolveOsSummary } from "../infra/os-summary.js";
import type { UpdateCheckResult } from "../infra/update-check.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import type { RuntimeEnv } from "../runtime.js";
import type { buildChannelsTable as buildChannelsTableFn } from "./status-all/channels.js";
import type { getAgentLocalStatuses as getAgentLocalStatusesFn } from "./status.agent-local.js";
@@ -178,7 +178,7 @@ export async function collectStatusScanOverview(params: {
params.progress?.tick();
const hasConfiguredChannels = params.resolveHasConfiguredChannels
? params.resolveHasConfiguredChannels(cfg)
: hasPotentialConfiguredChannels(cfg);
: hasConfiguredChannelsForReadOnlyScope({ config: cfg });
const osSummary = resolveOsSummary();
const bootstrap = await createStatusScanCoreBootstrap<
Awaited<ReturnType<typeof getAgentLocalStatusesFn>>

View File

@@ -1,4 +1,5 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
import {
@@ -12,9 +13,7 @@ type StatusJsonScanPolicy = {
commandName: string;
allowMissingConfigFastPath?: boolean;
includeChannelSummary?: boolean;
resolveHasConfiguredChannels: (
cfg: Parameters<typeof hasPotentialConfiguredChannels>[0],
) => boolean;
resolveHasConfiguredChannels: (cfg: OpenClawConfig) => boolean;
resolveMemory: Parameters<typeof executeStatusScanFromOverview>[0]["resolveMemory"];
};
@@ -60,7 +59,9 @@ export async function scanStatusJsonFast(
allowMissingConfigFastPath: true,
includeChannelSummary: false,
resolveHasConfiguredChannels: (cfg) =>
hasPotentialConfiguredChannels(cfg, process.env, {
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
env: process.env,
includePersistedAuthState: false,
}),
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>

View File

@@ -182,6 +182,36 @@ export async function loadStatusScanModuleForTest(
vi.doMock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels,
}));
vi.doMock("../plugins/channel-plugin-ids.js", () => ({
hasConfiguredChannelsForReadOnlyScope: (params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includePersistedAuthState?: boolean;
}) =>
Boolean(
mocks.hasPotentialConfiguredChannels(
params.config,
params.env,
params.includePersistedAuthState === undefined
? undefined
: { includePersistedAuthState: params.includePersistedAuthState },
),
),
listConfiguredChannelIdsForReadOnlyScope: (params: {
config: OpenClawConfig;
env?: NodeJS.ProcessEnv;
includePersistedAuthState?: boolean;
}) =>
mocks.hasPotentialConfiguredChannels(
params.config,
params.env,
params.includePersistedAuthState === undefined
? undefined
: { includePersistedAuthState: params.includePersistedAuthState },
)
? ["mock-channel"]
: [],
}));
vi.doMock("../config/io.js", () => ({
readBestEffortConfig: mocks.readBestEffortConfig,

View File

@@ -1,5 +1,5 @@
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { withProgress } from "../cli/progress.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { buildPluginCompatibilitySnapshotNotices } from "../plugins/status.js";
import type { RuntimeEnv } from "../runtime.js";
import { executeStatusScanFromOverview } from "./status.scan-execute.ts";
@@ -25,7 +25,8 @@ export async function scanStatus(
_runtime,
{
commandName: "status --json",
resolveHasConfiguredChannels: (cfg) => hasPotentialConfiguredChannels(cfg),
resolveHasConfiguredChannels: (cfg) =>
hasConfiguredChannelsForReadOnlyScope({ config: cfg }),
resolveMemory: async ({ cfg, agentStatus, memoryPlugin }) =>
await resolveStatusMemoryStatusSnapshot({
cfg,

View File

@@ -1,12 +1,12 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const statusSummaryMocks = vi.hoisted(() => ({
hasPotentialConfiguredChannels: vi.fn(() => true),
hasConfiguredChannelsForReadOnlyScope: vi.fn(() => true),
buildChannelSummary: vi.fn(async () => ["ok"]),
}));
vi.mock("../channels/config-presence.js", () => ({
hasPotentialConfiguredChannels: statusSummaryMocks.hasPotentialConfiguredChannels,
vi.mock("../plugins/channel-plugin-ids.js", () => ({
hasConfiguredChannelsForReadOnlyScope: statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope,
}));
vi.mock("./status.summary.runtime.js", () => ({
@@ -125,7 +125,7 @@ describe("getStatusSummary", () => {
beforeEach(() => {
vi.clearAllMocks();
statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(true);
statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(true);
statusSummaryMocks.buildChannelSummary.mockResolvedValue(["ok"]);
});
@@ -140,12 +140,15 @@ describe("getStatusSummary", () => {
});
it("skips channel summary imports when no channels are configured", async () => {
statusSummaryMocks.hasPotentialConfiguredChannels.mockReturnValue(false);
statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope.mockReturnValue(false);
const summary = await getStatusSummary();
expect(summary.channelSummary).toEqual([]);
expect(summary.linkChannel).toBeUndefined();
expect(statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope).toHaveBeenCalledWith({
config: {},
});
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});
@@ -155,7 +158,7 @@ describe("getStatusSummary", () => {
expect(summary.channelSummary).toEqual([]);
expect(summary.linkChannel).toBeUndefined();
expect(statusSummaryMocks.hasPotentialConfiguredChannels).not.toHaveBeenCalled();
expect(statusSummaryMocks.hasConfiguredChannelsForReadOnlyScope).not.toHaveBeenCalled();
expect(buildChannelSummary).not.toHaveBeenCalled();
expect(resolveLinkChannelContext).not.toHaveBeenCalled();
});

View File

@@ -1,5 +1,4 @@
import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
import { hasPotentialConfiguredChannels } from "../channels/config-presence.js";
import { resolveMainSessionKey } from "../config/sessions/main-session.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import { readSessionStoreReadOnly } from "../config/sessions/store-read.js";
@@ -8,6 +7,7 @@ import type { OpenClawConfig } from "../config/types.js";
import { listGatewayAgentsBasic } from "../gateway/agent-list.js";
import { resolveHeartbeatSummaryForAgent } from "../infra/heartbeat-summary.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { hasConfiguredChannelsForReadOnlyScope } from "../plugins/channel-plugin-ids.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { createLazyRuntimeSurface } from "../shared/lazy-runtime.js";
import { resolveRuntimeServiceVersion } from "../version.js";
@@ -118,7 +118,11 @@ export async function getStatusSummary(
resolveSessionModelRef,
} = await loadStatusSummaryRuntimeModule();
const cfg = options.config ?? (await loadConfigIoModule()).loadConfig();
const needsChannelPlugins = includeChannelSummary && hasPotentialConfiguredChannels(cfg);
const needsChannelPlugins =
includeChannelSummary &&
hasConfiguredChannelsForReadOnlyScope({
config: cfg,
});
const linkContext = needsChannelPlugins
? await loadLinkChannelModule().then(({ resolveLinkChannelContext }) =>
resolveLinkChannelContext(cfg),

View File

@@ -19,6 +19,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => {
});
import {
hasConfiguredChannelsForReadOnlyScope,
listConfiguredChannelIdsForReadOnlyScope,
resolveConfiguredChannelPluginIds,
resolveGatewayStartupPluginIds,
} from "./channel-plugin-ids.js";
@@ -634,3 +636,48 @@ describe("resolveConfiguredChannelPluginIds", () => {
).toEqual([]);
});
});
describe("listConfiguredChannelIdsForReadOnlyScope", () => {
beforeEach(() => {
listPotentialConfiguredChannelIds.mockReset().mockReturnValue([]);
hasPotentialConfiguredChannels.mockReset().mockReturnValue(false);
loadPluginManifestRegistry.mockReset().mockReturnValue(createManifestRegistryFixture());
});
it("uses manifest env vars as read-only configured channel triggers", () => {
expect(
listConfiguredChannelIdsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toEqual(["external-env-channel"]);
});
it("uses manifest env vars for read-only channel presence checks", () => {
listPotentialConfiguredChannelIds.mockReturnValue([]);
hasPotentialConfiguredChannels.mockReturnValue(false);
expect(
hasConfiguredChannelsForReadOnlyScope({
config: {
plugins: {
allow: ["external-env-channel-plugin"],
},
} as OpenClawConfig,
workspaceDir: "/tmp",
env: {
EXTERNAL_ENV_CHANNEL_TOKEN: "token",
} as NodeJS.ProcessEnv,
includePersistedAuthState: false,
}),
).toBe(true);
});
});

View File

@@ -1,5 +1,9 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { collectConfiguredAgentHarnessRuntimes } from "../agents/harness-runtimes.js";
import { listPotentialConfiguredChannelIds } from "../channels/config-presence.js";
import {
hasPotentialConfiguredChannels,
listPotentialConfiguredChannelIds,
} from "../channels/config-presence.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
resolveMemoryDreamingConfig,
@@ -111,6 +115,52 @@ export function listConfiguredChannelIdsForPluginScope(params: {
].toSorted((left, right) => left.localeCompare(right));
}
export function listConfiguredChannelIdsForReadOnlyScope(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): string[] {
const env = params.env ?? process.env;
const workspaceDir =
params.workspaceDir ??
resolveAgentWorkspaceDir(params.config, resolveDefaultAgentId(params.config));
return listConfiguredChannelIdsForPluginScope({
config: params.config,
workspaceDir,
env,
cache: params.cache,
includePersistedAuthState: params.includePersistedAuthState,
manifestRecords: params.manifestRecords,
});
}
export function hasConfiguredChannelsForReadOnlyScope(params: {
config: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
cache?: boolean;
includePersistedAuthState?: boolean;
manifestRecords?: readonly PluginManifestRecord[];
}): boolean {
const env = params.env ?? process.env;
if (
hasPotentialConfiguredChannels(params.config, env, {
includePersistedAuthState: params.includePersistedAuthState,
})
) {
return true;
}
return (
listConfiguredChannelIdsForReadOnlyScope({
...params,
env,
}).length > 0
);
}
function isChannelPluginEligibleForScopedOwnership(params: {
plugin: PluginManifestRecord;
normalizedConfig: ReturnType<typeof normalizePluginsConfig>;