mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-09 13:07:39 +00:00
CLI: make read-only SecretRef status flows degrade safely (#37023)
* CLI: add read-only SecretRef inspection * CLI: fix read-only SecretRef status regressions * CLI: preserve read-only SecretRef status fallbacks * Docs: document read-only channel inspection hook * CLI: preserve audit coverage for read-only SecretRefs * CLI: fix read-only status account selection * CLI: fix targeted gateway fallback analysis * CLI: fix Slack HTTP read-only inspection * CLI: align audit credential status checks * CLI: restore Telegram read-only fallback semantics
This commit is contained in:
271
src/commands/channels.config-only-status-output.test.ts
Normal file
271
src/commands/channels.config-only-status-output.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { formatConfigChannelsStatusLines } from "./channels/status.js";
|
||||
|
||||
function makeUnavailableTokenPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "token-only",
|
||||
meta: {
|
||||
id: "token-only",
|
||||
label: "TokenOnly",
|
||||
selectionLabel: "TokenOnly",
|
||||
docsPath: "/channels/token-only",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeResolvedTokenPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "token-only",
|
||||
meta: {
|
||||
id: "token-only",
|
||||
label: "TokenOnly",
|
||||
selectionLabel: "TokenOnly",
|
||||
docsPath: "/channels/token-only",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { secretResolved?: boolean }).secretResolved
|
||||
? {
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}
|
||||
: {
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeResolvedTokenPluginWithoutInspectAccount(): ChannelPlugin {
|
||||
return {
|
||||
id: "token-only",
|
||||
meta: {
|
||||
id: "token-only",
|
||||
label: "TokenOnly",
|
||||
selectionLabel: "TokenOnly",
|
||||
docsPath: "/channels/token-only",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
resolveAccount: (cfg) => {
|
||||
if (!(cfg as { secretResolved?: boolean }).secretResolved) {
|
||||
throw new Error("raw SecretRef reached resolveAccount");
|
||||
}
|
||||
return {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
token: "resolved-token",
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
};
|
||||
},
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnavailableHttpSlackPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "resolved-bot",
|
||||
botTokenSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecret: "",
|
||||
signingSecretSource: "config",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("config-only channels status output", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
});
|
||||
|
||||
it("shows configured-but-unavailable credentials distinctly from not configured", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "token-only",
|
||||
source: "test",
|
||||
plugin: makeUnavailableTokenPlugin(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await formatConfigChannelsStatusLines({ channels: {} } as never, {
|
||||
mode: "local",
|
||||
});
|
||||
|
||||
const joined = lines.join("\n");
|
||||
expect(joined).toContain("TokenOnly");
|
||||
expect(joined).toContain("configured, secret unavailable in this command path");
|
||||
expect(joined).toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("prefers resolved config snapshots when command-local secret resolution succeeds", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "token-only",
|
||||
source: "test",
|
||||
plugin: makeResolvedTokenPlugin(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await formatConfigChannelsStatusLines(
|
||||
{ secretResolved: true, channels: {} } as never,
|
||||
{
|
||||
mode: "local",
|
||||
},
|
||||
{
|
||||
sourceConfig: { channels: {} } as never,
|
||||
},
|
||||
);
|
||||
|
||||
const joined = lines.join("\n");
|
||||
expect(joined).toContain("TokenOnly");
|
||||
expect(joined).toContain("configured");
|
||||
expect(joined).toContain("token:config");
|
||||
expect(joined).not.toContain("secret unavailable in this command path");
|
||||
expect(joined).not.toContain("token:config (unavailable)");
|
||||
});
|
||||
|
||||
it("does not resolve raw source config for extension channels without inspectAccount", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "token-only",
|
||||
source: "test",
|
||||
plugin: makeResolvedTokenPluginWithoutInspectAccount(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await formatConfigChannelsStatusLines(
|
||||
{ secretResolved: true, channels: {} } as never,
|
||||
{
|
||||
mode: "local",
|
||||
},
|
||||
{
|
||||
sourceConfig: { channels: {} } as never,
|
||||
},
|
||||
);
|
||||
|
||||
const joined = lines.join("\n");
|
||||
expect(joined).toContain("TokenOnly");
|
||||
expect(joined).toContain("configured");
|
||||
expect(joined).toContain("token:config");
|
||||
expect(joined).not.toContain("secret unavailable in this command path");
|
||||
});
|
||||
|
||||
it("renders Slack HTTP signing-secret availability in config-only status", async () => {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
source: "test",
|
||||
plugin: makeUnavailableHttpSlackPlugin(),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const lines = await formatConfigChannelsStatusLines({ channels: {} } as never, {
|
||||
mode: "local",
|
||||
});
|
||||
|
||||
const joined = lines.join("\n");
|
||||
expect(joined).toContain("Slack");
|
||||
expect(joined).toContain("configured, secret unavailable in this command path");
|
||||
expect(joined).toContain("mode:http");
|
||||
expect(joined).toContain("bot:config");
|
||||
expect(joined).toContain("signing:config (unavailable)");
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
|
||||
config: loadedRaw,
|
||||
commandName: "channels resolve",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "operational_readonly",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import {
|
||||
type CommandSecretResolutionMode,
|
||||
resolveCommandSecretRefsViaGateway,
|
||||
} from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
|
||||
@@ -8,8 +11,14 @@ import { requireValidConfigSnapshot } from "../config-validation.js";
|
||||
|
||||
export type ChatChannel = ChannelId;
|
||||
|
||||
export { requireValidConfigSnapshot };
|
||||
|
||||
export async function requireValidConfig(
|
||||
runtime: RuntimeEnv = defaultRuntime,
|
||||
secretResolution?: {
|
||||
commandName?: string;
|
||||
mode?: CommandSecretResolutionMode;
|
||||
},
|
||||
): Promise<OpenClawConfig | null> {
|
||||
const cfg = await requireValidConfigSnapshot(runtime);
|
||||
if (!cfg) {
|
||||
@@ -17,8 +26,9 @@ export async function requireValidConfig(
|
||||
}
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "channels",
|
||||
commandName: secretResolution?.commandName ?? "channels",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: secretResolution?.mode,
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import {
|
||||
hasConfiguredUnavailableCredentialStatus,
|
||||
hasResolvedCredentialValue,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
import { listChannelPlugins } from "../../channels/plugins/index.js";
|
||||
import { buildChannelAccountSnapshot } from "../../channels/plugins/status.js";
|
||||
import {
|
||||
buildChannelAccountSnapshot,
|
||||
buildReadOnlySourceChannelAccountSnapshot,
|
||||
} from "../../channels/plugins/status.js";
|
||||
import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js";
|
||||
import { formatCliCommand } from "../../cli/command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
|
||||
import { withProgress } from "../../cli/progress.js";
|
||||
import { type OpenClawConfig, readConfigFileSnapshot } from "../../config/config.js";
|
||||
import { callGateway } from "../../gateway/call.js";
|
||||
@@ -10,7 +19,11 @@ import { formatTimeAgo } from "../../infra/format-time/format-relative.ts";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import { type ChatChannel, formatChannelAccountLabel, requireValidConfig } from "./shared.js";
|
||||
import {
|
||||
type ChatChannel,
|
||||
formatChannelAccountLabel,
|
||||
requireValidConfigSnapshot,
|
||||
} from "./shared.js";
|
||||
|
||||
export type ChannelsStatusOptions = {
|
||||
json?: boolean;
|
||||
@@ -23,7 +36,14 @@ function appendEnabledConfiguredLinkedBits(bits: string[], account: Record<strin
|
||||
bits.push(account.enabled ? "enabled" : "disabled");
|
||||
}
|
||||
if (typeof account.configured === "boolean") {
|
||||
bits.push(account.configured ? "configured" : "not configured");
|
||||
if (account.configured) {
|
||||
bits.push("configured");
|
||||
if (hasConfiguredUnavailableCredentialStatus(account)) {
|
||||
bits.push("secret unavailable in this command path");
|
||||
}
|
||||
} else {
|
||||
bits.push("not configured");
|
||||
}
|
||||
}
|
||||
if (typeof account.linked === "boolean") {
|
||||
bits.push(account.linked ? "linked" : "not linked");
|
||||
@@ -37,15 +57,20 @@ function appendModeBit(bits: string[], account: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
function appendTokenSourceBits(bits: string[], account: Record<string, unknown>) {
|
||||
if (typeof account.tokenSource === "string" && account.tokenSource) {
|
||||
bits.push(`token:${account.tokenSource}`);
|
||||
}
|
||||
if (typeof account.botTokenSource === "string" && account.botTokenSource) {
|
||||
bits.push(`bot:${account.botTokenSource}`);
|
||||
}
|
||||
if (typeof account.appTokenSource === "string" && account.appTokenSource) {
|
||||
bits.push(`app:${account.appTokenSource}`);
|
||||
}
|
||||
const appendSourceBit = (label: string, sourceKey: string, statusKey: string) => {
|
||||
const source = account[sourceKey];
|
||||
if (typeof source !== "string" || !source || source === "none") {
|
||||
return;
|
||||
}
|
||||
const status = account[statusKey];
|
||||
const unavailable = status === "configured_unavailable" ? " (unavailable)" : "";
|
||||
bits.push(`${label}:${source}${unavailable}`);
|
||||
};
|
||||
|
||||
appendSourceBit("token", "tokenSource", "tokenStatus");
|
||||
appendSourceBit("bot", "botTokenSource", "botTokenStatus");
|
||||
appendSourceBit("app", "appTokenSource", "appTokenStatus");
|
||||
appendSourceBit("signing", "signingSecretSource", "signingSecretStatus");
|
||||
}
|
||||
|
||||
function appendBaseUrlBit(bits: string[], account: Record<string, unknown>) {
|
||||
@@ -184,9 +209,10 @@ export function formatGatewayChannelsStatusLines(payload: Record<string, unknown
|
||||
return lines;
|
||||
}
|
||||
|
||||
async function formatConfigChannelsStatusLines(
|
||||
export async function formatConfigChannelsStatusLines(
|
||||
cfg: OpenClawConfig,
|
||||
meta: { path?: string; mode?: "local" | "remote" },
|
||||
opts?: { sourceConfig?: OpenClawConfig },
|
||||
): Promise<string[]> {
|
||||
const lines: string[] = [];
|
||||
lines.push(theme.warn("Gateway not reachable; showing config-only status."));
|
||||
@@ -211,6 +237,7 @@ async function formatConfigChannelsStatusLines(
|
||||
});
|
||||
|
||||
const plugins = listChannelPlugins();
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
for (const plugin of plugins) {
|
||||
const accountIds = plugin.config.listAccountIds(cfg);
|
||||
if (!accountIds.length) {
|
||||
@@ -218,12 +245,24 @@ async function formatConfigChannelsStatusLines(
|
||||
}
|
||||
const snapshots: ChannelAccountSnapshot[] = [];
|
||||
for (const accountId of accountIds) {
|
||||
const snapshot = await buildChannelAccountSnapshot({
|
||||
const sourceSnapshot = await buildReadOnlySourceChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg: sourceConfig,
|
||||
accountId,
|
||||
});
|
||||
const resolvedSnapshot = await buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
});
|
||||
snapshots.push(snapshot);
|
||||
snapshots.push(
|
||||
sourceSnapshot &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceSnapshot) &&
|
||||
(!hasResolvedCredentialValue(resolvedSnapshot) ||
|
||||
(sourceSnapshot.configured === true && resolvedSnapshot.configured === false))
|
||||
? sourceSnapshot
|
||||
: resolvedSnapshot,
|
||||
);
|
||||
}
|
||||
if (snapshots.length > 0) {
|
||||
lines.push(...accountLines(plugin.id, snapshots));
|
||||
@@ -268,18 +307,31 @@ export async function channelsStatusCommand(
|
||||
runtime.log(formatGatewayChannelsStatusLines(payload).join("\n"));
|
||||
} catch (err) {
|
||||
runtime.error(`Gateway not reachable: ${String(err)}`);
|
||||
const cfg = await requireValidConfig(runtime);
|
||||
const cfg = await requireValidConfigSnapshot(runtime);
|
||||
if (!cfg) {
|
||||
return;
|
||||
}
|
||||
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "channels status",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
for (const entry of diagnostics) {
|
||||
runtime.log(`[secrets] ${entry}`);
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const mode = cfg.gateway?.mode === "remote" ? "remote" : "local";
|
||||
runtime.log(
|
||||
(
|
||||
await formatConfigChannelsStatusLines(cfg, {
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
})
|
||||
await formatConfigChannelsStatusLines(
|
||||
resolvedConfig,
|
||||
{
|
||||
path: snapshot.path,
|
||||
mode,
|
||||
},
|
||||
{ sourceConfig: cfg },
|
||||
)
|
||||
).join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,6 +251,54 @@ describe("doctor config flow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not crash when Telegram allowFrom repair sees unavailable SecretRef-backed credentials", async () => {
|
||||
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
try {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
secrets: {
|
||||
providers: {
|
||||
default: { source: "env" },
|
||||
},
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
|
||||
allowFrom: ["@testuser"],
|
||||
},
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
channels?: {
|
||||
telegram?: {
|
||||
allowFrom?: string[];
|
||||
accounts?: Record<string, { allowFrom?: string[] }>;
|
||||
};
|
||||
};
|
||||
};
|
||||
const retainedAllowFrom =
|
||||
cfg.channels?.telegram?.accounts?.default?.allowFrom ?? cfg.channels?.telegram?.allowFrom;
|
||||
expect(retainedAllowFrom).toEqual(["@testuser"]);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
expect(
|
||||
noteSpy.mock.calls.some((call) =>
|
||||
String(call[0]).includes(
|
||||
"configured Telegram bot credentials are unavailable in this command path",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
noteSpy.mockRestore();
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
it("converts numeric discord ids to strings on repair", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const configDir = path.join(home, ".openclaw");
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
} from "../channels/telegram/allow-from.js";
|
||||
import { fetchTelegramChatId } from "../channels/telegram/api.js";
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js";
|
||||
import { getChannelsCommandSecretTargetIds } from "../cli/command-secret-targets.js";
|
||||
import { listRouteBindings } from "../config/bindings.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { CONFIG_PATH, migrateLegacyConfig, readConfigFileSnapshot } from "../config/config.js";
|
||||
@@ -45,6 +47,7 @@ import {
|
||||
isMattermostMutableAllowEntry,
|
||||
isSlackMutableAllowEntry,
|
||||
} from "../security/mutable-allowlist-detectors.js";
|
||||
import { inspectTelegramAccount } from "../telegram/account-inspect.js";
|
||||
import { listTelegramAccountIds, resolveTelegramAccount } from "../telegram/accounts.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
import { isRecord, resolveHomeDir } from "../utils.js";
|
||||
@@ -464,10 +467,20 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
|
||||
const { resolvedConfig } = await resolveCommandSecretRefsViaGateway({
|
||||
config: cfg,
|
||||
commandName: "doctor --fix",
|
||||
targetIds: getChannelsCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => {
|
||||
const inspected = inspectTelegramAccount({ cfg, accountId });
|
||||
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
|
||||
});
|
||||
const tokens = Array.from(
|
||||
new Set(
|
||||
listTelegramAccountIds(cfg)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg, accountId }))
|
||||
listTelegramAccountIds(resolvedConfig)
|
||||
.map((accountId) => resolveTelegramAccount({ cfg: resolvedConfig, accountId }))
|
||||
.map((account) => (account.tokenSource === "none" ? "" : account.token))
|
||||
.map((token) => token.trim())
|
||||
.filter(Boolean),
|
||||
@@ -478,7 +491,9 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi
|
||||
return {
|
||||
config: cfg,
|
||||
changes: [
|
||||
`- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`,
|
||||
hasConfiguredUnavailableToken
|
||||
? `- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path; cannot auto-resolve (start the gateway or make the secret source available, then rerun doctor --fix).`
|
||||
: `- Telegram allowFrom contains @username entries, but no Telegram bot token is configured; cannot auto-resolve (run onboarding or replace with numeric sender IDs).`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export async function statusAllCommand(
|
||||
config: loadedRaw,
|
||||
commandName: "status --all",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const snap = await readConfigFileSnapshot().catch(() => null);
|
||||
@@ -159,7 +160,10 @@ export async function statusAllCommand(
|
||||
const agentStatus = await getAgentLocalStatuses(cfg);
|
||||
progress.tick();
|
||||
progress.setLabel("Summarizing channels…");
|
||||
const channels = await buildChannelsTable(cfg, { showSecrets: false });
|
||||
const channels = await buildChannelsTable(cfg, {
|
||||
showSecrets: false,
|
||||
sourceConfig: loadedRaw,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
const connectionDetailsForReport = (() => {
|
||||
|
||||
@@ -50,6 +50,12 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: params?.botToken ?? "bot-token",
|
||||
appToken: params?.appToken ?? "app-token",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
@@ -65,6 +71,196 @@ function makeSlackPlugin(params?: { botToken?: string; appToken?: string }): Cha
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnavailableSlackPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSourceAwareUnavailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "config",
|
||||
appTokenSource: "config",
|
||||
botTokenStatus: "configured_unavailable",
|
||||
appTokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: false,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
botTokenSource: "none",
|
||||
appTokenSource: "none",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
botToken: "",
|
||||
appToken: "",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeSourceUnavailableResolvedAvailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "discord",
|
||||
meta: {
|
||||
id: "discord",
|
||||
label: "Discord",
|
||||
selectionLabel: "Discord",
|
||||
docsPath: "/channels/discord",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: (cfg) =>
|
||||
(cfg as { marker?: string }).marker === "source"
|
||||
? {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "configured_unavailable",
|
||||
}
|
||||
: {
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
},
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
tokenSource: "config",
|
||||
tokenStatus: "available",
|
||||
}),
|
||||
isConfigured: (account) => Boolean((account as { configured?: boolean }).configured),
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeHttpSlackUnavailablePlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "slack",
|
||||
meta: {
|
||||
id: "slack",
|
||||
label: "Slack",
|
||||
selectionLabel: "Slack",
|
||||
docsPath: "/channels/slack",
|
||||
blurb: "test",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => ["primary"],
|
||||
defaultAccountId: () => "primary",
|
||||
inspectAccount: () => ({
|
||||
accountId: "primary",
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
}),
|
||||
resolveAccount: () => ({
|
||||
name: "Primary",
|
||||
enabled: true,
|
||||
configured: true,
|
||||
mode: "http",
|
||||
botToken: "xoxb-http",
|
||||
signingSecret: "",
|
||||
botTokenSource: "config",
|
||||
signingSecretSource: "config",
|
||||
botTokenStatus: "available",
|
||||
signingSecretStatus: "configured_unavailable",
|
||||
}),
|
||||
isConfigured: () => true,
|
||||
isEnabled: () => true,
|
||||
},
|
||||
actions: {
|
||||
listActions: () => ["send"],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeTokenPlugin(): ChannelPlugin {
|
||||
return {
|
||||
id: "token-only",
|
||||
@@ -122,6 +318,90 @@ describe("buildChannelsTable - mattermost token summary", () => {
|
||||
expect(slackRow?.detail).toContain("need bot+app");
|
||||
});
|
||||
|
||||
it("reports configured-but-unavailable Slack credentials as warn", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeUnavailableSlackPlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
});
|
||||
|
||||
it("preserves unavailable credential state from the source config snapshot", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceAwareUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("unavailable in this command path");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · app:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats status-only available credentials as resolved", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeSourceUnavailableResolvedAvailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ marker: "resolved", channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
sourceConfig: { marker: "source", channels: {} } as never,
|
||||
});
|
||||
|
||||
const discordRow = table.rows.find((row) => row.id === "discord");
|
||||
expect(discordRow).toBeDefined();
|
||||
expect(discordRow?.state).toBe("ok");
|
||||
expect(discordRow?.detail).toBe("configured");
|
||||
|
||||
const discordDetails = table.details.find((detail) => detail.title === "Discord accounts");
|
||||
expect(discordDetails).toBeDefined();
|
||||
expect(discordDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "token:config",
|
||||
Status: "OK",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats Slack HTTP signing-secret availability as required config", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeHttpSlackUnavailablePlugin()]);
|
||||
|
||||
const table = await buildChannelsTable({ channels: {} } as never, {
|
||||
showSecrets: false,
|
||||
});
|
||||
|
||||
const slackRow = table.rows.find((row) => row.id === "slack");
|
||||
expect(slackRow).toBeDefined();
|
||||
expect(slackRow?.state).toBe("warn");
|
||||
expect(slackRow?.detail).toContain("configured http credentials unavailable");
|
||||
|
||||
const slackDetails = table.details.find((detail) => detail.title === "Slack accounts");
|
||||
expect(slackDetails).toBeDefined();
|
||||
expect(slackDetails?.rows).toEqual([
|
||||
{
|
||||
Account: "primary (Primary)",
|
||||
Notes: "bot:config · signing:config · secret unavailable in this command path",
|
||||
Status: "WARN",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("still reports single-token channels as ok", async () => {
|
||||
vi.mocked(listChannelPlugins).mockReturnValue([makeTokenPlugin()]);
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import fs from "node:fs";
|
||||
import {
|
||||
hasConfiguredUnavailableCredentialStatus,
|
||||
hasResolvedCredentialValue,
|
||||
} from "../../channels/account-snapshot-fields.js";
|
||||
import {
|
||||
buildChannelAccountSnapshot,
|
||||
formatChannelAllowFrom,
|
||||
@@ -12,6 +16,7 @@ import type {
|
||||
ChannelId,
|
||||
ChannelPlugin,
|
||||
} from "../../channels/plugins/types.js";
|
||||
import { inspectReadOnlyChannelAccount } from "../../channels/read-only-account-inspect.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { sha256HexPrefix } from "../../logging/redact-identifier.js";
|
||||
import { formatTimeAgo } from "./format.js";
|
||||
@@ -32,6 +37,13 @@ type ChannelAccountRow = {
|
||||
snapshot: ChannelAccountSnapshot;
|
||||
};
|
||||
|
||||
type ResolvedChannelAccountRowParams = {
|
||||
plugin: ChannelPlugin;
|
||||
cfg: OpenClawConfig;
|
||||
sourceConfig: OpenClawConfig;
|
||||
accountId: string;
|
||||
};
|
||||
|
||||
const asRecord = (value: unknown): Record<string, unknown> =>
|
||||
value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
||||
|
||||
@@ -79,6 +91,61 @@ function formatTokenHint(token: string, opts: { showSecrets: boolean }): string
|
||||
return `${head}…${tail} · len ${t.length}`;
|
||||
}
|
||||
|
||||
function inspectChannelAccount(plugin: ChannelPlugin, cfg: OpenClawConfig, accountId: string) {
|
||||
return (
|
||||
plugin.config.inspectAccount?.(cfg, accountId) ??
|
||||
inspectReadOnlyChannelAccount({
|
||||
channelId: plugin.id,
|
||||
cfg,
|
||||
accountId,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveChannelAccountRow(
|
||||
params: ResolvedChannelAccountRowParams,
|
||||
): Promise<ChannelAccountRow> {
|
||||
const { plugin, cfg, sourceConfig, accountId } = params;
|
||||
const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId);
|
||||
const resolvedInspectedAccount = inspectChannelAccount(plugin, cfg, accountId);
|
||||
const resolvedInspection = resolvedInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const sourceInspection = sourceInspectedAccount as {
|
||||
enabled?: boolean;
|
||||
configured?: boolean;
|
||||
} | null;
|
||||
const resolvedAccount = resolvedInspectedAccount ?? plugin.config.resolveAccount(cfg, accountId);
|
||||
const useSourceUnavailableAccount = Boolean(
|
||||
sourceInspectedAccount &&
|
||||
hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) &&
|
||||
(!hasResolvedCredentialValue(resolvedAccount) ||
|
||||
(sourceInspection?.configured === true && resolvedInspection?.configured === false)),
|
||||
);
|
||||
const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount;
|
||||
const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection;
|
||||
const enabled =
|
||||
selectedInspection?.enabled ?? resolveChannelAccountEnabled({ plugin, account, cfg });
|
||||
const configured =
|
||||
selectedInspection?.configured ??
|
||||
(await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg,
|
||||
readAccountConfiguredField: true,
|
||||
}));
|
||||
const snapshot = buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
});
|
||||
return { accountId, account, enabled, configured, snapshot };
|
||||
}
|
||||
|
||||
const formatAccountLabel = (params: { accountId: string; name?: string }) => {
|
||||
const base = params.accountId || "default";
|
||||
if (params.name?.trim()) {
|
||||
@@ -110,6 +177,12 @@ const buildAccountNotes = (params: {
|
||||
if (snapshot.appTokenSource && snapshot.appTokenSource !== "none") {
|
||||
notes.push(`app:${snapshot.appTokenSource}`);
|
||||
}
|
||||
if (snapshot.signingSecretSource && snapshot.signingSecretSource !== "none") {
|
||||
notes.push(`signing:${snapshot.signingSecretSource}`);
|
||||
}
|
||||
if (hasConfiguredUnavailableCredentialStatus(entry.account)) {
|
||||
notes.push("secret unavailable in this command path");
|
||||
}
|
||||
if (snapshot.baseUrl) {
|
||||
notes.push(snapshot.baseUrl);
|
||||
}
|
||||
@@ -191,13 +264,90 @@ function summarizeTokenConfig(params: {
|
||||
const accountRecs = enabled.map((a) => asRecord(a.account));
|
||||
const hasBotTokenField = accountRecs.some((r) => "botToken" in r);
|
||||
const hasAppTokenField = accountRecs.some((r) => "appToken" in r);
|
||||
const hasSigningSecretField = accountRecs.some(
|
||||
(r) => "signingSecret" in r || "signingSecretSource" in r || "signingSecretStatus" in r,
|
||||
);
|
||||
const hasTokenField = accountRecs.some((r) => "token" in r);
|
||||
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasTokenField) {
|
||||
if (!hasBotTokenField && !hasAppTokenField && !hasSigningSecretField && !hasTokenField) {
|
||||
return { state: null, detail: null };
|
||||
}
|
||||
|
||||
const accountIsHttpMode = (rec: Record<string, unknown>) =>
|
||||
typeof rec.mode === "string" && rec.mode.trim() === "http";
|
||||
const hasCredentialAvailable = (
|
||||
rec: Record<string, unknown>,
|
||||
valueKey: string,
|
||||
statusKey: string,
|
||||
) => {
|
||||
const value = rec[valueKey];
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
return true;
|
||||
}
|
||||
return rec[statusKey] === "available";
|
||||
};
|
||||
|
||||
if (
|
||||
hasBotTokenField &&
|
||||
hasSigningSecretField &&
|
||||
enabled.every((a) => accountIsHttpMode(asRecord(a.account)))
|
||||
) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return (
|
||||
hasCredentialAvailable(rec, "botToken", "botTokenStatus") &&
|
||||
hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus")
|
||||
);
|
||||
});
|
||||
const partial = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const hasBot = hasCredentialAvailable(rec, "botToken", "botTokenStatus");
|
||||
const hasSigning = hasCredentialAvailable(rec, "signingSecret", "signingSecretStatus");
|
||||
return (hasBot && !hasSigning) || (!hasBot && hasSigning);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured http credentials unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (partial.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `partial credentials (need bot+signing) · accounts ${partial.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no credentials (need bot+signing)" };
|
||||
}
|
||||
|
||||
const botSources = summarizeSources(ready.map((a) => a.snapshot.botTokenSource ?? "none"));
|
||||
const signingSources = summarizeSources(
|
||||
ready.map((a) => a.snapshot.signingSecretSource ?? "none"),
|
||||
);
|
||||
const sample = ready[0]?.account ? asRecord(ready[0].account) : {};
|
||||
const botToken = typeof sample.botToken === "string" ? sample.botToken : "";
|
||||
const signingSecret = typeof sample.signingSecret === "string" ? sample.signingSecret : "";
|
||||
const botHint = botToken.trim()
|
||||
? formatTokenHint(botToken, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const signingHint = signingSecret.trim()
|
||||
? formatTokenHint(signingSecret, { showSecrets: params.showSecrets })
|
||||
: "";
|
||||
const hint =
|
||||
botHint || signingHint ? ` (bot ${botHint || "?"}, signing ${signingHint || "?"})` : "";
|
||||
return {
|
||||
state: "ok",
|
||||
detail: `credentials ok (bot ${botSources.label}, signing ${signingSources.label})${hint} · accounts ${ready.length}/${enabled.length || 1}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasBotTokenField && hasAppTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||||
@@ -220,6 +370,13 @@ function summarizeTokenConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured tokens unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no tokens (need bot+app)" };
|
||||
}
|
||||
@@ -245,12 +402,20 @@ function summarizeTokenConfig(params: {
|
||||
}
|
||||
|
||||
if (hasBotTokenField) {
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
const bot = typeof rec.botToken === "string" ? rec.botToken.trim() : "";
|
||||
return Boolean(bot);
|
||||
});
|
||||
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured bot token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no bot token" };
|
||||
}
|
||||
@@ -268,10 +433,17 @@ function summarizeTokenConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const unavailable = enabled.filter((a) => hasConfiguredUnavailableCredentialStatus(a.account));
|
||||
const ready = enabled.filter((a) => {
|
||||
const rec = asRecord(a.account);
|
||||
return typeof rec.token === "string" ? Boolean(rec.token.trim()) : false;
|
||||
});
|
||||
if (unavailable.length > 0) {
|
||||
return {
|
||||
state: "warn",
|
||||
detail: `configured token unavailable in this command path · accounts ${unavailable.length}`,
|
||||
};
|
||||
}
|
||||
if (ready.length === 0) {
|
||||
return { state: "setup", detail: "no token" };
|
||||
}
|
||||
@@ -292,7 +464,7 @@ function summarizeTokenConfig(params: {
|
||||
// Keep this generic: channel-specific rules belong in the channel plugin.
|
||||
export async function buildChannelsTable(
|
||||
cfg: OpenClawConfig,
|
||||
opts?: { showSecrets?: boolean },
|
||||
opts?: { showSecrets?: boolean; sourceConfig?: OpenClawConfig },
|
||||
): Promise<{
|
||||
rows: ChannelRow[];
|
||||
details: Array<{
|
||||
@@ -319,29 +491,24 @@ export async function buildChannelsTable(
|
||||
const resolvedAccountIds = accountIds.length > 0 ? accountIds : [defaultAccountId];
|
||||
|
||||
const accounts: ChannelAccountRow[] = [];
|
||||
const sourceConfig = opts?.sourceConfig ?? cfg;
|
||||
for (const accountId of resolvedAccountIds) {
|
||||
const account = plugin.config.resolveAccount(cfg, accountId);
|
||||
const enabled = resolveChannelAccountEnabled({ plugin, account, cfg });
|
||||
const configured = await resolveChannelAccountConfigured({
|
||||
plugin,
|
||||
account,
|
||||
cfg,
|
||||
readAccountConfiguredField: true,
|
||||
});
|
||||
const snapshot = buildChannelAccountSnapshot({
|
||||
plugin,
|
||||
cfg,
|
||||
accountId,
|
||||
account,
|
||||
enabled,
|
||||
configured,
|
||||
});
|
||||
accounts.push({ accountId, account, enabled, configured, snapshot });
|
||||
accounts.push(
|
||||
await resolveChannelAccountRow({
|
||||
plugin,
|
||||
cfg,
|
||||
sourceConfig,
|
||||
accountId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const anyEnabled = accounts.some((a) => a.enabled);
|
||||
const enabledAccounts = accounts.filter((a) => a.enabled);
|
||||
const configuredAccounts = enabledAccounts.filter((a) => a.configured);
|
||||
const unavailableConfiguredAccounts = enabledAccounts.filter((a) =>
|
||||
hasConfiguredUnavailableCredentialStatus(a.account),
|
||||
);
|
||||
const defaultEntry = accounts.find((a) => a.accountId === defaultAccountId) ?? accounts[0];
|
||||
|
||||
const summary = plugin.status?.buildChannelSummary
|
||||
@@ -379,6 +546,9 @@ export async function buildChannelsTable(
|
||||
if (issues.length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
if (unavailableConfiguredAccounts.length > 0) {
|
||||
return "warn";
|
||||
}
|
||||
if (link.linked === false) {
|
||||
return "setup";
|
||||
}
|
||||
@@ -423,6 +593,13 @@ export async function buildChannelsTable(
|
||||
return extra.length > 0 ? `${base} · ${extra.join(" · ")}` : base;
|
||||
}
|
||||
|
||||
if (unavailableConfiguredAccounts.length > 0) {
|
||||
if (tokenSummary.detail?.includes("unavailable")) {
|
||||
return tokenSummary.detail;
|
||||
}
|
||||
return `configured credentials unavailable in this command path · accounts ${unavailableConfiguredAccounts.length}`;
|
||||
}
|
||||
|
||||
if (tokenSummary.detail) {
|
||||
return tokenSummary.detail;
|
||||
}
|
||||
@@ -461,7 +638,10 @@ export async function buildChannelsTable(
|
||||
accountId: entry.accountId,
|
||||
name: entry.snapshot.name,
|
||||
}),
|
||||
Status: entry.enabled ? "OK" : "WARN",
|
||||
Status:
|
||||
entry.enabled && !hasConfiguredUnavailableCredentialStatus(entry.account)
|
||||
? "OK"
|
||||
: "WARN",
|
||||
Notes: notes.join(" · "),
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatCliCommand } from "../cli/command-format.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||
import { resolveGatewayPort } from "../config/config.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import { info } from "../globals.js";
|
||||
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
|
||||
@@ -80,33 +80,33 @@ export async function statusCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const [scan, securityAudit] = opts.json
|
||||
? await Promise.all([
|
||||
scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime),
|
||||
runSecurityAudit({
|
||||
config: loadConfig(),
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
])
|
||||
: [
|
||||
await scanStatus({ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all }, runtime),
|
||||
await withProgress(
|
||||
{
|
||||
label: "Running security audit…",
|
||||
indeterminate: true,
|
||||
enabled: true,
|
||||
},
|
||||
async () =>
|
||||
await runSecurityAudit({
|
||||
config: loadConfig(),
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
),
|
||||
];
|
||||
const scan = await scanStatus(
|
||||
{ json: opts.json, timeoutMs: opts.timeoutMs, all: opts.all },
|
||||
runtime,
|
||||
);
|
||||
const securityAudit = opts.json
|
||||
? await runSecurityAudit({
|
||||
config: scan.cfg,
|
||||
sourceConfig: scan.sourceConfig,
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
})
|
||||
: await withProgress(
|
||||
{
|
||||
label: "Running security audit…",
|
||||
indeterminate: true,
|
||||
enabled: true,
|
||||
},
|
||||
async () =>
|
||||
await runSecurityAudit({
|
||||
config: scan.cfg,
|
||||
sourceConfig: scan.sourceConfig,
|
||||
deep: false,
|
||||
includeFilesystem: true,
|
||||
includeChannelSecurity: true,
|
||||
}),
|
||||
);
|
||||
const {
|
||||
cfg,
|
||||
osSummary,
|
||||
@@ -126,6 +126,7 @@ export async function statusCommand(
|
||||
agentStatus,
|
||||
channels,
|
||||
summary,
|
||||
secretDiagnostics,
|
||||
memory,
|
||||
memoryPlugin,
|
||||
} = scan;
|
||||
@@ -202,6 +203,7 @@ export async function statusCommand(
|
||||
nodeService: nodeDaemon,
|
||||
agents: agentStatus,
|
||||
securityAudit,
|
||||
secretDiagnostics,
|
||||
...(health || usage || lastHeartbeat ? { health, usage, lastHeartbeat } : {}),
|
||||
},
|
||||
null,
|
||||
@@ -227,6 +229,14 @@ export async function statusCommand(
|
||||
|
||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||
|
||||
if (secretDiagnostics.length > 0) {
|
||||
runtime.log(theme.warn("Secret diagnostics:"));
|
||||
for (const entry of secretDiagnostics) {
|
||||
runtime.log(`- ${entry}`);
|
||||
}
|
||||
runtime.log("");
|
||||
}
|
||||
|
||||
const dashboard = (() => {
|
||||
const controlUiEnabled = cfg.gateway?.controlUi?.enabled ?? true;
|
||||
if (!controlUiEnabled) {
|
||||
|
||||
138
src/commands/status.scan.test.ts
Normal file
138
src/commands/status.scan.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(),
|
||||
resolveCommandSecretRefsViaGateway: vi.fn(),
|
||||
buildChannelsTable: vi.fn(),
|
||||
getUpdateCheckResult: vi.fn(),
|
||||
getAgentLocalStatuses: vi.fn(),
|
||||
getStatusSummary: vi.fn(),
|
||||
buildGatewayConnectionDetails: vi.fn(),
|
||||
probeGateway: vi.fn(),
|
||||
resolveGatewayProbeAuthResolution: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/progress.js", () => ({
|
||||
withProgress: vi.fn(async (_opts, run) => await run({ setLabel: vi.fn(), tick: vi.fn() })),
|
||||
}));
|
||||
|
||||
vi.mock("../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/command-secret-gateway.js", () => ({
|
||||
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
|
||||
}));
|
||||
|
||||
vi.mock("./status-all/channels.js", () => ({
|
||||
buildChannelsTable: mocks.buildChannelsTable,
|
||||
}));
|
||||
|
||||
vi.mock("./status.update.js", () => ({
|
||||
getUpdateCheckResult: mocks.getUpdateCheckResult,
|
||||
}));
|
||||
|
||||
vi.mock("./status.agent-local.js", () => ({
|
||||
getAgentLocalStatuses: mocks.getAgentLocalStatuses,
|
||||
}));
|
||||
|
||||
vi.mock("./status.summary.js", () => ({
|
||||
getStatusSummary: mocks.getStatusSummary,
|
||||
}));
|
||||
|
||||
vi.mock("../infra/os-summary.js", () => ({
|
||||
resolveOsSummary: vi.fn(() => ({ label: "test-os" })),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/tailscale.js", () => ({
|
||||
getTailnetHostname: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
|
||||
callGateway: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/probe.js", () => ({
|
||||
probeGateway: mocks.probeGateway,
|
||||
}));
|
||||
|
||||
vi.mock("./status.gateway-probe.js", () => ({
|
||||
pickGatewaySelfPresence: vi.fn(() => null),
|
||||
resolveGatewayProbeAuthResolution: mocks.resolveGatewayProbeAuthResolution,
|
||||
}));
|
||||
|
||||
vi.mock("../memory/index.js", () => ({
|
||||
getMemorySearchManager: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../process/exec.js", () => ({
|
||||
runExec: vi.fn(),
|
||||
}));
|
||||
|
||||
import { scanStatus } from "./status.scan.js";
|
||||
|
||||
describe("scanStatus", () => {
|
||||
it("passes sourceConfig into buildChannelsTable for summary-mode status output", async () => {
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
marker: "source",
|
||||
session: {},
|
||||
plugins: { enabled: false },
|
||||
gateway: {},
|
||||
});
|
||||
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
|
||||
resolvedConfig: {
|
||||
marker: "resolved",
|
||||
session: {},
|
||||
plugins: { enabled: false },
|
||||
gateway: {},
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
mocks.getUpdateCheckResult.mockResolvedValue({
|
||||
installKind: "git",
|
||||
git: null,
|
||||
registry: null,
|
||||
});
|
||||
mocks.getAgentLocalStatuses.mockResolvedValue({
|
||||
defaultId: "main",
|
||||
agents: [],
|
||||
});
|
||||
mocks.getStatusSummary.mockResolvedValue({
|
||||
linkChannel: { linked: false },
|
||||
sessions: { count: 0, paths: [], defaults: {}, recent: [] },
|
||||
});
|
||||
mocks.buildGatewayConnectionDetails.mockReturnValue({
|
||||
url: "ws://127.0.0.1:18789",
|
||||
urlSource: "default",
|
||||
});
|
||||
mocks.resolveGatewayProbeAuthResolution.mockReturnValue({
|
||||
auth: {},
|
||||
warning: undefined,
|
||||
});
|
||||
mocks.probeGateway.mockResolvedValue({
|
||||
ok: false,
|
||||
url: "ws://127.0.0.1:18789",
|
||||
connectLatencyMs: null,
|
||||
error: "timeout",
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
});
|
||||
mocks.buildChannelsTable.mockResolvedValue({
|
||||
rows: [],
|
||||
details: [],
|
||||
});
|
||||
|
||||
await scanStatus({ json: false }, {} as never);
|
||||
|
||||
expect(mocks.buildChannelsTable).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ marker: "resolved" }),
|
||||
expect.objectContaining({
|
||||
sourceConfig: expect.objectContaining({ marker: "source" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,8 @@ async function resolveChannelsStatus(params: {
|
||||
|
||||
export type StatusScanResult = {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
sourceConfig: ReturnType<typeof loadConfig>;
|
||||
secretDiagnostics: string[];
|
||||
osSummary: ReturnType<typeof resolveOsSummary>;
|
||||
tailscaleMode: string;
|
||||
tailscaleDns: string | null;
|
||||
@@ -179,11 +181,13 @@ async function scanStatusJsonFast(opts: {
|
||||
all?: boolean;
|
||||
}): Promise<StatusScanResult> {
|
||||
const loadedRaw = loadConfig();
|
||||
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --json",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status --json",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const updateTimeoutMs = opts.all ? 6500 : 2500;
|
||||
@@ -193,7 +197,7 @@ async function scanStatusJsonFast(opts: {
|
||||
includeRegistry: true,
|
||||
});
|
||||
const agentStatusPromise = getAgentLocalStatuses();
|
||||
const summaryPromise = getStatusSummary({ config: cfg });
|
||||
const summaryPromise = getStatusSummary({ config: cfg, sourceConfig: loadedRaw });
|
||||
|
||||
const tailscaleDnsPromise =
|
||||
tailscaleMode === "off"
|
||||
@@ -236,6 +240,8 @@ async function scanStatusJsonFast(opts: {
|
||||
|
||||
return {
|
||||
cfg,
|
||||
sourceConfig: loadedRaw,
|
||||
secretDiagnostics,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
@@ -278,11 +284,13 @@ export async function scanStatus(
|
||||
async (progress) => {
|
||||
progress.setLabel("Loading config…");
|
||||
const loadedRaw = loadConfig();
|
||||
const { resolvedConfig: cfg } = await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
});
|
||||
const { resolvedConfig: cfg, diagnostics: secretDiagnostics } =
|
||||
await resolveCommandSecretRefsViaGateway({
|
||||
config: loadedRaw,
|
||||
commandName: "status",
|
||||
targetIds: getStatusCommandSecretTargetIds(),
|
||||
mode: "summary",
|
||||
});
|
||||
const osSummary = resolveOsSummary();
|
||||
const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off";
|
||||
const tailscaleDnsPromise =
|
||||
@@ -300,7 +308,9 @@ export async function scanStatus(
|
||||
}),
|
||||
);
|
||||
const agentStatusPromise = deferResult(getAgentLocalStatuses());
|
||||
const summaryPromise = deferResult(getStatusSummary({ config: cfg }));
|
||||
const summaryPromise = deferResult(
|
||||
getStatusSummary({ config: cfg, sourceConfig: loadedRaw }),
|
||||
);
|
||||
progress.tick();
|
||||
|
||||
progress.setLabel("Checking Tailscale…");
|
||||
@@ -344,6 +354,7 @@ export async function scanStatus(
|
||||
// Show token previews in regular status; keep `status --all` redacted.
|
||||
// Set `CLAWDBOT_SHOW_SECRETS=0` to force redaction.
|
||||
showSecrets: process.env.CLAWDBOT_SHOW_SECRETS?.trim() !== "0",
|
||||
sourceConfig: loadedRaw,
|
||||
});
|
||||
progress.tick();
|
||||
|
||||
@@ -361,6 +372,8 @@ export async function scanStatus(
|
||||
|
||||
return {
|
||||
cfg,
|
||||
sourceConfig: loadedRaw,
|
||||
secretDiagnostics,
|
||||
osSummary,
|
||||
tailscaleMode,
|
||||
tailscaleDns,
|
||||
|
||||
@@ -77,7 +77,11 @@ export function redactSensitiveStatusSummary(summary: StatusSummary): StatusSumm
|
||||
}
|
||||
|
||||
export async function getStatusSummary(
|
||||
options: { includeSensitive?: boolean; config?: OpenClawConfig } = {},
|
||||
options: {
|
||||
includeSensitive?: boolean;
|
||||
config?: OpenClawConfig;
|
||||
sourceConfig?: OpenClawConfig;
|
||||
} = {},
|
||||
): Promise<StatusSummary> {
|
||||
const { includeSensitive = true } = options;
|
||||
const cfg = options.config ?? loadConfig();
|
||||
@@ -95,6 +99,7 @@ export async function getStatusSummary(
|
||||
const channelSummary = await buildChannelSummary(cfg, {
|
||||
colorize: true,
|
||||
includeAllowFrom: true,
|
||||
sourceConfig: options.sourceConfig,
|
||||
});
|
||||
const mainSessionKey = resolveMainSessionKey(cfg);
|
||||
const queuedSystemEvents = peekSystemEvents(mainSessionKey);
|
||||
|
||||
Reference in New Issue
Block a user