fix(channels): prefer runtime status in channel list (#82016)

This commit is contained in:
Peter Steinberger
2026-05-15 05:42:10 +01:00
committed by GitHub
parent 7e7ce53e5a
commit b672be59ae
3 changed files with 186 additions and 11 deletions

View File

@@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Discord/channels: make `openclaw channels list --all` prefer reachable Gateway runtime account status and mark configured-but-unavailable credentials, avoiding false `not configured` output when Discord is running from service-only env. Fixes #79343. Thanks @EricY019.
- Installer: handle noninteractive git installs from moving refs without tag-fetch conflicts, while keeping immutable refs on frozen lockfile installs. (#81875) Thanks @keshavbotagent.
- Codex app-server: inject native client factories per run and compaction attempt instead of using module-scope test state, avoiding temporal-dead-zone reads during cyclic startup. (#81148) Thanks @bdjben.
- Plugin skills: replace generated Windows plugin-skill directories before publishing the current skill link, avoiding repeated `EINVAL` warnings from stale non-symlink entries. Fixes #81432. (#81446) Thanks @hclsys and @vincentkoc.

View File

@@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({
isCatalogChannelInstalled: vi.fn<(params: { entry: ChannelPluginCatalogEntry }) => boolean>(
() => true,
),
callGateway: vi.fn(),
resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"),
resolveDefaultAgentId: vi.fn(() => "main"),
}));
@@ -29,6 +30,10 @@ vi.mock("../cli/command-config-resolution.js", () => ({
resolveCommandConfigWithSecrets: mocks.resolveCommandConfigWithSecrets,
}));
vi.mock("../gateway/call.js", () => ({
callGateway: mocks.callGateway,
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: () => new Set<string>(),
}));
@@ -115,6 +120,8 @@ describe("channels list", () => {
mocks.listTrustedChannelPluginCatalogEntries.mockReturnValue([]);
mocks.isCatalogChannelInstalled.mockReset();
mocks.isCatalogChannelInstalled.mockReturnValue(true);
mocks.callGateway.mockReset();
mocks.callGateway.mockRejectedValue(new Error("gateway unavailable"));
});
it("does not include auth providers in JSON output (auth section was removed)", async () => {
@@ -221,6 +228,112 @@ describe("channels list", () => {
expect(output).not.toContain("Auth providers");
});
it("prefers reachable gateway account snapshots over command-local token state", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }),
]);
mocks.buildChannelAccountSnapshot.mockResolvedValue({
accountId: "default",
configured: false,
tokenSource: "none",
enabled: true,
});
mocks.callGateway.mockResolvedValue({
channelAccounts: {
discord: [
{
accountId: "default",
name: "clawsweeper",
configured: true,
tokenSource: "env",
tokenStatus: "available",
enabled: true,
},
],
},
});
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
discord: { enabled: true },
},
},
});
await channelsListCommand({ all: true }, runtime);
expect(mocks.callGateway).toHaveBeenCalledWith({
method: "channels.status",
params: { probe: false, timeoutMs: 5000 },
timeoutMs: 5000,
});
const output = stripAnsi(loggedText(runtime));
expect(output).toContain("Discord default (clawsweeper):");
expect(output).toContain("configured");
expect(output).toContain("token=env");
expect(output).not.toContain("not configured");
expect(output).not.toContain("token=none");
});
it("falls back to command-local account snapshots when gateway status is unavailable", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }),
]);
mocks.buildChannelAccountSnapshot.mockResolvedValue({
accountId: "default",
configured: false,
tokenSource: "none",
enabled: true,
});
mocks.callGateway.mockRejectedValue(new Error("gateway unavailable"));
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
discord: { enabled: true },
},
},
});
await channelsListCommand({ all: true }, runtime);
const output = stripAnsi(loggedText(runtime));
expect(output).toContain("Discord default:");
expect(output).toContain("not configured");
expect(output).toContain("token=none");
});
it("marks configured-but-unavailable credential sources in text output", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([
createMockChannelPlugin({ id: "discord", label: "Discord", accountIds: ["default"] }),
]);
mocks.buildChannelAccountSnapshot.mockResolvedValue({
accountId: "default",
configured: true,
tokenSource: "config",
tokenStatus: "configured_unavailable",
enabled: true,
});
mocks.readConfigFileSnapshot.mockResolvedValue({
...baseConfigSnapshot,
config: {
channels: {
discord: { enabled: true },
},
},
});
await channelsListCommand({ all: true }, runtime);
const output = stripAnsi(loggedText(runtime));
expect(output).toContain("configured");
expect(output).toContain("token=config-unavailable");
});
it("default output does NOT show installable catalog channels (only configured ones)", async () => {
const runtime = createTestRuntime();
mocks.listReadOnlyChannelPluginsForConfig.mockReturnValue([]);

View File

@@ -5,6 +5,7 @@ import { listReadOnlyChannelPluginsForConfig } from "../../channels/plugins/read
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js";
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.public.js";
import { callGateway } from "../../gateway/call.js";
import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js";
import { formatDocsLink } from "../../terminal/links.js";
import { theme } from "../../terminal/theme.js";
@@ -17,6 +18,47 @@ export type ChannelsListOptions = {
all?: boolean;
};
type RuntimeChannelStatus = {
channelAccounts?: Record<string, unknown>;
};
function normalizeRuntimeAccounts(
payload: RuntimeChannelStatus | null,
): Map<string, ChannelAccountSnapshot[]> {
const out = new Map<string, ChannelAccountSnapshot[]>();
const rawAccounts = payload?.channelAccounts;
if (!rawAccounts || typeof rawAccounts !== "object") {
return out;
}
for (const [channelId, accounts] of Object.entries(rawAccounts)) {
if (!Array.isArray(accounts)) {
continue;
}
const normalized = accounts.filter(
(account): account is ChannelAccountSnapshot =>
Boolean(account) &&
typeof account === "object" &&
typeof (account as { accountId?: unknown }).accountId === "string",
);
if (normalized.length > 0) {
out.set(channelId, normalized);
}
}
return out;
}
async function readGatewayChannelStatus(): Promise<RuntimeChannelStatus | null> {
try {
return (await callGateway({
method: "channels.status",
params: { probe: false, timeoutMs: 5_000 },
timeoutMs: 5_000,
})) as RuntimeChannelStatus;
} catch {
return null;
}
}
const colorValue = (value: string) => {
if (value === "none") {
return theme.error(value);
@@ -39,14 +81,20 @@ function formatInstalled(value: boolean): string {
return value ? theme.success("installed") : theme.warn("not installed");
}
function formatTokenSource(source?: string): string {
function formatCredentialSource(source?: string, status?: string): string {
const value = source || "none";
return `token=${colorValue(value)}`;
if (status === "configured_unavailable" && value !== "none") {
return theme.warn(`${value}-unavailable`);
}
return colorValue(value);
}
function formatSource(label: string, source?: string): string {
const value = source || "none";
return `${label}=${colorValue(value)}`;
function formatTokenSource(source?: string, status?: string): string {
return `token=${formatCredentialSource(source, status)}`;
}
function formatSource(label: string, source?: string, status?: string): string {
return `${label}=${formatCredentialSource(source, status)}`;
}
function formatLinked(value: boolean): string {
@@ -83,13 +131,13 @@ function formatAccountLine(params: {
bits.push(formatLinked(snapshot.linked));
}
if (snapshot.tokenSource) {
bits.push(formatTokenSource(snapshot.tokenSource));
bits.push(formatTokenSource(snapshot.tokenSource, snapshot.tokenStatus));
}
if (snapshot.botTokenSource) {
bits.push(formatSource("bot", snapshot.botTokenSource));
bits.push(formatSource("bot", snapshot.botTokenSource, snapshot.botTokenStatus));
}
if (snapshot.appTokenSource) {
bits.push(formatSource("app", snapshot.appTokenSource));
bits.push(formatSource("app", snapshot.appTokenSource, snapshot.appTokenStatus));
}
if (snapshot.baseUrl) {
bits.push(`base=${theme.muted(snapshot.baseUrl)}`);
@@ -129,6 +177,10 @@ export async function channelsListCommand(
cfg,
...(workspaceDir ? { workspaceDir } : {}),
});
const runtimeAccountsByChannel =
opts.json === true
? new Map<string, ChannelAccountSnapshot[]>()
: normalizeRuntimeAccounts(await readGatewayChannelStatus());
const installedByChannelId = new Map<string, boolean>();
for (const entry of catalogEntries) {
installedByChannelId.set(
@@ -158,8 +210,14 @@ export async function channelsListCommand(
const accountIds = plugin.config.listAccountIds(cfg);
if (accountIds && accountIds.length > 0) {
renderedChannelIds.add(plugin.id);
for (const accountId of accountIds) {
const snapshot = await buildChannelAccountSnapshot({ plugin, cfg, accountId });
const runtimeAccounts = runtimeAccountsByChannel.get(plugin.id) ?? [];
const mergedAccountIds = [
...new Set([...accountIds, ...runtimeAccounts.map((account) => account.accountId)]),
];
for (const accountId of mergedAccountIds) {
const runtimeSnapshot = runtimeAccounts.find((account) => account.accountId === accountId);
const snapshot =
runtimeSnapshot ?? (await buildChannelAccountSnapshot({ plugin, cfg, accountId }));
accountLines.push({
plugin,
snapshot,
@@ -184,10 +242,13 @@ export async function channelsListCommand(
cfg,
accountId: "default",
});
const runtimeSnapshot = runtimeAccountsByChannel
.get(plugin.id)
?.find((account) => account.accountId === "default");
renderedChannelIds.add(plugin.id);
accountLines.push({
plugin,
snapshot,
snapshot: runtimeSnapshot ?? snapshot,
installed: isInstalled(plugin.id),
});
}