From b3f1cd36dbc4d2183cd837ea0c965c680bf6bf29 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 14 Aug 2026 14:10:23 -0700 Subject: [PATCH] fix(status): keep multi-agent diagnostics owner-safe (#123826) * fix(status): preserve explicit multi-agent inventory ownership * chore: leave changelog to release automation --- docs/cli/channels.md | 5 + docs/cli/status.md | 3 + .../read-only.legacy-workspace.test.ts | 1 + src/channels/plugins/read-only.ts | 3 + src/commands/channels.list.test.ts | 103 ++++++++++++++++-- src/commands/channels/list.ts | 16 ++- src/commands/status-all/channels.test.ts | 23 +++- src/commands/status-all/channels.ts | 12 +- src/commands/status-runtime-shared.test.ts | 35 ++++++ src/commands/status-runtime-shared.ts | 20 +++- 10 files changed, 194 insertions(+), 27 deletions(-) diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 47b4eff19f2f..7cad621fe99f 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -31,6 +31,11 @@ openclaw channels dead-letters list --channel telegram --account default `channels list` shows chat channels only: configured accounts by default, with `installed`, `configured`, and `enabled` status tags per account (`--json` for machine output). Pass `--all` to also surface bundled channels that have no configured account yet and installable catalog channels that are not yet on disk. Provider auth and model usage live elsewhere: `openclaw models auth list` for provider auth profiles, `openclaw status` or `openclaw models list` for usage/quota. +In an explicit multi-agent setup, workspace-scoped channel plugins come from +`agents.defaults.systemAgent.agentId`. Without that owner, `channels list` +returns the shared bundled, managed, and global inventory with a diagnostic; +it does not guess one agent workspace. + ## Status / capabilities / resolve / logs - `channels status`: `--channel `, `--probe`, `--timeout ` (default `10000`), `--json` diff --git a/docs/cli/status.md b/docs/cli/status.md index 6b38305b170e..666d1f0b650f 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -59,6 +59,9 @@ and `openclaw memory status --deep`. ## Usage and quota - `--usage` prints normalized provider usage windows as `X% left`. +- In an explicit multi-agent setup, `--usage` reads the auth profiles owned by + `agents.defaults.systemAgent.agentId`. Set that owner before using `--usage`; + OpenClaw does not guess one agent's credentials from an ambiguous roster. - MiniMax's raw `usage_percent` / `usagePercent` fields are remaining quota, so OpenClaw inverts them before display; count-based fields win when present. `model_remains` responses prefer the chat-model entry, derive the diff --git a/src/channels/plugins/read-only.legacy-workspace.test.ts b/src/channels/plugins/read-only.legacy-workspace.test.ts index 4b7b67d03124..b6934bd9f432 100644 --- a/src/channels/plugins/read-only.legacy-workspace.test.ts +++ b/src/channels/plugins/read-only.legacy-workspace.test.ts @@ -87,6 +87,7 @@ describe("read-only channel plugin legacy workspace discovery", () => { }); expect(resolution.plugins.map((plugin) => plugin.id)).toContain("research-chat"); + expect(resolution.manifestRecords.map((plugin) => plugin.id)).toContain("research-chat-plugin"); expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith( expect.objectContaining({ workspaceDir: path.resolve("/srv/ops") }), ); diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index e4a5060ac167..b2bf1c0f07e5 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -67,6 +67,7 @@ type ReadOnlyChannelPluginOptions = { type ReadOnlyChannelPluginResolution = { plugins: ChannelPlugin[]; + manifestRecords: readonly PluginManifestRecord[]; configuredChannelIds: string[]; missingConfiguredChannelIds: string[]; loadFailures: ReadOnlyChannelPluginLoadFailure[]; @@ -93,6 +94,7 @@ function cloneReadOnlyChannelPluginResolution( ): ReadOnlyChannelPluginResolution { return { plugins: [...resolution.plugins], + manifestRecords: [...resolution.manifestRecords], configuredChannelIds: [...resolution.configuredChannelIds], missingConfiguredChannelIds: [...resolution.missingConfiguredChannelIds], loadFailures: resolution.loadFailures.map((failure) => ({ ...failure })), @@ -862,6 +864,7 @@ export function resolveReadOnlyChannelPluginsForConfig( const plugins = [...byId.values()]; const resolution = { plugins, + manifestRecords, configuredChannelIds, missingConfiguredChannelIds: configuredChannelIds.filter((channelId) => !byId.has(channelId)), loadFailures, diff --git a/src/commands/channels.list.test.ts b/src/commands/channels.list.test.ts index a90396f413b1..44b376e3b207 100644 --- a/src/commands/channels.list.test.ts +++ b/src/commands/channels.list.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { stripAnsi } from "../../packages/terminal-core/src/ansi.js"; import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js"; import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { resolvePluginControlPlaneWorkspace } from "../plugins/control-plane-workspace.js"; import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js"; const mocks = vi.hoisted(() => ({ @@ -23,8 +24,10 @@ const mocks = vi.hoisted(() => ({ listManifestInstalledChannelIds: vi.fn<() => Set>(() => new Set()), resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(), callGateway: vi.fn(), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveDefaultAgentId: vi.fn(() => "main"), + resolvePluginControlPlaneWorkspace: vi.fn(() => ({ + workspaceDir: "/tmp/workspace", + workspaceScope: "selected", + })), resolvePluginMetadataSnapshot: vi.fn(), })); @@ -44,6 +47,10 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({ resolvePluginMetadataSnapshot: mocks.resolvePluginMetadataSnapshot, })); +vi.mock("../plugins/control-plane-workspace.js", () => ({ + resolvePluginControlPlaneWorkspace: mocks.resolvePluginControlPlaneWorkspace, +})); + vi.mock("../cli/command-secret-targets.js", () => ({ getChannelsCommandSecretTargetIds: () => new Set(), })); @@ -69,11 +76,6 @@ vi.mock("../plugins/official-external-plugin-repair-hints.js", () => ({ mocks.resolveMissingOfficialExternalChannelPluginRepairHint, })); -vi.mock("../agents/agent-scope.js", () => ({ - resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir, - resolveDefaultAgentId: mocks.resolveDefaultAgentId, -})); - import { channelsListCommand } from "./channels/list.js"; function createMockChannelPlugin(overrides: { @@ -139,6 +141,11 @@ describe("channels list", () => { mocks.resolveMissingOfficialExternalChannelPluginRepairHint.mockReturnValue(null); mocks.callGateway.mockReset(); mocks.callGateway.mockRejectedValue(new Error("gateway unavailable")); + mocks.resolvePluginControlPlaneWorkspace.mockReset(); + mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({ + workspaceDir: "/tmp/workspace", + workspaceScope: "selected", + }); mocks.resolvePluginMetadataSnapshot.mockReturnValue(mocks.metadataSnapshot); }); @@ -192,6 +199,88 @@ describe("channels list", () => { }); }); + it("keeps shared inventory when an explicit multi-agent roster has no system owner", async () => { + const runtime = createTestRuntime(); + const config = { + agents: { + ownership: "explicit" as const, + entries: { main: {}, research: {} }, + }, + }; + mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({ + workspaceScope: "omitted", + diagnostic: { + level: "warn", + code: "workspace-scope-omitted", + message: "Workspace plugin discovery was skipped for this explicit roster.", + }, + }); + mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([ + createCatalogEntry("qqbot", "QQ Bot"), + ]); + mocks.listManifestInstalledChannelIds.mockReturnValue(new Set(["qqbot"])); + mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config }); + + await channelsListCommand({ all: true, json: true }, runtime); + + expect(mocks.resolvePluginControlPlaneWorkspace).toHaveBeenCalledWith({ + config, + env: process.env, + }); + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith({ + config, + env: process.env, + allowWorkspaceScopedCurrent: true, + }); + expect(mocks.listTrustedChannelPluginCatalogEntries).toHaveBeenCalledWith({ + cfg: config, + discovery: mocks.metadataSnapshot.discovery, + }); + const payload = JSON.parse(loggedText(runtime)) as { + chat: Record; + diagnostics?: Array<{ code?: string }>; + }; + expect(payload.chat.qqbot?.installed).toBe(true); + expect(payload.diagnostics).toContainEqual( + expect.objectContaining({ code: "workspace-scope-omitted" }), + ); + }); + + it("uses the named system owner for workspace-scoped channel inventory", async () => { + const runtime = createTestRuntime(); + const config = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "research" } }, + entries: { main: {}, research: { workspace: "/tmp/research-workspace" } }, + }, + }; + mocks.resolvePluginControlPlaneWorkspace.mockReturnValue({ + workspaceDir: "/tmp/research-workspace", + workspaceScope: "selected", + }); + mocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot, config }); + + await channelsListCommand({ all: true, json: true }, runtime); + + expect(mocks.resolvePluginMetadataSnapshot).toHaveBeenCalledWith({ + config, + env: process.env, + workspaceDir: "/tmp/research-workspace", + allowWorkspaceScopedCurrent: true, + }); + expect(mocks.listTrustedChannelPluginCatalogEntries).toHaveBeenCalledWith({ + cfg: config, + workspaceDir: "/tmp/research-workspace", + discovery: mocks.metadataSnapshot.discovery, + }); + expect(mocks.listManifestInstalledChannelIds).toHaveBeenCalledWith({ + cfg: config, + workspaceDir: "/tmp/research-workspace", + index: mocks.metadataSnapshot.index, + }); + }); + it("keeps JSON output valid when only channels are provided (no usage field)", async () => { const runtime = createTestRuntime(); mocks.readConfigFileSnapshot.mockResolvedValue({ diff --git a/src/commands/channels/list.ts b/src/commands/channels/list.ts index 5ee19fc4f728..fc363a9e9556 100644 --- a/src/commands/channels/list.ts +++ b/src/commands/channels/list.ts @@ -1,7 +1,6 @@ // Implements `openclaw channels list` across runtime accounts, local config, and catalog-only entries. import { formatDocsLink } from "../../../packages/terminal-core/src/links.js"; import { theme } from "../../../packages/terminal-core/src/theme.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; import { isChannelVisibleInConfiguredLists } from "../../channels/plugins/exposure.js"; import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read-only.js"; @@ -14,6 +13,7 @@ import { type RuntimeChannelStatusPayload, } from "../../channels/status/read-model.js"; import { callGateway } from "../../gateway/call.js"; +import { resolvePluginControlPlaneWorkspace } from "../../plugins/control-plane-workspace.js"; import { resolveMissingOfficialExternalChannelPluginRepairHint } from "../../plugins/official-external-plugin-repair-hints.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; @@ -153,7 +153,11 @@ export async function channelsListCommand( return; } const showAll = opts.all === true; - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); + const workspace = resolvePluginControlPlaneWorkspace({ + config: cfg, + env: process.env, + }); + const workspaceDir = workspace.workspaceDir; // Plugin metadata is process-stable. Resolve it once and carry its manifest, // discovery, and installed-index facts through every list projection. const metadataSnapshot = resolvePluginMetadataSnapshot({ @@ -323,12 +327,18 @@ export async function channelsListCommand( origin: line.configured ? "configured" : line.installed ? "available" : "installable", }; } - writeRuntimeJson(runtime, { chat }); + writeRuntimeJson(runtime, { + chat, + ...(workspace.diagnostic ? { diagnostics: [workspace.diagnostic] } : {}), + }); return; } const lines: string[] = []; lines.push(theme.heading("Chat channels:")); + if (workspace.diagnostic) { + lines.push(theme.warn(`- ${workspace.diagnostic.message}`)); + } if (accountLines.length === 0 && catalogOnlyLines.length === 0) { lines.push( theme.muted( diff --git a/src/commands/status-all/channels.test.ts b/src/commands/status-all/channels.test.ts index f4480c66902d..85aa0b7888fa 100644 --- a/src/commands/status-all/channels.test.ts +++ b/src/commands/status-all/channels.test.ts @@ -27,8 +27,9 @@ vi.mock("../../channels/account-inspection.js", () => ({ })); vi.mock("../../channels/plugins/read-only.js", () => ({ - resolveReadOnlyChannelPluginsForConfig: () => ({ - plugins: mocks.listReadOnlyChannelPluginsForConfig(), + resolveReadOnlyChannelPluginsForConfig: (...args: unknown[]) => ({ + plugins: mocks.listReadOnlyChannelPluginsForConfig(...args), + manifestRecords: [], configuredChannelIds: [], missingConfiguredChannelIds: [ ...new Set([ @@ -108,6 +109,24 @@ describe("buildChannelsTable", () => { expect(detailRow?.Notes).toContain("credential available in gateway runtime"); }); + it("summarizes channels without selecting an owner from an explicit multi-agent roster", async () => { + const config = { + agents: { + ownership: "explicit" as const, + entries: { ops: {}, research: {} }, + }, + channels: { discord: { enabled: true } }, + }; + + const table = await buildChannelsTable(config); + + expect(table.rows).toContainEqual(expect.objectContaining({ id: "discord", state: "warn" })); + expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(config, { + activationSourceConfig: config, + includeSetupFallbackPlugins: true, + }); + }); + it("warns when a configured token is unavailable and there is no live account proof", async () => { const table = await buildChannelsTable({ channels: { discord: { enabled: true } } }); diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index ea0dd08bd863..e2064156868f 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -5,7 +5,6 @@ import fs from "node:fs"; import { asRecord } from "@openclaw/normalization-core/record-coerce"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js"; -import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; import { resolveInspectedChannelAccount } from "../../channels/account-inspection.js"; import { hasConfiguredUnavailableCredentialStatus } from "../../channels/account-snapshot-fields.js"; import { @@ -29,7 +28,6 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { formatPhoneNumberForCli } from "../../infra/phone-number-presentation.js"; import { listExplicitConfiguredChannelIdsForConfig } from "../../plugins/channel-plugin-ids.js"; import { resolveMissingOfficialExternalChannelPluginRepairHints } from "../../plugins/official-external-plugin-repair-hints.js"; -import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { summarizeTokenConfig, type ChannelAccountTokenSummaryRow, @@ -257,17 +255,9 @@ export async function buildChannelsTable( const sourceConfig = opts?.sourceConfig ?? cfg; const includeSetupFallbackPlugins = opts?.includeSetupFallbackPlugins ?? true; const credentialResolutionSkipped = opts?.credentialResolutionSkipped === true; - const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)); - const metadataSnapshot = resolvePluginMetadataSnapshot({ - config: cfg, - ...(workspaceDir ? { workspaceDir } : {}), - env: process.env, - allowWorkspaceScopedCurrent: true, - }); const readOnlyPlugins = resolveReadOnlyChannelPluginsForConfig(cfg, { activationSourceConfig: sourceConfig, includeSetupFallbackPlugins, - metadataSnapshot, }); for (const plugin of readOnlyPlugins.plugins) { // Use the plugin's default account even when no accounts are configured so setup guidance is concrete. @@ -530,7 +520,7 @@ export async function buildChannelsTable( config: cfg, activationSourceConfig: sourceConfig, channelIds: missingCandidateChannelIds, - manifestRecords: metadataSnapshot.plugins, + manifestRecords: readOnlyPlugins.manifestRecords, }).map((hint) => [hint.channelId, hint]), ); for (const channelId of missingCandidateChannelIds) { diff --git a/src/commands/status-runtime-shared.test.ts b/src/commands/status-runtime-shared.test.ts index eac4a055477a..055298e24896 100644 --- a/src/commands/status-runtime-shared.test.ts +++ b/src/commands/status-runtime-shared.test.ts @@ -143,6 +143,41 @@ describe("status-runtime-shared", () => { expect(usageCall.agentDir).toContain("main"); }); + it("uses the named system agent for agent-scoped usage credentials", async () => { + const config = { + agents: { + ownership: "explicit" as const, + defaults: { systemAgent: { agentId: "ops" } }, + entries: { + main: { agentDir: "/tmp/status-main-agent" }, + ops: { agentDir: "/tmp/status-ops-agent" }, + }, + }, + }; + + await resolveStatusUsageSummary({ config }); + + expect(mocks.loadProviderUsageSummary).toHaveBeenCalledWith({ + timeoutMs: undefined, + config, + agentDir: "/tmp/status-ops-agent", + }); + }); + + it("requires a system owner for usage credentials in an explicit multi-agent roster", async () => { + await expect( + resolveStatusUsageSummary({ + config: { + agents: { + ownership: "explicit", + entries: { main: {}, ops: {} }, + }, + }, + }), + ).rejects.toThrow("Set agents.defaults.systemAgent.agentId"); + expect(mocks.loadProviderUsageSummary).not.toHaveBeenCalled(); + }); + it("adds Codex synthetic usage for configured OpenAI Codex runtime routes without profiles", async () => { mocks.loadProviderUsageSummary .mockResolvedValueOnce({ diff --git a/src/commands/status-runtime-shared.ts b/src/commands/status-runtime-shared.ts index 8d48729d2966..8345d3c070fb 100644 --- a/src/commands/status-runtime-shared.ts +++ b/src/commands/status-runtime-shared.ts @@ -1,7 +1,8 @@ // Shared runtime probes used by status text and JSON commands. // Heavy modules stay lazily loaded so fast status output avoids security/provider/gateway costs. -import { resolveDefaultAgentDir } from "../agents/agent-scope.js"; +import { resolveSystemAgentTargetAgentId } from "../agents/agent-scope-config.js"; +import { resolveAgentDir } from "../agents/agent-scope.js"; import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js"; import { resolveModelAuthLabel } from "../agents/model-auth-label.js"; import { resolveDefaultModelForAgent } from "../agents/model-selection.js"; @@ -46,15 +47,18 @@ function loadGatewayCallModule() { function shouldUseConfiguredCodexSyntheticUsage(params: { config: OpenClawConfig; agentDir: string; + agentId?: string; }): boolean { const configuredDefault = resolveDefaultModelForAgent({ cfg: params.config, + agentId: params.agentId, allowPluginNormalization: false, }); const policy = resolveAgentHarnessPolicy({ config: params.config, provider: configuredDefault.provider, modelId: configuredDefault.model, + agentId: params.agentId, }); if ( !shouldUseCodexSyntheticUsageForRuntime({ @@ -108,19 +112,27 @@ export async function resolveStatusSecurityAudit(params: { type StatusUsageSummaryOptions = { config: OpenClawConfig; timeoutMs?: number; + agentId?: string; agentDir?: string; }; -/** Loads provider usage for status output, defaulting to the config's default agent directory. */ +/** Loads provider usage for status output from an explicit or ambient system-agent scope. */ export async function resolveStatusUsageSummary(params: StatusUsageSummaryOptions) { const { loadProviderUsageSummary } = await loadProviderUsage(); - const agentDir = params.agentDir ?? resolveDefaultAgentDir(params.config); + let agentId = params.agentId + ? resolveSystemAgentTargetAgentId(params.config, params.agentId) + : undefined; + let agentDir = params.agentDir; + if (!agentDir) { + agentId ??= resolveSystemAgentTargetAgentId(params.config); + agentDir = resolveAgentDir(params.config, agentId); + } const usage = await loadProviderUsageSummary({ timeoutMs: params.timeoutMs, config: params.config, agentDir, }); - if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir })) { + if (!shouldUseConfiguredCodexSyntheticUsage({ config: params.config, agentDir, agentId })) { return usage; } const codexUsage = await loadProviderUsageSummary({