fix(cli): render channel capability JSON failures (#128155)

This commit is contained in:
Peter Steinberger
2026-08-23 02:36:36 -07:00
committed by GitHub
parent a0bf9cf693
commit 0a70356c55
3 changed files with 157 additions and 20 deletions
@@ -3,6 +3,7 @@ import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getChannelPlugin, listChannelPlugins } from "../../channels/plugins/index.js";
import type { ChannelPlugin } from "../../channels/plugins/types.public.js";
import { ExpectedCliError } from "../../cli/failure-output.js";
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import { channelsCapabilitiesCommand } from "./capabilities.js";
@@ -191,6 +192,77 @@ describe("channelsCapabilitiesCommand", () => {
expect(logs).toStrictEqual([JSON.stringify({ channels: [] }, null, 2)]);
});
it.each([
{
name: "account without a channel",
options: { account: "ghost", json: true },
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
discoversChannels: false,
},
{
name: "account with all channels",
options: { channel: "all", account: "ghost" },
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
discoversChannels: false,
},
{
name: "target without a channel",
options: { target: "channel:1", json: true },
message: "--target requires a specific --channel. Run openclaw channels list to choose one.",
discoversChannels: false,
},
{
name: "target with all channels",
options: { channel: "all", target: "channel:1" },
message: "--target requires a specific --channel. Run openclaw channels list to choose one.",
discoversChannels: false,
},
{
name: "account before target when both lack a channel",
options: { account: "ghost", target: "channel:1" },
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
discoversChannels: false,
},
{
name: "unknown channel after installable plugin lookup",
options: { channel: "definitely-not-a-channel", json: true },
message:
'Unknown channel "definitely-not-a-channel". Run `openclaw channels list --all` to see configured and installable channels.',
discoversChannels: true,
},
])("rejects $name before resolving or probing an account", async (testCase) => {
const plugin = buildPlugin({ id: "slack" });
const listAccountIds = vi.fn(() => ["default"]);
const resolveAccount = vi.fn(() => ({ accountId: "default" }));
const probeAccount = vi.fn(async () => ({ ok: true }));
plugin.config.listAccountIds = listAccountIds;
plugin.config.resolveAccount = resolveAccount;
plugin.status = { probeAccount };
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([plugin]);
const failure = channelsCapabilitiesCommand(testCase.options, runtime);
await expect(failure).rejects.toBeInstanceOf(ExpectedCliError);
await expect(failure).rejects.toMatchObject({
message: testCase.message,
humanOutput: testCase.message,
machineOutput: testCase.message,
});
expect(logs).toStrictEqual([]);
expect(errors).toStrictEqual([]);
expect(mocks.listReadOnlyChannelPluginsForConfig).toHaveBeenCalledTimes(
testCase.discoversChannels ? 1 : 0,
);
expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledTimes(
testCase.discoversChannels ? 1 : 0,
);
expect(listAccountIds).not.toHaveBeenCalled();
expect(resolveAccount).not.toHaveBeenCalled();
expect(probeAccount).not.toHaveBeenCalled();
expect(mocks.replaceConfigFile).not.toHaveBeenCalled();
expect(mocks.refreshPluginRegistryAfterConfigMutation).not.toHaveBeenCalled();
});
it("rejects malformed timeouts before capability probes", async () => {
const probeAccount = vi.fn(async () => ({ ok: true }));
const plugin = buildPlugin({
+7 -20
View File
@@ -18,6 +18,7 @@ import type {
} from "../../channels/plugins/types.public.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { formatUnknownChannelMessage } from "../../cli/error-format.js";
import { ExpectedCliError } from "../../cli/failure-output.js";
import { parseTimeoutMsWithFallback } from "../../cli/parse-timeout.js";
import { readConfigFileSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/config.js";
@@ -314,23 +315,10 @@ export async function channelsCapabilitiesCommand(
const rawChannel = normalizeLowercaseStringOrEmpty(opts.channel);
const rawTarget = normalizeOptionalString(opts.target) ?? "";
if (opts.account && (!rawChannel || rawChannel === "all")) {
runtime.error(
danger(
`--account requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`,
),
);
runtime.exit(1);
return;
}
if (rawTarget && (!rawChannel || rawChannel === "all")) {
runtime.error(
danger(
`--target requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`,
),
);
runtime.exit(1);
return;
if ((!rawChannel || rawChannel === "all") && (opts.account || rawTarget)) {
const option = opts.account ? "--account" : "--target";
const message = `${option} requires a specific --channel. Run ${formatCliCommand("openclaw channels list")} to choose one.`;
throw new ExpectedCliError({ message, humanOutput: danger(message), machineOutput: message });
}
const plugins = listReadOnlyChannelPluginsForConfig(cfg, {
@@ -371,9 +359,8 @@ export async function channelsCapabilitiesCommand(
);
return;
}
runtime.error(danger(formatUnknownChannelMessage({ channel: rawChannel })));
runtime.exit(1);
return;
const message = formatUnknownChannelMessage({ channel: rawChannel });
throw new ExpectedCliError({ message, humanOutput: danger(message), machineOutput: message });
}
const reports: ChannelCapabilitiesReport[] = [];
+78
View File
@@ -331,6 +331,84 @@ describe("cli json stdout contract", () => {
);
});
it.each([
{
name: "account validation in human mode",
args: ["channels", "capabilities", "--account", "ghost"],
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
human: true,
},
{
name: "account validation with JSON before its option",
args: ["channels", "capabilities", "--json", "--account", "ghost"],
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
},
{
name: "target validation with JSON after its option and explicit Commander routing",
args: ["channels", "capabilities", "--target", "channel:1", "--json"],
message: "--target requires a specific --channel. Run openclaw channels list to choose one.",
commander: true,
},
{
name: "unknown channel validation with JSON before its option",
args: ["channels", "capabilities", "--json", "--channel", "definitely-not-a-channel"],
message:
'Unknown channel "definitely-not-a-channel". Run `openclaw channels list --all` to see configured and installable channels.',
},
{
name: "account validation through dual-TTY finalization",
args: ["channels", "capabilities", "--account", "ghost", "--json"],
message: "--account requires a specific --channel. Run openclaw channels list to choose one.",
tty: true,
},
])(
"renders channels capabilities $name through the canonical failure owner",
async (testCase) => {
await withTempHome(
async (tempHome) => {
const preload = Buffer.from(
[
'import net from "node:net";',
'net.Socket.prototype.connect = function () { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
'globalThis.fetch = async () => { throw new Error("AUTOQA_NETWORK_FORBIDDEN"); };',
...("tty" in testCase
? [
'Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true });',
'Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true });',
]
: []),
].join("\n"),
).toString("base64");
const result = runBuiltCli(tempHome, testCase.args, {
NODE_OPTIONS: `--import=data:text/javascript;base64,${preload}`,
OPENCLAW_STATE_DIR: path.join(tempHome, "isolated-state"),
OPENCLAW_CONFIG_PATH: path.join(tempHome, "missing-openclaw.json"),
OPENCLAW_GATEWAY_PORT: "29871",
...("commander" in testCase ? { OPENCLAW_DISABLE_ROUTE_FIRST: "1" } : {}),
...("tty" in testCase ? { FORCE_COLOR: "1" } : {}),
});
expect(result.status, result.stderr).toBe(1);
if ("human" in testCase) {
expect(result.stdout).toBe("");
} else {
expect(result.stdout, result.stderr).not.toMatch(/[\u001B\u0007]/u);
expect(JSON.parse(result.stdout)).toEqual({
ok: false,
error: { type: "cli_error", message: testCase.message },
});
}
expect(result.stderr).toContain(testCase.message);
expect(result.stderr).not.toContain("AUTOQA_NETWORK_FORBIDDEN");
if ("tty" in testCase) {
expect(result.stderr).toContain("\u001B[?25h");
}
},
{ prefix: "openclaw-channels-capabilities-failure-e2e-" },
);
},
);
it("returns one canonical document for a command that previously failed on stderr only", async () => {
await withTempHome(
async (tempHome) => {