mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-18 23:04:45 +00:00
fix(channels): prefer runtime status in channel list (#82016)
This commit is contained in:
committed by
GitHub
parent
7e7ce53e5a
commit
b672be59ae
@@ -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.
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user