diff --git a/CHANGELOG.md b/CHANGELOG.md index f46e450d164..232cbb167a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Plugins/bundles: add compatible Codex, Claude, and Cursor bundle discovery/install support, map bundle skills into OpenClaw skills, and apply Claude bundle `settings.json` defaults to embedded Pi with shell overrides sanitized. - Plugins/agent integrations: broaden the plugin surface for app-server integrations with channel-aware commands, interactive callbacks, inbound claims, and Discord/Telegram conversation binding support. (#45318) Thanks @huntharo and @vincentkoc. - Telegram/actions: add `topic-edit` for forum-topic renames and icon updates while sharing the same Telegram topic-edit transport used by the plugin runtime. (#47798) Thanks @obviyus. +- secrets: harden read-only SecretRef command paths and diagnostics. (#47794) Thanks @joshavant. ### Fixes diff --git a/docs/cli/daemon.md b/docs/cli/daemon.md index 8f6042e7400..f21c3930ece 100644 --- a/docs/cli/daemon.md +++ b/docs/cli/daemon.md @@ -34,13 +34,15 @@ openclaw daemon uninstall ## Common options -- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--deep`, `--json` +- `status`: `--url`, `--token`, `--password`, `--timeout`, `--no-probe`, `--require-rpc`, `--deep`, `--json` - `install`: `--port`, `--runtime `, `--token`, `--force`, `--json` - lifecycle (`uninstall|start|stop|restart`): `--json` Notes: - `status` resolves configured auth SecretRefs for probe auth when possible. +- If a required auth SecretRef is unresolved in this command path, `daemon status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - On Linux systemd installs, `status` token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - When token auth requires a token and `gateway.auth.token` is SecretRef-managed, `install` validates that the SecretRef is resolvable but does not persist the resolved token into service environment metadata. - If token auth requires a token and the configured token SecretRef is unresolved, install fails closed. diff --git a/docs/cli/doctor.md b/docs/cli/doctor.md index 90e5fa7d7a2..4718135ee68 100644 --- a/docs/cli/doctor.md +++ b/docs/cli/doctor.md @@ -31,6 +31,7 @@ Notes: - Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime. - Doctor includes a memory-search readiness check and can recommend `openclaw configure --section model` when embedding credentials are missing. - If sandbox mode is enabled but Docker is unavailable, doctor reports a high-signal warning with remediation (`install Docker` or `openclaw config set agents.defaults.sandbox.mode off`). +- If `gateway.auth.token`/`gateway.auth.password` are SecretRef-managed and unavailable in the current command path, doctor reports a read-only warning and does not write plaintext fallback credentials. ## macOS: `launchctl` env overrides diff --git a/docs/cli/gateway.md b/docs/cli/gateway.md index 16b05baefce..d36fbde6c35 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -111,7 +111,8 @@ Options: Notes: - `gateway status` resolves configured auth SecretRefs for probe auth when possible. -- If a required auth SecretRef is unresolved in this command path, probe auth can fail; pass `--token`/`--password` explicitly or resolve the secret source first. +- If a required auth SecretRef is unresolved in this command path, `gateway status --json` reports `rpc.authWarning` when probe connectivity/auth fails; pass `--token`/`--password` explicitly or resolve the secret source first. +- If the probe succeeds, unresolved auth-ref warnings are suppressed to avoid false positives. - Use `--require-rpc` in scripts and automation when a listening service is not enough and you need the Gateway RPC itself to be healthy. - On Linux systemd installs, service auth drift checks read both `Environment=` and `EnvironmentFile=` values from the unit (including `%h`, quoted paths, multiple files, and optional `-` files). diff --git a/docs/cli/index.md b/docs/cli/index.md index fbc0bf1378f..f99b04efece 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -783,6 +783,7 @@ Notes: - `gateway status` supports `--no-probe`, `--deep`, `--require-rpc`, and `--json` for scripting. - `gateway status` also surfaces legacy or extra gateway services when it can detect them (`--deep` adds system-level scans). Profile-named OpenClaw services are treated as first-class and aren't flagged as "extra". - `gateway status` prints which config path the CLI uses vs which config the service likely uses (service env), plus the resolved probe target URL. +- If gateway auth SecretRefs are unresolved in the current command path, `gateway status --json` reports `rpc.authWarning` only when probe connectivity/auth fails (warnings are suppressed when probe succeeds). - On Linux systemd installs, status token-drift checks include both `Environment=` and `EnvironmentFile=` unit sources. - `gateway install|uninstall|start|stop|restart` support `--json` for scripting (default output stays human-friendly). - `gateway install` defaults to Node runtime; bun is **not recommended** (WhatsApp/Telegram bugs). diff --git a/docs/cli/security.md b/docs/cli/security.md index cc705b31a30..76a7ae75976 100644 --- a/docs/cli/security.md +++ b/docs/cli/security.md @@ -19,6 +19,8 @@ Related: ```bash openclaw security audit openclaw security audit --deep +openclaw security audit --deep --password +openclaw security audit --deep --token openclaw security audit --fix openclaw security audit --json ``` @@ -40,6 +42,12 @@ It warns when `gateway.auth.mode="none"` leaves Gateway HTTP APIs reachable with Settings prefixed with `dangerous`/`dangerously` are explicit break-glass operator overrides; enabling one is not, by itself, a security vulnerability report. For the complete dangerous-parameter inventory, see the "Insecure or dangerous flags summary" section in [Security](/gateway/security). +SecretRef behavior: + +- `security audit` resolves supported SecretRefs in read-only mode for its targeted paths. +- If a SecretRef is unavailable in the current command path, audit continues and reports `secretDiagnostics` (instead of crashing). +- `--token` and `--password` only override deep-probe auth for that command invocation; they do not rewrite config or SecretRef mappings. + ## JSON output Use `--json` for CI/policy checks: diff --git a/docs/cli/status.md b/docs/cli/status.md index 856c341b036..770bf6ab50d 100644 --- a/docs/cli/status.md +++ b/docs/cli/status.md @@ -26,3 +26,4 @@ Notes: - Update info surfaces in the Overview; if an update is available, status prints a hint to run `openclaw update` (see [Updating](/install/updating)). - Read-only status surfaces (`status`, `status --json`, `status --all`) resolve supported SecretRefs for their targeted config paths when possible. - If a supported channel SecretRef is configured but unavailable in the current command path, status stays read-only and reports degraded output instead of crashing. Human output shows warnings such as “configured token unavailable in this command path”, and JSON output includes `secretDiagnostics`. +- When command-local SecretRef resolution succeeds, status prefers the resolved snapshot and clears transient “secret unavailable” channel markers from the final output. diff --git a/docs/gateway/secrets.md b/docs/gateway/secrets.md index 93cd508d4f1..379e4a527d4 100644 --- a/docs/gateway/secrets.md +++ b/docs/gateway/secrets.md @@ -348,7 +348,7 @@ Command paths can opt into supported SecretRef resolution via gateway snapshot R There are two broad behaviors: - Strict command paths (for example `openclaw memory` remote-memory paths and `openclaw qr --remote`) read from the active snapshot and fail fast when a required SecretRef is unavailable. -- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. +- Read-only command paths (for example `openclaw status`, `openclaw status --all`, `openclaw channels status`, `openclaw channels resolve`, `openclaw security audit`, and read-only doctor/config repair flows) also prefer the active snapshot, but degrade instead of aborting when a targeted SecretRef is unavailable in that command path. Read-only behavior: diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 63963ab5f38..b4ec54d62dd 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -12,6 +12,8 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../../channels/plugins/types.js"; +import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import type { OpenClawConfig } from "../../config/config.js"; import { loadConfig } from "../../config/config.js"; import { GATEWAY_CLIENT_IDS, GATEWAY_CLIENT_MODES } from "../../gateway/protocol/client-info.js"; @@ -709,7 +711,16 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { } } - const cfg = options?.config ?? loadConfig(); + const cfg = options?.config + ? options.config + : ( + await resolveCommandSecretRefsViaGateway({ + config: loadConfig(), + commandName: "tools.message", + targetIds: getChannelsCommandSecretTargetIds(), + mode: "enforce_resolved", + }) + ).resolvedConfig; const action = readStringParam(params, "action", { required: true, }) as ChannelMessageActionName; diff --git a/src/cli/command-secret-gateway.test.ts b/src/cli/command-secret-gateway.test.ts index 74c47f637e9..c9de91d4257 100644 --- a/src/cli/command-secret-gateway.test.ts +++ b/src/cli/command-secret-gateway.test.ts @@ -43,7 +43,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { async function resolveTalkApiKey(params: { envKey: string; commandName?: string; - mode?: "strict" | "summary"; + mode?: "enforce_resolved" | "read_only_status"; }) { return resolveCommandSecretRefsViaGateway({ config: makeTalkApiKeySecretRefConfig(params.envKey), @@ -447,7 +447,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { expect(result.diagnostics).toEqual(["memory search ref inactive"]); }); - it("degrades unresolved refs in summary mode instead of throwing", async () => { + it("degrades unresolved refs in read-only status mode instead of throwing", async () => { const envKey = "TALK_API_KEY_SUMMARY_MISSING"; callGateway.mockResolvedValueOnce({ assignments: [], @@ -457,7 +457,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); expect(result.hadUnresolvedTargets).toBe(true); @@ -470,6 +470,25 @@ describe("resolveCommandSecretRefsViaGateway", () => { }); }); + it("accepts legacy summary mode as a read-only alias", async () => { + const envKey = "TALK_API_KEY_LEGACY_SUMMARY_MISSING"; + callGateway.mockResolvedValueOnce({ + assignments: [], + diagnostics: [], + }); + await withEnvValue(envKey, undefined, async () => { + const result = await resolveCommandSecretRefsViaGateway({ + config: makeTalkApiKeySecretRefConfig(envKey), + commandName: "status", + targetIds: new Set(["talk.apiKey"]), + mode: "summary", + }); + expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); + expect(result.hadUnresolvedTargets).toBe(true); + expect(result.targetStatesByPath["talk.apiKey"]).toBe("unresolved"); + }); + }); + it("uses targeted local fallback after an incomplete gateway snapshot", async () => { const envKey = "TALK_API_KEY_PARTIAL_GATEWAY"; callGateway.mockResolvedValueOnce({ @@ -480,7 +499,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { const result = await resolveTalkApiKey({ envKey, commandName: "status", - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("recovered-locally"); expect(result.hadUnresolvedTargets).toBe(false); @@ -571,7 +590,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "status", targetIds: new Set(["talk.apiKey"]), - mode: "summary", + mode: "read_only_status", }); expect(result.resolvedConfig.talk?.apiKey).toBe("target-only"); @@ -591,7 +610,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } }); - it("degrades unresolved refs in operational read-only mode", async () => { + it("degrades unresolved refs in read-only operational mode", async () => { const envKey = "TALK_API_KEY_OPERATIONAL_MISSING"; const priorValue = process.env[envKey]; delete process.env[envKey]; @@ -606,7 +625,7 @@ describe("resolveCommandSecretRefsViaGateway", () => { } as OpenClawConfig, commandName: "channels resolve", targetIds: new Set(["talk.apiKey"]), - mode: "operational_readonly", + mode: "read_only_operational", }); expect(result.resolvedConfig.talk?.apiKey).toBeUndefined(); diff --git a/src/cli/command-secret-gateway.ts b/src/cli/command-secret-gateway.ts index 03e578b642c..8b2b73c9f0f 100644 --- a/src/cli/command-secret-gateway.ts +++ b/src/cli/command-secret-gateway.ts @@ -26,7 +26,16 @@ type ResolveCommandSecretsResult = { hadUnresolvedTargets: boolean; }; -export type CommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret +export type CommandSecretResolutionMode = + | "enforce_resolved" + | "read_only_status" + | "read_only_operational"; + +type LegacyCommandSecretResolutionMode = "strict" | "summary" | "operational_readonly"; // pragma: allowlist secret + +type CommandSecretResolutionModeInput = + | CommandSecretResolutionMode + | LegacyCommandSecretResolutionMode; export type CommandSecretTargetState = | "resolved_gateway" @@ -54,6 +63,22 @@ const WEB_RUNTIME_SECRET_PATH_PREFIXES = [ "tools.web.fetch.firecrawl.", ] as const; +function normalizeCommandSecretResolutionMode( + mode?: CommandSecretResolutionModeInput, +): CommandSecretResolutionMode { + if (!mode || mode === "enforce_resolved" || mode === "strict") { + return "enforce_resolved"; + } + if (mode === "read_only_status" || mode === "summary") { + return "read_only_status"; + } + return "read_only_operational"; +} + +function enforcesResolvedSecrets(mode: CommandSecretResolutionMode): boolean { + return mode === "enforce_resolved"; +} + function dedupeDiagnostics(entries: readonly string[]): string[] { const seen = new Set(); const ordered: string[] = []; @@ -242,7 +267,7 @@ async function resolveCommandSecretRefsLocally(params: { context, }); } catch (error) { - if (params.mode === "strict") { + if (enforcesResolvedSecrets(params.mode)) { throw error; } localResolutionDiagnostics.push( @@ -289,7 +314,7 @@ async function resolveCommandSecretRefsLocally(params: { analyzed, resolvedState: "resolved_local", }); - if (params.mode !== "strict" && analyzed.unresolved.length > 0) { + if (!enforcesResolvedSecrets(params.mode) && analyzed.unresolved.length > 0) { scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); } else if (analyzed.unresolved.length > 0) { throw new Error( @@ -336,7 +361,7 @@ function buildUnresolvedDiagnostics( unresolved: UnresolvedCommandSecretAssignment[], mode: CommandSecretResolutionMode, ): string[] { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { return []; } return unresolved.map( @@ -411,7 +436,7 @@ async function resolveTargetSecretLocally(params: { }); setPathExistingStrict(params.resolvedConfig, params.target.pathSegments, resolved); } catch (error) { - if (params.mode !== "strict") { + if (!enforcesResolvedSecrets(params.mode)) { params.localResolutionDiagnostics.push( `${params.commandName}: failed to resolve ${params.target.path} locally (${describeUnknownError(error)}).`, ); @@ -423,9 +448,9 @@ export async function resolveCommandSecretRefsViaGateway(params: { config: OpenClawConfig; commandName: string; targetIds: Set; - mode?: CommandSecretResolutionMode; + mode?: CommandSecretResolutionModeInput; }): Promise { - const mode = params.mode ?? "strict"; + const mode = normalizeCommandSecretResolutionMode(params.mode); const configuredTargetRefPaths = collectConfiguredTargetRefPaths({ config: params.config, targetIds: params.targetIds, @@ -567,7 +592,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { (entry) => !recoveredPaths.has(entry.path), ); if (stillUnresolved.length > 0) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw new Error( `${params.commandName}: ${stillUnresolved[0]?.path ?? "target"} is unresolved in the active runtime snapshot.`, ); @@ -590,7 +615,7 @@ export async function resolveCommandSecretRefsViaGateway(params: { ]); } } catch (error) { - if (mode === "strict") { + if (enforcesResolvedSecrets(mode)) { throw error; } scrubUnresolvedAssignments(resolvedConfig, analyzed.unresolved); diff --git a/src/cli/command-secret-targets.test.ts b/src/cli/command-secret-targets.test.ts index a71ac5e00c4..22a23b36055 100644 --- a/src/cli/command-secret-targets.test.ts +++ b/src/cli/command-secret-targets.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { getAgentRuntimeCommandSecretTargetIds, getMemoryCommandSecretTargetIds, + getSecurityAuditCommandSecretTargetIds, } from "./command-secret-targets.js"; describe("command secret target ids", () => { @@ -21,4 +22,13 @@ describe("command secret target ids", () => { ]), ); }); + + it("includes gateway auth and channel targets for security audit", () => { + const ids = getSecurityAuditCommandSecretTargetIds(); + expect(ids.has("channels.discord.token")).toBe(true); + expect(ids.has("gateway.auth.token")).toBe(true); + expect(ids.has("gateway.auth.password")).toBe(true); + expect(ids.has("gateway.remote.token")).toBe(true); + expect(ids.has("gateway.remote.password")).toBe(true); + }); }); diff --git a/src/cli/command-secret-targets.ts b/src/cli/command-secret-targets.ts index e1c2c49e0ae..d6dde83cd19 100644 --- a/src/cli/command-secret-targets.ts +++ b/src/cli/command-secret-targets.ts @@ -30,6 +30,7 @@ const COMMAND_SECRET_TARGETS = { "agents.defaults.memorySearch.remote.", "agents.list[].memorySearch.remote.", ]), + securityAudit: idsByPrefix(["channels.", "gateway.auth.", "gateway.remote."]), } as const; function toTargetIdSet(values: readonly string[]): Set { @@ -59,3 +60,7 @@ export function getAgentRuntimeCommandSecretTargetIds(): Set { export function getStatusCommandSecretTargetIds(): Set { return toTargetIdSet(COMMAND_SECRET_TARGETS.status); } + +export function getSecurityAuditCommandSecretTargetIds(): Set { + return toTargetIdSet(COMMAND_SECRET_TARGETS.securityAudit); +} diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 27b53753eda..fd94acca3a9 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -2,7 +2,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { captureEnv } from "../../test-utils/env.js"; import type { GatewayRestartSnapshot } from "./restart-health.js"; -const callGatewayStatusProbe = vi.fn(async (_opts?: unknown) => ({ ok: true as const })); +const callGatewayStatusProbe = vi.fn< + (opts?: unknown) => Promise<{ ok: boolean; url?: string; error?: string | null }> +>(async (_opts?: unknown) => ({ + ok: true, + url: "ws://127.0.0.1:19001", + error: null, +})); const loadGatewayTlsRuntime = vi.fn(async (_cfg?: unknown) => ({ enabled: true, required: true, @@ -333,6 +339,71 @@ describe("gatherDaemonStatus", () => { ); }); + it("degrades safely when daemon probe auth SecretRef is unresolved", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(callGatewayStatusProbe).toHaveBeenCalledWith( + expect.objectContaining({ + token: undefined, + password: undefined, + }), + ); + expect(status.rpc?.authWarning).toBeUndefined(); + }); + + it("surfaces authWarning when daemon probe auth SecretRef is unresolved and probe fails", async () => { + daemonLoadedConfig = { + gateway: { + bind: "lan", + tls: { enabled: true }, + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "MISSING_DAEMON_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: false, + error: "gateway closed", + url: "wss://127.0.0.1:19001", + }); + + const status = await gatherDaemonStatus({ + rpc: {}, + probe: true, + deep: false, + }); + + expect(status.rpc?.ok).toBe(false); + expect(status.rpc?.authWarning).toContain("gateway.auth.token SecretRef is unavailable"); + expect(status.rpc?.authWarning).toContain("probing without configured auth credentials"); + }); + it("keeps remote probe auth strict when remote token is missing", async () => { daemonLoadedConfig = { gateway: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 707a908b1f6..4647b789ff9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -16,7 +16,7 @@ import type { ServiceConfigAudit } from "../../daemon/service-audit.js"; import { auditGatewayServiceConfig } from "../../daemon/service-audit.js"; import type { GatewayServiceRuntime } from "../../daemon/service-runtime.js"; import { resolveGatewayService } from "../../daemon/service.js"; -import { trimToUndefined } from "../../gateway/credentials.js"; +import { isGatewaySecretRefUnavailableError, trimToUndefined } from "../../gateway/credentials.js"; import { resolveGatewayBindHost } from "../../gateway/net.js"; import { resolveGatewayProbeAuthWithSecretInputs } from "../../gateway/probe-auth.js"; import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js"; @@ -112,6 +112,7 @@ export type DaemonStatus = { ok: boolean; error?: string; url?: string; + authWarning?: string; }; health?: { healthy: boolean; @@ -130,6 +131,10 @@ function shouldReportPortUsage(status: PortUsageStatus | undefined, rpcOk?: bool return true; } +function parseGatewaySecretRefPathFromError(error: unknown): string | null { + return isGatewaySecretRefUnavailableError(error) ? error.path : null; +} + async function loadDaemonConfigContext( serviceEnv?: Record, ): Promise { @@ -310,8 +315,11 @@ export async function gatherDaemonStatus( const tlsRuntime = shouldUseLocalTlsRuntime ? await loadGatewayTlsRuntime(daemonCfg.gateway?.tls) : undefined; - const daemonProbeAuth = opts.probe - ? await resolveGatewayProbeAuthWithSecretInputs({ + let daemonProbeAuth: { token?: string; password?: string } | undefined; + let rpcAuthWarning: string | undefined; + if (opts.probe) { + try { + daemonProbeAuth = await resolveGatewayProbeAuthWithSecretInputs({ cfg: daemonCfg, mode: daemonCfg.gateway?.mode === "remote" ? "remote" : "local", env: mergedDaemonEnv as NodeJS.ProcessEnv, @@ -319,8 +327,16 @@ export async function gatherDaemonStatus( token: opts.rpc.token, password: opts.rpc.password, }, - }) - : undefined; + }); + } catch (error) { + const refPath = parseGatewaySecretRefPathFromError(error); + if (!refPath) { + throw error; + } + daemonProbeAuth = undefined; + rpcAuthWarning = `${refPath} SecretRef is unavailable in this command path; probing without configured auth credentials.`; + } + } const rpc = opts.probe ? await probeGatewayStatus({ @@ -336,6 +352,9 @@ export async function gatherDaemonStatus( configPath: daemonConfigSummary.path, }) : undefined; + if (rpc?.ok) { + rpcAuthWarning = undefined; + } const health = opts.probe && loaded ? await inspectGatewayRestart({ @@ -369,7 +388,15 @@ export async function gatherDaemonStatus( port: portStatus, ...(portCliStatus ? { portCli: portCliStatus } : {}), lastError, - ...(rpc ? { rpc: { ...rpc, url: gateway.probeUrl } } : {}), + ...(rpc + ? { + rpc: { + ...rpc, + url: gateway.probeUrl, + ...(rpcAuthWarning ? { authWarning: rpcAuthWarning } : {}), + }, + } + : {}), ...(health ? { health: { diff --git a/src/cli/daemon-cli/status.print.ts b/src/cli/daemon-cli/status.print.ts index 91348d10d4a..088a3654797 100644 --- a/src/cli/daemon-cli/status.print.ts +++ b/src/cli/daemon-cli/status.print.ts @@ -181,6 +181,9 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean }) defaultRuntime.log(`${label("RPC probe:")} ${okText("ok")}`); } else { defaultRuntime.error(`${label("RPC probe:")} ${errorText("failed")}`); + if (rpc.authWarning) { + defaultRuntime.error(`${label("RPC auth:")} ${warnText(rpc.authWarning)}`); + } if (rpc.url) { defaultRuntime.error(`${label("RPC target:")} ${rpc.url}`); } diff --git a/src/cli/security-cli.test.ts b/src/cli/security-cli.test.ts new file mode 100644 index 00000000000..95c3e62d4ae --- /dev/null +++ b/src/cli/security-cli.test.ts @@ -0,0 +1,245 @@ +import { Command } from "commander"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createCliRuntimeCapture } from "./test-runtime-capture.js"; + +const loadConfig = vi.fn(); +const runSecurityAudit = vi.fn(); +const fixSecurityFootguns = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const getSecurityAuditCommandSecretTargetIds = vi.fn( + () => new Set(["gateway.auth.token", "gateway.auth.password"]), +); + +const { defaultRuntime, runtimeLogs, resetRuntimeCapture } = createCliRuntimeCapture(); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfig(), +})); + +vi.mock("../runtime.js", () => ({ + defaultRuntime, +})); + +vi.mock("../security/audit.js", () => ({ + runSecurityAudit: (opts: unknown) => runSecurityAudit(opts), +})); + +vi.mock("../security/fix.js", () => ({ + fixSecurityFootguns: () => fixSecurityFootguns(), +})); + +vi.mock("./command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./command-secret-targets.js", () => ({ + getSecurityAuditCommandSecretTargetIds: () => getSecurityAuditCommandSecretTargetIds(), +})); + +const { registerSecurityCli } = await import("./security-cli.js"); + +function createProgram() { + const program = new Command(); + program.exitOverride(); + registerSecurityCli(program); + return program; +} + +describe("security CLI", () => { + beforeEach(() => { + resetRuntimeCapture(); + loadConfig.mockReset(); + runSecurityAudit.mockReset(); + fixSecurityFootguns.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + getSecurityAuditCommandSecretTargetIds.mockClear(); + fixSecurityFootguns.mockResolvedValue({ + changes: [], + actions: [], + errors: [], + }); + }); + + it("runs audit with read-only SecretRef resolution and prints JSON diagnostics", async () => { + const sourceConfig = { + gateway: { + auth: { + mode: "token", + token: { source: "env", provider: "default", id: "OPENCLAW_GATEWAY_TOKEN" }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig = { + ...sourceConfig, + gateway: { + ...sourceConfig.gateway, + auth: { + ...sourceConfig.gateway.auth, + token: "resolved-token", + }, + }, + }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 1, info: 0 }, + findings: [ + { + checkId: "gateway.probe_failed", + severity: "warn", + title: "Gateway probe failed (deep)", + detail: "connect failed: connect ECONNREFUSED 127.0.0.1:18789", + }, + ], + }); + + await createProgram().parseAsync(["security", "audit", "--json"], { from: "user" }); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + config: sourceConfig, + commandName: "security audit", + mode: "read_only_status", + targetIds: expect.any(Set), + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + config: resolvedConfig, + sourceConfig, + deep: false, + includeFilesystem: true, + includeChannelSecurity: true, + }), + ); + const payload = JSON.parse(String(runtimeLogs.at(-1))); + expect(payload.secretDiagnostics).toEqual([ + "security audit: gateway secrets.resolve unavailable (gateway closed); resolved command secrets locally.", + ]); + }); + + it("forwards --token to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--token", "explicit-token", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { token: "explicit-token" }, + }), + ); + }); + + it("forwards --password to deep probe auth without altering command-level resolver mode", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + ["security", "audit", "--deep", "--password", "explicit-password", "--json"], + { + from: "user", + }, + ); + + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + mode: "read_only_status", + }), + ); + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { password: "explicit-password" }, + }), + ); + }); + + it("forwards both --token and --password to deep probe auth", async () => { + const sourceConfig = { gateway: { mode: "local" } }; + loadConfig.mockReturnValue(sourceConfig); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: sourceConfig, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + runSecurityAudit.mockResolvedValue({ + ts: 0, + summary: { critical: 0, warn: 0, info: 0 }, + findings: [], + }); + + await createProgram().parseAsync( + [ + "security", + "audit", + "--deep", + "--token", + "explicit-token", + "--password", + "explicit-password", + "--json", + ], + { + from: "user", + }, + ); + + expect(runSecurityAudit).toHaveBeenCalledWith( + expect.objectContaining({ + deep: true, + deepProbeAuth: { + token: "explicit-token", + password: "explicit-password", + }, + }), + ); + }); +}); diff --git a/src/cli/security-cli.ts b/src/cli/security-cli.ts index f55f657f4c1..586e5e0f114 100644 --- a/src/cli/security-cli.ts +++ b/src/cli/security-cli.ts @@ -7,12 +7,16 @@ import { formatDocsLink } from "../terminal/links.js"; import { isRich, theme } from "../terminal/theme.js"; import { shortenHomeInString, shortenHomePath } from "../utils.js"; import { formatCliCommand } from "./command-format.js"; +import { resolveCommandSecretRefsViaGateway } from "./command-secret-gateway.js"; +import { getSecurityAuditCommandSecretTargetIds } from "./command-secret-targets.js"; import { formatHelpExamples } from "./help-format.js"; type SecurityAuditOptions = { json?: boolean; deep?: boolean; fix?: boolean; + token?: string; + password?: string; }; function formatSummary(summary: { critical: number; warn: number; info: number }): string { @@ -37,6 +41,11 @@ export function registerSecurityCli(program: Command) { `\n${theme.heading("Examples:")}\n${formatHelpExamples([ ["openclaw security audit", "Run a local security audit."], ["openclaw security audit --deep", "Include best-effort live Gateway probe checks."], + ["openclaw security audit --deep --token ", "Use explicit token for deep probe."], + [ + "openclaw security audit --deep --password ", + "Use explicit password for deep probe.", + ], ["openclaw security audit --fix", "Apply safe remediations and file-permission fixes."], ["openclaw security audit --json", "Output machine-readable JSON."], ])}\n\n${theme.muted("Docs:")} ${formatDocsLink("/cli/security", "docs.openclaw.ai/cli/security")}\n`, @@ -46,22 +55,45 @@ export function registerSecurityCli(program: Command) { .command("audit") .description("Audit config + local state for common security foot-guns") .option("--deep", "Attempt live Gateway probe (best-effort)", false) + .option("--token ", "Use explicit gateway token for deep probe auth") + .option("--password ", "Use explicit gateway password for deep probe auth") .option("--fix", "Apply safe fixes (tighten defaults + chmod state/config)", false) .option("--json", "Print JSON", false) .action(async (opts: SecurityAuditOptions) => { const fixResult = opts.fix ? await fixSecurityFootguns().catch((_err) => null) : null; - const cfg = loadConfig(); + const sourceConfig = loadConfig(); + const { resolvedConfig: cfg, diagnostics: secretDiagnostics } = + await resolveCommandSecretRefsViaGateway({ + config: sourceConfig, + commandName: "security audit", + targetIds: getSecurityAuditCommandSecretTargetIds(), + mode: "read_only_status", + }); const report = await runSecurityAudit({ config: cfg, + sourceConfig, deep: Boolean(opts.deep), includeFilesystem: true, includeChannelSecurity: true, + deepProbeAuth: + opts.token?.trim() || opts.password?.trim() + ? { + ...(opts.token?.trim() ? { token: opts.token } : {}), + ...(opts.password?.trim() ? { password: opts.password } : {}), + } + : undefined, }); if (opts.json) { defaultRuntime.log( - JSON.stringify(fixResult ? { fix: fixResult, report } : report, null, 2), + JSON.stringify( + fixResult + ? { fix: fixResult, report, secretDiagnostics } + : { ...report, secretDiagnostics }, + null, + 2, + ), ); return; } @@ -74,6 +106,9 @@ export function registerSecurityCli(program: Command) { lines.push(heading("OpenClaw security audit")); lines.push(muted(`Summary: ${formatSummary(report.summary)}`)); lines.push(muted(`Run deeper: ${formatCliCommand("openclaw security audit --deep")}`)); + for (const diagnostic of secretDiagnostics) { + lines.push(muted(`[secrets] ${diagnostic}`)); + } if (opts.fix) { lines.push(muted(`Fix: ${formatCliCommand("openclaw security audit --fix")}`)); diff --git a/src/commands/channel-account-context.test.ts b/src/commands/channel-account-context.test.ts index 9fdaadb5231..4cdbde4d7e2 100644 --- a/src/commands/channel-account-context.test.ts +++ b/src/commands/channel-account-context.test.ts @@ -21,6 +21,8 @@ describe("resolveDefaultChannelAccountContext", () => { expect(result.account).toBe(account); expect(result.enabled).toBe(true); expect(result.configured).toBe(true); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); }); it("uses plugin enable/configure hooks", async () => { @@ -43,5 +45,70 @@ describe("resolveDefaultChannelAccountContext", () => { expect(isConfigured).toHaveBeenCalledWith(account, {}); expect(result.enabled).toBe(false); expect(result.configured).toBe(false); + expect(result.diagnostics).toEqual([]); + expect(result.degraded).toBe(false); + }); + + it("keeps strict mode fail-closed when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + await expect(resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig)).rejects.toThrow( + /missing secret/i, + ); + }); + + it("degrades safely in read_only mode when resolveAccount throws", async () => { + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-err"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + commandName: "status", + }); + + expect(result.enabled).toBe(false); + expect(result.configured).toBe(false); + expect(result.degraded).toBe(true); + expect(result.diagnostics.some((entry) => entry.includes("failed to resolve account"))).toBe( + true, + ); + }); + + it("prefers inspectAccount in read_only mode", async () => { + const inspectAccount = vi.fn(() => ({ configured: true, enabled: true })); + const resolveAccount = vi.fn(() => ({ configured: false, enabled: false })); + const plugin = { + id: "demo", + config: { + listAccountIds: () => ["acc-1"], + inspectAccount, + resolveAccount, + }, + } as unknown as ChannelPlugin; + + const result = await resolveDefaultChannelAccountContext(plugin, {} as OpenClawConfig, { + mode: "read_only", + }); + + expect(inspectAccount).toHaveBeenCalled(); + expect(resolveAccount).not.toHaveBeenCalled(); + expect(result.enabled).toBe(true); + expect(result.configured).toBe(true); + expect(result.degraded).toBe(true); }); }); diff --git a/src/commands/channel-account-context.ts b/src/commands/channel-account-context.ts index 36ce8c53e72..c997ec3e18a 100644 --- a/src/commands/channel-account-context.ts +++ b/src/commands/channel-account-context.ts @@ -1,6 +1,8 @@ import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import type { ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import type { OpenClawConfig } from "../config/config.js"; +import { formatErrorMessage } from "../infra/errors.js"; export type ChannelDefaultAccountContext = { accountIds: string[]; @@ -8,22 +10,154 @@ export type ChannelDefaultAccountContext = { account: unknown; enabled: boolean; configured: boolean; + diagnostics: string[]; + /** + * Indicates read-only resolution was used instead of strict full-account resolution. + * This is expected for read_only mode and does not necessarily mean an error occurred. + */ + degraded: boolean; }; +export type ChannelAccountContextMode = "strict" | "read_only"; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + return value as Record; +} + +function getBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +function formatContextDiagnostic(params: { + commandName?: string; + pluginId: string; + accountId: string; + message: string; +}): string { + const prefix = params.commandName ? `${params.commandName}: ` : ""; + return `${prefix}channels.${params.pluginId}.accounts.${params.accountId}: ${params.message}`; +} + export async function resolveDefaultChannelAccountContext( plugin: ChannelPlugin, cfg: OpenClawConfig, + options?: { mode?: ChannelAccountContextMode; commandName?: string }, ): Promise { + const mode = options?.mode ?? "strict"; const accountIds = plugin.config.listAccountIds(cfg); const defaultAccountId = resolveChannelDefaultAccountId({ plugin, cfg, accountIds, }); - const account = plugin.config.resolveAccount(cfg, defaultAccountId); - const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; - return { accountIds, defaultAccountId, account, enabled, configured }; + if (mode === "strict") { + const account = plugin.config.resolveAccount(cfg, defaultAccountId); + const enabled = plugin.config.isEnabled ? plugin.config.isEnabled(account, cfg) : true; + const configured = plugin.config.isConfigured + ? await plugin.config.isConfigured(account, cfg) + : true; + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics: [], + degraded: false, + }; + } + + const diagnostics: string[] = []; + let degraded = false; + + const inspected = + plugin.config.inspectAccount?.(cfg, defaultAccountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId: defaultAccountId, + }); + + let account = inspected; + if (!account) { + try { + account = plugin.config.resolveAccount(cfg, defaultAccountId); + } catch (error) { + degraded = true; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to resolve account (${formatErrorMessage(error)}); skipping read-only checks.`, + }), + ); + return { + accountIds, + defaultAccountId, + account: {}, + enabled: false, + configured: false, + diagnostics, + degraded, + }; + } + } else { + degraded = true; + } + + const inspectEnabled = getBooleanField(account, "enabled"); + let enabled = inspectEnabled ?? true; + if (inspectEnabled === undefined && plugin.config.isEnabled) { + try { + enabled = plugin.config.isEnabled(account, cfg); + } catch (error) { + degraded = true; + enabled = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate enabled state (${formatErrorMessage(error)}); treating as disabled.`, + }), + ); + } + } + + const inspectConfigured = getBooleanField(account, "configured"); + let configured = inspectConfigured ?? true; + if (inspectConfigured === undefined && plugin.config.isConfigured) { + try { + configured = await plugin.config.isConfigured(account, cfg); + } catch (error) { + degraded = true; + configured = false; + diagnostics.push( + formatContextDiagnostic({ + commandName: options?.commandName, + pluginId: plugin.id, + accountId: defaultAccountId, + message: `failed to evaluate configured state (${formatErrorMessage(error)}); treating as unconfigured.`, + }), + ); + } + } + + return { + accountIds, + defaultAccountId, + account, + enabled, + configured, + diagnostics, + degraded, + }; } diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts new file mode 100644 index 00000000000..e613c64323a --- /dev/null +++ b/src/commands/channels.status.command-flow.test.ts @@ -0,0 +1,172 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const callGateway = vi.fn(); +const resolveCommandSecretRefsViaGateway = vi.fn(); +const requireValidConfigSnapshot = vi.fn(); +const listChannelPlugins = vi.fn(); +const withProgress = vi.fn(async (_opts: unknown, run: () => Promise) => await run()); + +vi.mock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGateway(opts), +})); + +vi.mock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: (opts: unknown) => resolveCommandSecretRefsViaGateway(opts), +})); + +vi.mock("./shared.js", () => ({ + requireValidConfigSnapshot: (runtime: unknown) => requireValidConfigSnapshot(runtime), + formatChannelAccountLabel: ({ + channel, + accountId, + }: { + channel: string; + accountId: string; + name?: string; + }) => `${channel} ${accountId}`, +})); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => listChannelPlugins(), + getChannelPlugin: (channel: string) => + (listChannelPlugins() as Array<{ id: string }>).find((plugin) => plugin.id === channel), +})); + +vi.mock("../cli/progress.js", () => ({ + withProgress: (opts: unknown, run: () => Promise) => withProgress(opts, run), +})); + +const { channelsStatusCommand } = await import("./channels/status.js"); + +function createTokenOnlyPlugin() { + return { + id: "discord", + meta: { + id: "discord", + label: "Discord", + selectionLabel: "Discord", + docsPath: "/channels/discord", + blurb: "test", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => ["default"], + defaultAccountId: () => "default", + inspectAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + resolveAccount: (cfg: { secretResolved?: boolean }) => + cfg.secretResolved + ? { + name: "Primary", + enabled: true, + configured: true, + token: "resolved-discord-token", + tokenSource: "config", + tokenStatus: "available", + } + : { + name: "Primary", + enabled: true, + configured: true, + token: "", + tokenSource: "config", + tokenStatus: "configured_unavailable", + }, + isConfigured: () => true, + isEnabled: () => true, + }, + actions: { + listActions: () => ["send"], + }, + }; +} + +function createRuntimeCapture() { + const logs: string[] = []; + const errors: string[] = []; + const runtime = { + log: (message: unknown) => logs.push(String(message)), + error: (message: unknown) => errors.push(String(message)), + exit: (_code?: number) => undefined, + }; + return { runtime, logs, errors }; +} + +describe("channelsStatusCommand SecretRef fallback flow", () => { + beforeEach(() => { + callGateway.mockReset(); + resolveCommandSecretRefsViaGateway.mockReset(); + requireValidConfigSnapshot.mockReset(); + listChannelPlugins.mockReset(); + withProgress.mockClear(); + listChannelPlugins.mockReturnValue([createTokenOnlyPlugin()]); + }); + + it("keeps read-only fallback output when SecretRefs are unresolved", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: false, channels: {} }, + diagnostics: [ + "channels status: channels.discord.token is unavailable in this command path; continuing with degraded read-only config.", + ], + targetStatesByPath: {}, + hadUnresolvedTargets: true, + }); + const { runtime, logs, errors } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + expect(errors.some((line) => line.includes("Gateway not reachable"))).toBe(true); + expect(resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith( + expect.objectContaining({ + commandName: "channels status", + mode: "read_only_status", + }), + ); + expect( + logs.some((line) => + line.includes("[secrets] channels status: channels.discord.token is unavailable"), + ), + ).toBe(true); + const joined = logs.join("\n"); + expect(joined).toContain("configured, secret unavailable in this command path"); + expect(joined).toContain("token:config (unavailable)"); + }); + + it("prefers resolved snapshots when command-local SecretRef resolution succeeds", async () => { + callGateway.mockRejectedValue(new Error("gateway closed")); + requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); + resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig: { secretResolved: true, channels: {} }, + diagnostics: [], + targetStatesByPath: {}, + hadUnresolvedTargets: false, + }); + const { runtime, logs } = createRuntimeCapture(); + + await channelsStatusCommand({ probe: false }, runtime as never); + + const joined = logs.join("\n"); + 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)"); + }); +}); diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index e9e0345871f..7a29b4993f5 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -75,7 +75,7 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), - mode: "operational_readonly", + mode: "read_only_operational", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 3a56810e44c..2cbdaf17726 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -315,7 +315,7 @@ export async function channelsStatusCommand( config: cfg, commandName: "channels status", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); for (const entry of diagnostics) { runtime.log(`[secrets] ${entry}`); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index f616bfaba55..a06c090f9f4 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -330,7 +330,7 @@ async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig): Promi config: cfg, commandName: "doctor --fix", targetIds: getChannelsCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => { const inspected = inspectTelegramAccount({ cfg, accountId }); diff --git a/src/commands/doctor-security.test.ts b/src/commands/doctor-security.test.ts index c91ed2087a4..ca2bfb2989c 100644 --- a/src/commands/doctor-security.test.ts +++ b/src/commands/doctor-security.test.ts @@ -173,6 +173,32 @@ describe("noteSecurityWarnings gateway exposure", () => { expect(message).toContain("direct/DM targets by default"); }); + it("degrades safely when channel account resolution fails in read-only security checks", async () => { + pluginRegistry.list = [ + { + id: "whatsapp", + meta: { label: "WhatsApp" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + isEnabled: () => true, + isConfigured: () => true, + }, + security: { + resolveDmPolicy: () => null, + }, + }, + ]; + + await noteSecurityWarnings({} as OpenClawConfig); + const message = lastMessage(); + expect(message).toContain("[secrets]"); + expect(message).toContain("failed to resolve account"); + expect(message).toContain("Run: openclaw security audit --deep"); + }); + it("skips heartbeat directPolicy warning when delivery is internal-only or explicit", async () => { const cfg = { agents: { diff --git a/src/commands/doctor-security.ts b/src/commands/doctor-security.ts index 5ba17c1c751..c489682f607 100644 --- a/src/commands/doctor-security.ts +++ b/src/commands/doctor-security.ts @@ -189,8 +189,14 @@ export async function noteSecurityWarnings(cfg: OpenClawConfig) { if (!plugin.security) { continue; } - const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + const { defaultAccountId, account, enabled, configured, diagnostics } = + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "doctor", + }); + for (const diagnostic of diagnostics) { + warnings.push(`- [secrets] ${diagnostic}`); + } if (!enabled) { continue; } diff --git a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts index 68d865996d2..11a382db241 100644 --- a/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts +++ b/src/commands/doctor.warns-state-directory-is-missing.e2e.test.ts @@ -122,4 +122,52 @@ describe("doctor command", () => { "openclaw config set gateway.auth.mode password", ); }); + + it("keeps doctor read-only when gateway token is SecretRef-managed but unresolved", async () => { + mockDoctorConfigSnapshot({ + config: { + gateway: { + mode: "local", + auth: { + mode: "token", + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }, + }); + + const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + note.mockClear(); + try { + await doctorCommand(createDoctorRuntime(), { + nonInteractive: true, + workspaceSuggestions: false, + }); + } finally { + if (previousToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = previousToken; + } + } + + const gatewayAuthNote = note.mock.calls.find((call) => call[1] === "Gateway auth"); + expect(gatewayAuthNote).toBeTruthy(); + expect(String(gatewayAuthNote?.[0])).toContain( + "Gateway token is managed via SecretRef and is currently unavailable.", + ); + expect(String(gatewayAuthNote?.[0])).toContain( + "Doctor will not overwrite gateway.auth.token with a plaintext value.", + ); + }); }); diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 452bcb3691b..46212816410 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -268,7 +268,7 @@ describe("gateway-status command", () => { expect(scopeLimitedWarning?.targetIds).toContain("localLoopback"); }); - it("surfaces unresolved SecretRef auth diagnostics in warnings", async () => { + it("suppresses unresolved SecretRef auth warnings when probe is reachable", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { mockLocalTokenEnvRefConfig(); @@ -276,6 +276,38 @@ describe("gateway-status command", () => { await runGatewayStatus(runtime, { timeout: "1000", json: true }); }); + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; + }; + const unresolvedWarning = parsed.warnings?.find( + (warning) => + warning.code === "auth_secretref_unresolved" && + warning.message?.includes("gateway.auth.token SecretRef is unresolved"), + ); + expect(unresolvedWarning).toBeUndefined(); + }); + + it("surfaces unresolved SecretRef auth diagnostics when probe fails", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + await withEnvAsync({ MISSING_GATEWAY_TOKEN: undefined }, async () => { + mockLocalTokenEnvRefConfig(); + probeGateway.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "connection refused", + close: null, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow( + "__exit__:1", + ); + }); + expect(runtimeErrors).toHaveLength(0); const parsed = JSON.parse(runtimeLogs.join("\n")) as { warnings?: Array<{ code?: string; message?: string; targetIds?: string[] }>; diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index be0b9abf69a..ff2ba419cc8 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -229,7 +229,7 @@ export async function gatewayStatusCommand( }); } for (const result of probed) { - if (result.authDiagnostics.length === 0) { + if (result.authDiagnostics.length === 0 || isProbeReachable(result.probe)) { continue; } for (const diagnostic of result.authDiagnostics) { diff --git a/src/commands/health.ts b/src/commands/health.ts index 56705c96270..0e54eebadc7 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,7 +1,8 @@ import { resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { getChannelPlugin, listChannelPlugins } from "../channels/plugins/index.js"; -import type { ChannelAccountSnapshot } from "../channels/plugins/types.js"; +import type { ChannelAccountSnapshot, ChannelPlugin } from "../channels/plugins/types.js"; +import { inspectReadOnlyChannelAccount } from "../channels/read-only-account-inspect.js"; import { withProgress } from "../cli/progress.js"; import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, readBestEffortConfig } from "../config/config.js"; @@ -161,17 +162,91 @@ const buildSessionSummary = (storePath: string) => { } satisfies HealthSummary["sessions"]; }; -const isAccountEnabled = (account: unknown): boolean => { - if (!account || typeof account !== "object") { - return true; - } - const enabled = (account as { enabled?: boolean }).enabled; - return enabled !== false; -}; - const asRecord = (value: unknown): Record | null => value && typeof value === "object" ? (value as Record) : null; +function inspectHealthAccount( + plugin: ChannelPlugin, + cfg: OpenClawConfig, + accountId: string, +): unknown { + return ( + plugin.config.inspectAccount?.(cfg, accountId) ?? + inspectReadOnlyChannelAccount({ + channelId: plugin.id, + cfg, + accountId, + }) + ); +} + +function readBooleanField(value: unknown, key: string): boolean | undefined { + const record = asRecord(value); + if (!record) { + return undefined; + } + return typeof record[key] === "boolean" ? record[key] : undefined; +} + +async function resolveHealthAccountContext(params: { + plugin: ChannelPlugin; + cfg: OpenClawConfig; + accountId: string; +}): Promise<{ + account: unknown; + enabled: boolean; + configured: boolean; + diagnostics: string[]; +}> { + const diagnostics: string[] = []; + let account: unknown; + try { + account = params.plugin.config.resolveAccount(params.cfg, params.accountId); + } catch (error) { + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + account = inspectHealthAccount(params.plugin, params.cfg, params.accountId); + } + + if (!account) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } + + const enabledFallback = readBooleanField(account, "enabled") ?? true; + let enabled = enabledFallback; + if (params.plugin.config.isEnabled) { + try { + enabled = params.plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = enabledFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + const configuredFallback = readBooleanField(account, "configured") ?? true; + let configured = configuredFallback; + if (params.plugin.config.isConfigured) { + try { + configured = await params.plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = configuredFallback; + diagnostics.push( + `${params.plugin.id}:${params.accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; +} + const formatProbeLine = (probe: unknown, opts: { botUsernames?: string[] } = {}): string | null => { const record = asRecord(probe); if (!record) { @@ -416,13 +491,14 @@ export async function getHealthSnapshot(params?: { const accountSummaries: Record = {}; for (const accountId of accountIdsToProbe) { - const account = plugin.config.resolveAccount(cfg, accountId); - const enabled = plugin.config.isEnabled - ? plugin.config.isEnabled(account, cfg) - : isAccountEnabled(account); - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; + const { account, enabled, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); + if (diagnostics.length > 0) { + debugHealth("account.diagnostics", { channel: plugin.id, accountId, diagnostics }); + } let probe: unknown; let lastProbeAt: number | null = null; @@ -588,16 +664,20 @@ export async function healthCommand( ` ${plugin.id}: accounts=${accountIds.join(", ") || "(none)"} default=${defaultAccountId}`, ); for (const accountId of accountIds) { - const account = plugin.config.resolveAccount(cfg, accountId); + const { account, configured, diagnostics } = await resolveHealthAccountContext({ + plugin, + cfg, + accountId, + }); const record = asRecord(account); const tokenSource = record && typeof record.tokenSource === "string" ? record.tokenSource : undefined; - const configured = plugin.config.isConfigured - ? await plugin.config.isConfigured(account, cfg) - : true; runtime.log( ` - ${accountId}: configured=${configured}${tokenSource ? ` tokenSource=${tokenSource}` : ""}`, ); + for (const diagnostic of diagnostics) { + runtime.log(` ! ${diagnostic}`); + } } } runtime.log(info("[debug] bindings map")); @@ -691,13 +771,31 @@ export async function healthCommand( defaultAccountId, boundAccounts, }); - const account = plugin.config.resolveAccount(cfg, accountId); - plugin.status.logSelfId({ - account, + const accountContext = await resolveHealthAccountContext({ + plugin, cfg, - runtime, - includeChannelPrefix: true, + accountId, }); + if (!accountContext.enabled || !accountContext.configured) { + continue; + } + if (accountContext.diagnostics.length > 0) { + continue; + } + try { + plugin.status.logSelfId({ + account: accountContext.account, + cfg, + runtime, + includeChannelPrefix: true, + }); + } catch (error) { + debugHealth("logSelfId.failed", { + channel: plugin.id, + accountId, + error: formatErrorMessage(error), + }); + } } if (resolvedAgents.length > 0) { diff --git a/src/commands/status-all.ts b/src/commands/status-all.ts index fa4e3dcb435..b643c30ff33 100644 --- a/src/commands/status-all.ts +++ b/src/commands/status-all.ts @@ -48,7 +48,7 @@ export async function statusAllCommand( config: loadedRaw, commandName: "status --all", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const snap = await readConfigFileSnapshot().catch(() => null); diff --git a/src/commands/status.link-channel.test.ts b/src/commands/status.link-channel.test.ts new file mode 100644 index 00000000000..14315ef1a35 --- /dev/null +++ b/src/commands/status.link-channel.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const pluginRegistry = vi.hoisted(() => ({ list: [] as unknown[] })); + +vi.mock("../channels/plugins/index.js", () => ({ + listChannelPlugins: () => pluginRegistry.list, +})); + +import { resolveLinkChannelContext } from "./status.link-channel.js"; + +describe("resolveLinkChannelContext", () => { + it("returns linked context from read-only inspected account state", async () => { + const account = { configured: true, enabled: true }; + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + inspectAccount: () => account, + resolveAccount: () => { + throw new Error("should not be called in read-only mode"); + }, + }, + status: { + buildChannelSummary: () => ({ linked: true, authAgeMs: 1234 }), + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result?.linked).toBe(true); + expect(result?.authAgeMs).toBe(1234); + expect(result?.account).toBe(account); + }); + + it("degrades safely when account resolution throws", async () => { + pluginRegistry.list = [ + { + id: "discord", + meta: { label: "Discord" }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing secret"); + }, + }, + }, + ]; + + const result = await resolveLinkChannelContext({} as OpenClawConfig); + expect(result).toBeNull(); + }); +}); diff --git a/src/commands/status.link-channel.ts b/src/commands/status.link-channel.ts index 2ee0eee4f2e..4f192f31623 100644 --- a/src/commands/status.link-channel.ts +++ b/src/commands/status.link-channel.ts @@ -16,7 +16,10 @@ export async function resolveLinkChannelContext( ): Promise { for (const plugin of listChannelPlugins()) { const { defaultAccountId, account, enabled, configured } = - await resolveDefaultChannelAccountContext(plugin, cfg); + await resolveDefaultChannelAccountContext(plugin, cfg, { + mode: "read_only", + commandName: "status", + }); const snapshot = plugin.config.describeAccount ? plugin.config.describeAccount(account, cfg) : ({ diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 8de4aae7745..f7661573578 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -197,7 +197,7 @@ async function scanStatusJsonFast(opts: { config: loadedRaw, commandName: "status --json", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); if (hasPotentialConfiguredChannels(cfg)) { const { ensurePluginRegistryLoaded } = await loadPluginRegistryModule(); @@ -302,7 +302,7 @@ export async function scanStatus( config: loadedRaw, commandName: "status", targetIds: getStatusCommandSecretTargetIds(), - mode: "summary", + mode: "read_only_status", }); const osSummary = resolveOsSummary(); const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 5cc71b6e950..f3dfd37064a 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -512,6 +512,11 @@ describe("statusCommand", () => { await statusCommand({ json: true }, runtime as never); const payload = JSON.parse(String(runtimeLogMock.mock.calls.at(-1)?.[0])); expect(payload.gateway.error ?? payload.gateway.authWarning ?? null).not.toBeNull(); + if (Array.isArray(payload.secretDiagnostics) && payload.secretDiagnostics.length > 0) { + expect( + payload.secretDiagnostics.some((entry: string) => entry.includes("gateway.auth.token")), + ).toBe(true); + } expect(runtime.error).not.toHaveBeenCalled(); }); diff --git a/src/gateway/call.ts b/src/gateway/call.ts index f163a45ef06..300391b6047 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -330,11 +330,8 @@ async function resolveGatewaySecretInputString(params: { value: params.value, env: params.env, normalize: trimToUndefined, - onResolveRefError: (error) => { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`${params.path} secret reference could not be resolved: ${detail}`, { - cause: error, - }); + onResolveRefError: () => { + throw new GatewaySecretRefUnavailableError(params.path); }, }); if (!value) { diff --git a/src/gateway/probe-auth.ts b/src/gateway/probe-auth.ts index 64980be601e..2c624acaa00 100644 --- a/src/gateway/probe-auth.ts +++ b/src/gateway/probe-auth.ts @@ -54,10 +54,22 @@ export function resolveGatewayProbeAuthSafe(params: { cfg: OpenClawConfig; mode: "local" | "remote"; env?: NodeJS.ProcessEnv; + explicitAuth?: ExplicitGatewayAuth; }): { auth: { token?: string; password?: string }; warning?: string; } { + const explicitToken = params.explicitAuth?.token?.trim(); + const explicitPassword = params.explicitAuth?.password?.trim(); + if (explicitToken || explicitPassword) { + return { + auth: { + ...(explicitToken ? { token: explicitToken } : {}), + ...(explicitPassword ? { password: explicitPassword } : {}), + }, + }; + } + try { return { auth: resolveGatewayProbeAuth(params) }; } catch (error) { diff --git a/src/security/audit-channel.ts b/src/security/audit-channel.ts index ca0e69722e3..bf501cf659b 100644 --- a/src/security/audit-channel.ts +++ b/src/security/audit-channel.ts @@ -14,6 +14,7 @@ import { formatCliCommand } from "../cli/command-format.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../config/commands.js"; import type { OpenClawConfig } from "../config/config.js"; import { isDangerousNameMatchingEnabled } from "../config/dangerous-name-matching.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { normalizeStringEntries } from "../shared/string-normalization.js"; import type { SecurityAuditFinding, SecurityAuditSeverity } from "./audit.js"; @@ -164,6 +165,7 @@ export async function collectChannelSecurityFindings(params: { plugin: (typeof params.plugins)[number], accountId: string, ) => { + const diagnostics: string[] = []; const sourceInspectedAccount = inspectChannelAccount(plugin, sourceConfig, accountId); const resolvedInspectedAccount = inspectChannelAccount(plugin, params.cfg, accountId); const sourceInspection = sourceInspectedAccount as { @@ -174,8 +176,27 @@ export async function collectChannelSecurityFindings(params: { enabled?: boolean; configured?: boolean; } | null; - const resolvedAccount = - resolvedInspectedAccount ?? plugin.config.resolveAccount(params.cfg, accountId); + let resolvedAccount = resolvedInspectedAccount; + if (!resolvedAccount) { + try { + resolvedAccount = plugin.config.resolveAccount(params.cfg, accountId); + } catch (error) { + diagnostics.push( + `${plugin.id}:${accountId}: failed to resolve account (${formatErrorMessage(error)}).`, + ); + } + } + if (!resolvedAccount && sourceInspectedAccount) { + resolvedAccount = sourceInspectedAccount; + } + if (!resolvedAccount) { + return { + account: {}, + enabled: false, + configured: false, + diagnostics, + }; + } const useSourceUnavailableAccount = Boolean( sourceInspectedAccount && hasConfiguredUnavailableCredentialStatus(sourceInspectedAccount) && @@ -185,23 +206,49 @@ export async function collectChannelSecurityFindings(params: { const account = useSourceUnavailableAccount ? sourceInspectedAccount : resolvedAccount; const selectedInspection = useSourceUnavailableAccount ? sourceInspection : resolvedInspection; const accountRecord = asAccountRecord(account); - const enabled = + let enabled = typeof selectedInspection?.enabled === "boolean" ? selectedInspection.enabled : typeof accountRecord?.enabled === "boolean" ? accountRecord.enabled - : plugin.config.isEnabled - ? plugin.config.isEnabled(account, params.cfg) - : true; - const configured = + : true; + if ( + typeof selectedInspection?.enabled !== "boolean" && + typeof accountRecord?.enabled !== "boolean" && + plugin.config.isEnabled + ) { + try { + enabled = plugin.config.isEnabled(account, params.cfg); + } catch (error) { + enabled = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate enabled state (${formatErrorMessage(error)}).`, + ); + } + } + + let configured = typeof selectedInspection?.configured === "boolean" ? selectedInspection.configured : typeof accountRecord?.configured === "boolean" ? accountRecord.configured - : plugin.config.isConfigured - ? await plugin.config.isConfigured(account, params.cfg) - : true; - return { account, enabled, configured }; + : true; + if ( + typeof selectedInspection?.configured !== "boolean" && + typeof accountRecord?.configured !== "boolean" && + plugin.config.isConfigured + ) { + try { + configured = await plugin.config.isConfigured(account, params.cfg); + } catch (error) { + configured = false; + diagnostics.push( + `${plugin.id}:${accountId}: failed to evaluate configured state (${formatErrorMessage(error)}).`, + ); + } + } + + return { account, enabled, configured, diagnostics }; }; const coerceNativeSetting = (value: unknown): boolean | "auto" | undefined => { @@ -298,7 +345,20 @@ export async function collectChannelSecurityFindings(params: { plugin.id, accountId, ); - const { account, enabled, configured } = await resolveChannelAuditAccount(plugin, accountId); + const { account, enabled, configured, diagnostics } = await resolveChannelAuditAccount( + plugin, + accountId, + ); + for (const diagnostic of diagnostics) { + findings.push({ + checkId: `channels.${plugin.id}.account.read_only_resolution`, + severity: "warn", + title: `${plugin.meta.label ?? plugin.id} account could not be fully resolved`, + detail: diagnostic, + remediation: + "Ensure referenced secrets are available in this shell or run with a running gateway snapshot so security audit can inspect the full channel configuration.", + }); + } if (!enabled) { continue; } diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index dd1040e1263..dedc789773c 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -346,6 +346,43 @@ description: test skill expectNoFinding(res, "gateway.bind_no_auth"); }); + it("does not flag missing gateway auth when read-only scrubbed config omits unavailable auth SecretRefs", async () => { + const sourceConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: { + token: { + source: "env", + provider: "default", + id: "OPENCLAW_GATEWAY_TOKEN", + }, + }, + }, + secrets: { + providers: { + default: { source: "env" }, + }, + }, + }; + const resolvedConfig: OpenClawConfig = { + gateway: { + bind: "lan", + auth: {}, + }, + secrets: sourceConfig.secrets, + }; + + const res = await runSecurityAudit({ + config: resolvedConfig, + sourceConfig, + env: {}, + includeFilesystem: false, + includeChannelSecurity: false, + }); + + expectNoFinding(res, "gateway.bind_no_auth"); + }); + it("evaluates gateway auth rate-limit warning based on configuration", async () => { const cases: Array<{ name: string; @@ -1805,11 +1842,7 @@ description: test skill it("warns when multiple DM senders share the main session", async () => { const cfg: OpenClawConfig = { session: { dmScope: "main" }, - channels: { - whatsapp: { - enabled: true, - }, - }, + channels: { whatsapp: { enabled: true } }, }; const plugins: ChannelPlugin[] = [ { @@ -1984,6 +2017,40 @@ description: test skill }); }); + it("adds a read-only resolution warning when channel account resolveAccount throws", async () => { + const plugin = stubChannelPlugin({ + id: "zalouser", + label: "Zalo Personal", + listAccountIds: () => ["default"], + resolveAccount: () => { + throw new Error("missing SecretRef"); + }, + }); + + const cfg: OpenClawConfig = { + channels: { + zalouser: { + enabled: true, + }, + }, + }; + + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: false, + includeChannelSecurity: true, + plugins: [plugin], + }); + + const finding = res.findings.find( + (entry) => entry.checkId === "channels.zalouser.account.read_only_resolution", + ); + expect(finding?.severity).toBe("warn"); + expect(finding?.title).toContain("could not be fully resolved"); + expect(finding?.detail).toContain("zalouser:default: failed to resolve account"); + expect(finding?.detail).toContain("missing SecretRef"); + }); + it("keeps Slack HTTP slash-command findings when resolved inspection only exposes signingSecret status", async () => { await withChannelSecurityStateDir(async () => { const sourceConfig: OpenClawConfig = { diff --git a/src/security/audit.ts b/src/security/audit.ts index dbbfb9651be..d3c1337e042 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -113,6 +113,8 @@ export type SecurityAuditOptions = { configSnapshot?: ConfigFileSnapshot | null; /** Optional cache for code-safety summaries across repeated deep audits. */ codeSafetySummaryCache?: Map>; + /** Optional explicit auth for deep gateway probe. */ + deepProbeAuth?: { token?: string; password?: string }; }; type AuditExecutionContext = { @@ -132,6 +134,7 @@ type AuditExecutionContext = { plugins?: ReturnType; configSnapshot: ConfigFileSnapshot | null; codeSafetySummaryCache: Map>; + deepProbeAuth?: { token?: string; password?: string }; }; function countBySeverity(findings: SecurityAuditFinding[]): SecurityAuditSummary { @@ -341,6 +344,7 @@ async function collectFilesystemFindings(params: { function collectGatewayConfigFindings( cfg: OpenClawConfig, + sourceConfig: OpenClawConfig, env: NodeJS.ProcessEnv, ): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; @@ -365,18 +369,18 @@ function collectGatewayConfigFindings( hasNonEmptyString(env.OPENCLAW_GATEWAY_PASSWORD) || hasNonEmptyString(env.CLAWDBOT_GATEWAY_PASSWORD); const tokenConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.token, + sourceConfig.secrets?.defaults, ); const passwordConfiguredFromConfig = hasConfiguredSecretInput( - cfg.gateway?.auth?.password, - cfg.secrets?.defaults, + sourceConfig.gateway?.auth?.password, + sourceConfig.secrets?.defaults, ); const remoteTokenConfigured = hasConfiguredSecretInput( - cfg.gateway?.remote?.token, - cfg.secrets?.defaults, + sourceConfig.gateway?.remote?.token, + sourceConfig.secrets?.defaults, ); - const explicitAuthMode = cfg.gateway?.auth?.mode; + const explicitAuthMode = sourceConfig.gateway?.auth?.mode; const tokenCanWin = hasToken || envTokenConfigured || tokenConfiguredFromConfig || remoteTokenConfigured; const passwordCanWin = @@ -1062,6 +1066,7 @@ async function maybeProbeGateway(params: { env: NodeJS.ProcessEnv; timeoutMs: number; probe: typeof probeGateway; + explicitAuth?: { token?: string; password?: string }; }): Promise<{ deep: SecurityAuditReport["deep"]; authWarning?: string; @@ -1075,8 +1080,18 @@ async function maybeProbeGateway(params: { const authResolution = !isRemoteMode || remoteUrlMissing - ? resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "local" }) - : resolveGatewayProbeAuthSafe({ cfg: params.cfg, env: params.env, mode: "remote" }); + ? resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "local", + explicitAuth: params.explicitAuth, + }) + : resolveGatewayProbeAuthSafe({ + cfg: params.cfg, + env: params.env, + mode: "remote", + explicitAuth: params.explicitAuth, + }); const res = await params .probe({ url, auth: authResolution.auth, timeoutMs: params.timeoutMs }) .catch((err) => ({ @@ -1144,6 +1159,7 @@ async function createAuditExecutionContext( plugins: opts.plugins, configSnapshot, codeSafetySummaryCache: opts.codeSafetySummaryCache ?? new Map>(), + deepProbeAuth: opts.deepProbeAuth, }; } @@ -1155,7 +1171,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise