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:
Josh Avant
2026-03-05 23:07:13 -06:00
committed by GitHub
parent 8d4a2f2c59
commit 0e4245063f
58 changed files with 3422 additions and 215 deletions

View 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)");
});
});

View File

@@ -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}`);

View File

@@ -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}`);

View File

@@ -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"),
);
}

View File

@@ -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");

View File

@@ -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).`,
],
};
}

View File

@@ -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 = (() => {

View File

@@ -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()]);

View File

@@ -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(" · "),
};
}),

View File

@@ -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) {

View 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" }),
}),
);
});
});

View File

@@ -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,

View File

@@ -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);