fix(status): keep multi-agent diagnostics owner-safe (#123826)

* fix(status): preserve explicit multi-agent inventory ownership

* chore: leave changelog to release automation
This commit is contained in:
Peter Steinberger
2026-08-14 14:10:23 -07:00
committed by GitHub
parent 3c5e2ff296
commit b3f1cd36db
10 changed files with 194 additions and 27 deletions
+5
View File
@@ -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 <name>`, `--probe`, `--timeout <ms>` (default `10000`), `--json`
+3
View File
@@ -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
@@ -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") }),
);
+3
View File
@@ -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,
+96 -7
View File
@@ -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<string>>(() => new Set()),
resolveMissingOfficialExternalChannelPluginRepairHint: vi.fn(),
callGateway: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveDefaultAgentId: vi.fn(() => "main"),
resolvePluginControlPlaneWorkspace: vi.fn<typeof resolvePluginControlPlaneWorkspace>(() => ({
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<string>(),
}));
@@ -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<string, { installed: boolean }>;
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({
+13 -3
View File
@@ -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(
+21 -2
View File
@@ -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 } } });
+1 -11
View File
@@ -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) {
@@ -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({
+16 -4
View File
@@ -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({