diff --git a/src/channels/config-presence.ts b/src/channels/config-presence.ts new file mode 100644 index 00000000000..792aa545a54 --- /dev/null +++ b/src/channels/config-presence.ts @@ -0,0 +1,94 @@ +import fs from "node:fs"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; + +const IGNORED_CHANNEL_CONFIG_KEYS = new Set(["defaults", "modelByChannel"]); + +const CHANNEL_ENV_PREFIXES = [ + "BLUEBUBBLES_", + "DISCORD_", + "GOOGLECHAT_", + "IRC_", + "LINE_", + "MATRIX_", + "MSTEAMS_", + "SIGNAL_", + "SLACK_", + "TELEGRAM_", + "WHATSAPP_", + "ZALOUSER_", + "ZALO_", +] as const; + +function hasNonEmptyString(value: unknown): boolean { + return typeof value === "string" && value.trim().length > 0; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function recordHasKeys(value: unknown): boolean { + return isRecord(value) && Object.keys(value).length > 0; +} + +function hasWhatsAppAuthState(env: NodeJS.ProcessEnv): boolean { + try { + const oauthDir = resolveOAuthDir(env); + const legacyCreds = path.join(oauthDir, "creds.json"); + if (fs.existsSync(legacyCreds)) { + return true; + } + + const accountsRoot = path.join(oauthDir, "whatsapp"); + const defaultCreds = path.join(accountsRoot, DEFAULT_ACCOUNT_ID, "creds.json"); + if (fs.existsSync(defaultCreds)) { + return true; + } + + const entries = fs.readdirSync(accountsRoot, { withFileTypes: true }); + return entries.some((entry) => { + if (!entry.isDirectory()) { + return false; + } + return fs.existsSync(path.join(accountsRoot, entry.name, "creds.json")); + }); + } catch { + return false; + } +} + +function hasEnvConfiguredChannel(env: NodeJS.ProcessEnv): boolean { + for (const [key, value] of Object.entries(env)) { + if (!hasNonEmptyString(value)) { + continue; + } + if ( + CHANNEL_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)) || + key === "TELEGRAM_BOT_TOKEN" + ) { + return true; + } + } + return hasWhatsAppAuthState(env); +} + +export function hasPotentialConfiguredChannels( + cfg: OpenClawConfig, + env: NodeJS.ProcessEnv = process.env, +): boolean { + const channels = isRecord(cfg.channels) ? cfg.channels : null; + if (channels) { + for (const [key, value] of Object.entries(channels)) { + if (IGNORED_CHANNEL_CONFIG_KEYS.has(key)) { + continue; + } + if (recordHasKeys(value)) { + return true; + } + } + } + return hasEnvConfiguredChannel(env); +} diff --git a/src/cli/program/preaction.test.ts b/src/cli/program/preaction.test.ts index 2a1367870c6..2376e97100f 100644 --- a/src/cli/program/preaction.test.ts +++ b/src/cli/program/preaction.test.ts @@ -190,6 +190,19 @@ describe("registerPreActionHooks", () => { }); it("applies --json stdout suppression only for explicit JSON output commands", async () => { + await runPreAction({ + parseArgv: ["status"], + processArgv: ["node", "openclaw", "status", "--json"], + }); + + expect(ensureConfigReadyMock).toHaveBeenCalledWith({ + runtime: runtimeMock, + commandPath: ["status"], + suppressDoctorStdout: true, + }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); + + vi.clearAllMocks(); await runPreAction({ parseArgv: ["update", "status", "--json"], processArgv: ["node", "openclaw", "update", "status", "--json"], @@ -200,6 +213,7 @@ describe("registerPreActionHooks", () => { commandPath: ["update", "status"], suppressDoctorStdout: true, }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); vi.clearAllMocks(); await runPreAction({ diff --git a/src/cli/program/preaction.ts b/src/cli/program/preaction.ts index ccd84e3201e..19659f97c7e 100644 --- a/src/cli/program/preaction.ts +++ b/src/cli/program/preaction.ts @@ -71,6 +71,16 @@ function resolvePluginRegistryScope(commandPath: string[]): "channels" | "all" { return commandPath[0] === "status" || commandPath[0] === "health" ? "channels" : "all"; } +function shouldLoadPluginsForCommand(commandPath: string[], argv: string[]): boolean { + if (!PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + return false; + } + if ((commandPath[0] === "status" || commandPath[0] === "health") && hasFlag(argv, "--json")) { + return false; + } + return true; +} + function getRootCommand(command: Command): Command { let current = command; while (current.parent) { @@ -138,7 +148,7 @@ export function registerPreActionHooks(program: Command, programVersion: string) ...(suppressDoctorStdout ? { suppressDoctorStdout: true } : {}), }); // Load plugins for commands that need channel access - if (PLUGIN_REQUIRED_COMMANDS.has(commandPath[0])) { + if (shouldLoadPluginsForCommand(commandPath, argv)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); ensurePluginRegistryLoaded({ scope: resolvePluginRegistryScope(commandPath) }); } diff --git a/src/cli/program/routes.ts b/src/cli/program/routes.ts index cea5fcb8138..52e0d8f8446 100644 --- a/src/cli/program/routes.ts +++ b/src/cli/program/routes.ts @@ -34,9 +34,9 @@ const routeHealth: RouteSpec = { const routeStatus: RouteSpec = { match: (path) => path[0] === "status", - // Status runs security audit with channel checks in both text and JSON output, - // so plugin registry must be ready for consistent findings. - loadPlugins: true, + // `status --json` can defer channel plugin loading until config/env inspection + // proves it is needed, which keeps the fast-path startup lightweight. + loadPlugins: (argv) => !hasFlag(argv, "--json"), run: async (argv) => { const json = hasFlag(argv, "--json"); const deep = hasFlag(argv, "--deep"); diff --git a/src/cli/route.test.ts b/src/cli/route.test.ts index 93516906ad0..9e7c6c7c110 100644 --- a/src/cli/route.test.ts +++ b/src/cli/route.test.ts @@ -37,7 +37,7 @@ describe("tryRouteCli", () => { vi.resetModules(); ({ tryRouteCli } = await import("./route.js")); findRoutedCommandMock.mockReturnValue({ - loadPlugins: true, + loadPlugins: (argv: string[]) => !argv.includes("--json"), run: runRouteMock, }); }); @@ -59,7 +59,7 @@ describe("tryRouteCli", () => { suppressDoctorStdout: true, }), ); - expect(ensurePluginRegistryLoadedMock).toHaveBeenCalledWith({ scope: "channels" }); + expect(ensurePluginRegistryLoadedMock).not.toHaveBeenCalled(); }); it("does not pass suppressDoctorStdout for routed non-json commands", async () => { diff --git a/src/commands/status.command.ts b/src/commands/status.command.ts index 7e68424c5a9..92702bac66e 100644 --- a/src/commands/status.command.ts +++ b/src/commands/status.command.ts @@ -5,7 +5,6 @@ import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js"; import { info } from "../globals.js"; import { formatTimeAgo } from "../infra/format-time/format-relative.ts"; import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js"; -import { formatUsageReportLines, loadProviderUsageSummary } from "../infra/provider-usage.js"; import { normalizeUpdateChannel, resolveUpdateChannelDisplay } from "../infra/update-channels.js"; import { formatGitInstallLabel } from "../infra/update-check.js"; import { @@ -37,6 +36,13 @@ import { resolveUpdateAvailability, } from "./status.update.js"; +let providerUsagePromise: Promise | undefined; + +function loadProviderUsage() { + providerUsagePromise ??= import("../infra/provider-usage.js"); + return providerUsagePromise; +} + function resolvePairingRecoveryContext(params: { error?: string | null; closeReason?: string | null; @@ -138,7 +144,10 @@ export async function statusCommand( indeterminate: true, enabled: opts.json !== true, }, - async () => await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }), + async () => { + const { loadProviderUsageSummary } = await loadProviderUsage(); + return await loadProviderUsageSummary({ timeoutMs: opts.timeoutMs }); + }, ) : undefined; const health: HealthSummary | undefined = opts.deep @@ -658,6 +667,7 @@ export async function statusCommand( } if (usage) { + const { formatUsageReportLines } = await loadProviderUsage(); runtime.log(""); runtime.log(theme.heading("Usage")); for (const line of formatUsageReportLines(usage)) { diff --git a/src/commands/status.scan.test.ts b/src/commands/status.scan.test.ts index 6592b84c864..9d3399997bf 100644 --- a/src/commands/status.scan.test.ts +++ b/src/commands/status.scan.test.ts @@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({ buildGatewayConnectionDetails: vi.fn(), probeGateway: vi.fn(), resolveGatewayProbeAuthResolution: vi.fn(), + ensurePluginRegistryLoaded: vi.fn(), })); vi.mock("../cli/progress.js", () => ({ @@ -70,6 +71,10 @@ vi.mock("../process/exec.js", () => ({ runExec: vi.fn(), })); +vi.mock("../cli/plugin-registry.js", () => ({ + ensurePluginRegistryLoaded: mocks.ensurePluginRegistryLoaded, +})); + import { scanStatus } from "./status.scan.js"; describe("scanStatus", () => { @@ -135,4 +140,172 @@ describe("scanStatus", () => { }), ); }); + + it("skips channel plugin preload for status --json with no channel config", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: undefined, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).not.toHaveBeenCalled(); + }); + + it("preloads channel plugins for status --json when channel config exists", async () => { + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + channels: { telegram: { enabled: false } }, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + await scanStatus({ json: true }, {} as never); + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); + + it("preloads channel plugins for status --json when channel auth is env-only", async () => { + const prevMatrixToken = process.env.MATRIX_ACCESS_TOKEN; + process.env.MATRIX_ACCESS_TOKEN = "token"; + mocks.readBestEffortConfig.mockResolvedValue({ + session: {}, + plugins: { enabled: false }, + gateway: {}, + }); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { + session: {}, + plugins: { enabled: false }, + gateway: {}, + }, + diagnostics: [], + }); + mocks.getUpdateCheckResult.mockResolvedValue({ + installKind: "git", + git: null, + registry: null, + }); + mocks.getAgentLocalStatuses.mockResolvedValue({ + defaultId: "main", + agents: [], + }); + mocks.getStatusSummary.mockResolvedValue({ + linkChannel: { linked: false }, + sessions: { count: 0, paths: [], defaults: {}, recent: [] }, + }); + mocks.buildGatewayConnectionDetails.mockReturnValue({ + url: "ws://127.0.0.1:18789", + urlSource: "default", + }); + mocks.resolveGatewayProbeAuthResolution.mockReturnValue({ + auth: {}, + warning: undefined, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + + try { + await scanStatus({ json: true }, {} as never); + } finally { + if (prevMatrixToken === undefined) { + delete process.env.MATRIX_ACCESS_TOKEN; + } else { + process.env.MATRIX_ACCESS_TOKEN = prevMatrixToken; + } + } + + expect(mocks.ensurePluginRegistryLoaded).toHaveBeenCalledWith({ scope: "channels" }); + }); }); diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 38e15e6417b..0de308f17f2 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -1,3 +1,4 @@ +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getStatusCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { withProgress } from "../cli/progress.js"; @@ -46,6 +47,13 @@ type GatewayProbeSnapshot = { gatewayProbe: Awaited> | null; }; +let pluginRegistryModulePromise: Promise | undefined; + +function loadPluginRegistryModule() { + pluginRegistryModulePromise ??= import("../cli/plugin-registry.js"); + return pluginRegistryModulePromise; +} + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -191,6 +199,10 @@ async function scanStatusJsonFast(opts: { targetIds: getStatusCommandSecretTargetIds(), mode: "summary", }); + if (hasPotentialConfiguredChannels(cfg)) { + const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); + ensurePluginRegistryLoaded({ scope: "channels" }); + } const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; const updateTimeoutMs = opts.all ? 6500 : 2500; diff --git a/src/commands/status.summary.ts b/src/commands/status.summary.ts index b84bada07ff..e1347a90b5a 100644 --- a/src/commands/status.summary.ts +++ b/src/commands/status.summary.ts @@ -1,6 +1,7 @@ import { resolveContextTokensForModel } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js"; import { resolveConfiguredModelRef } from "../agents/model-selection.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { @@ -89,7 +90,8 @@ export async function getStatusSummary( ): Promise { const { includeSensitive = true } = options; const cfg = options.config ?? loadConfig(); - const linkContext = await resolveLinkChannelContext(cfg); + const needsChannelPlugins = hasPotentialConfiguredChannels(cfg); + const linkContext = needsChannelPlugins ? await resolveLinkChannelContext(cfg) : null; const agentList = listAgentsForGateway(cfg); const heartbeatAgents: HeartbeatStatus[] = agentList.agents.map((agent) => { const summary = resolveHeartbeatSummaryForAgent(cfg, agent.id); @@ -100,11 +102,13 @@ export async function getStatusSummary( everyMs: summary.everyMs, } satisfies HeartbeatStatus; }); - const channelSummary = await buildChannelSummary(cfg, { - colorize: true, - includeAllowFrom: true, - sourceConfig: options.sourceConfig, - }); + const channelSummary = needsChannelPlugins + ? await buildChannelSummary(cfg, { + colorize: true, + includeAllowFrom: true, + sourceConfig: options.sourceConfig, + }) + : []; const mainSessionKey = resolveMainSessionKey(cfg); const queuedSystemEvents = peekSystemEvents(mainSessionKey); diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index c40693302ac..5cc71b6e950 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -398,7 +398,7 @@ describe("statusCommand", () => { it("prints JSON when requested", async () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls[0]?.[0])); - expect(payload.linkChannel.linked).toBe(true); + expect(payload.linkChannel).toBeUndefined(); expect(payload.memory.agentId).toBe("main"); expect(payload.memoryPlugin.enabled).toBe(true); expect(payload.memoryPlugin.slot).toBe("memory-core"); diff --git a/src/security/audit.ts b/src/security/audit.ts index 113ec2bd067..dbbfb9651be 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -5,6 +5,7 @@ import { execDockerRaw } from "../agents/sandbox/docker.js"; import { redactCdpUrl } from "../browser/cdp.helpers.js"; import { resolveBrowserConfig, resolveProfile } from "../browser/config.js"; import { resolveBrowserControlAuth } from "../browser/control-auth.js"; +import { hasPotentialConfiguredChannels } from "../channels/config-presence.js"; import { listChannelPlugins } from "../channels/plugins/index.js"; import { formatCliCommand } from "../cli/command-format.js"; import type { ConfigFileSnapshot, OpenClawConfig } from "../config/config.js"; @@ -1226,7 +1227,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise