From f7833376eab9f7eaa95dc1ba2199b3ec98fd7da4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 07:40:23 +0100 Subject: [PATCH] refactor: share command config resolution --- src/agents/agent-command.ts | 8 +-- src/cli/command-config-resolution.test.ts | 82 +++++++++++++++++++++++ src/cli/command-config-resolution.ts | 46 +++++++++++++ src/commands/channels/resolve.ts | 14 ++-- src/commands/channels/shared.ts | 14 ++-- src/commands/channels/status.ts | 8 +-- src/commands/message.ts | 14 ++-- src/commands/models/load-config.ts | 10 +-- 8 files changed, 150 insertions(+), 46 deletions(-) create mode 100644 src/cli/command-config-resolution.test.ts create mode 100644 src/cli/command-config-resolution.ts diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index b643d917a2d..8f4442e93a8 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -10,8 +10,8 @@ import { supportsXHighThinking, type VerboseLevel, } from "../auto-reply/thinking.js"; +import { resolveCommandConfigWithSecrets } from "../cli/command-config-resolution.js"; import { formatCliCommand } from "../cli/command-format.js"; -import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; import { getAgentRuntimeCommandSecretTargetIds } from "../cli/command-secret-targets.js"; import { type CliDeps, createDefaultDeps } from "../cli/deps.js"; import { @@ -178,10 +178,11 @@ async function prepareAgentCommandExecution( } return loadedRaw; })(); - const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { resolvedConfig: cfg } = await resolveCommandConfigWithSecrets({ config: loadedRaw, commandName: "agent", targetIds: getAgentRuntimeCommandSecretTargetIds(), + runtime, }); setRuntimeConfigSnapshot(cfg, sourceConfig); const normalizedSpawned = normalizeSpawnedRunMetadata({ @@ -191,9 +192,6 @@ async function prepareAgentCommandExecution( groupSpace: opts.groupSpace, workspaceDir: opts.workspaceDir, }); - for (const entry of diagnostics) { - runtime.log(`[secrets] ${entry}`); - } const agentIdOverrideRaw = opts.agentId?.trim(); const agentIdOverride = agentIdOverrideRaw ? normalizeAgentId(agentIdOverrideRaw) : undefined; if (agentIdOverride) { diff --git a/src/cli/command-config-resolution.test.ts b/src/cli/command-config-resolution.test.ts new file mode 100644 index 00000000000..0fd2ed68c0b --- /dev/null +++ b/src/cli/command-config-resolution.test.ts @@ -0,0 +1,82 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + resolveCommandSecretRefsViaGateway: vi.fn(), + applyPluginAutoEnable: vi.fn(), +})); + +vi.mock("./command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway, +})); + +vi.mock("../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: mocks.applyPluginAutoEnable, +})); + +import { resolveCommandConfigWithSecrets } from "./command-config-resolution.js"; + +describe("resolveCommandConfigWithSecrets", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("logs diagnostics and preserves resolved config when auto-enable is off", async () => { + const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() } as const; + const config = { channels: {} }; + const resolvedConfig = { channels: { telegram: {} } }; + const targetIds = new Set(["channels.telegram.token"]); + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: ["resolved channels.telegram.token"], + }); + + const result = await resolveCommandConfigWithSecrets({ + config, + commandName: "status", + targetIds, + mode: "read_only_status", + runtime, + }); + + expect(mocks.resolveCommandSecretRefsViaGateway).toHaveBeenCalledWith({ + config, + commandName: "status", + targetIds, + mode: "read_only_status", + }); + expect(runtime.log).toHaveBeenCalledWith("[secrets] resolved channels.telegram.token"); + expect(mocks.applyPluginAutoEnable).not.toHaveBeenCalled(); + expect(result).toEqual({ + resolvedConfig, + effectiveConfig: resolvedConfig, + diagnostics: ["resolved channels.telegram.token"], + }); + }); + + it("returns auto-enabled config when requested", async () => { + const resolvedConfig = { channels: {} }; + const effectiveConfig = { channels: {}, plugins: { allow: ["telegram"] } }; + mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({ + resolvedConfig, + diagnostics: [], + }); + mocks.applyPluginAutoEnable.mockReturnValue({ + config: effectiveConfig, + changes: ["enabled telegram"], + }); + + const result = await resolveCommandConfigWithSecrets({ + config: resolvedConfig, + commandName: "message", + targetIds: new Set(["channels.telegram.token"]), + autoEnable: true, + env: { OPENCLAW_AUTO_ENABLE: "1" } as NodeJS.ProcessEnv, + }); + + expect(mocks.applyPluginAutoEnable).toHaveBeenCalledWith({ + config: resolvedConfig, + env: { OPENCLAW_AUTO_ENABLE: "1" }, + }); + expect(result.effectiveConfig).toBe(effectiveConfig); + }); +}); diff --git a/src/cli/command-config-resolution.ts b/src/cli/command-config-resolution.ts new file mode 100644 index 00000000000..0ee80efc59d --- /dev/null +++ b/src/cli/command-config-resolution.ts @@ -0,0 +1,46 @@ +import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; +import type { OpenClawConfig } from "../config/types.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { + type CommandSecretResolutionMode, + resolveCommandSecretRefsViaGateway, +} from "./command-secret-gateway.js"; + +export async function resolveCommandConfigWithSecrets(params: { + config: TConfig; + commandName: string; + targetIds: Set; + mode?: CommandSecretResolutionMode; + allowedPaths?: Set; + runtime?: RuntimeEnv; + autoEnable?: boolean; + env?: NodeJS.ProcessEnv; +}): Promise<{ + resolvedConfig: TConfig; + effectiveConfig: TConfig; + diagnostics: string[]; +}> { + const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + config: params.config, + commandName: params.commandName, + targetIds: params.targetIds, + ...(params.mode ? { mode: params.mode } : {}), + ...(params.allowedPaths ? { allowedPaths: params.allowedPaths } : {}), + }); + if (params.runtime) { + for (const entry of diagnostics) { + params.runtime.log(`[secrets] ${entry}`); + } + } + const effectiveConfig = params.autoEnable + ? applyPluginAutoEnable({ + config: resolvedConfig, + env: params.env ?? process.env, + }).config + : resolvedConfig; + return { + resolvedConfig: resolvedConfig as TConfig, + effectiveConfig: effectiveConfig as TConfig, + diagnostics, + }; +} diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 885f16e2d8e..c3b7db9faff 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -1,9 +1,8 @@ import { getChannelPlugin } from "../../channels/plugins/index.js"; import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js"; -import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js"; +import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js"; import { loadConfig, readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js"; -import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import { danger } from "../../globals.js"; import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js"; import { type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; @@ -108,19 +107,14 @@ function formatResolveResult(result: ResolveResult): string { export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) { const sourceSnapshotPromise = readConfigFileSnapshot().catch(() => null); const loadedRaw = loadConfig(); - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + let { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({ config: loadedRaw, commandName: "channels resolve", targetIds: getChannelsCommandSecretTargetIds(), mode: "read_only_operational", + runtime, + autoEnable: true, }); - let cfg = applyPluginAutoEnable({ - config: resolvedConfig, - env: process.env, - }).config; - for (const entry of diagnostics) { - runtime.log(`[secrets] ${entry}`); - } const entries = (opts.entries ?? []).map((entry) => entry.trim()).filter(Boolean); if (entries.length === 0) { throw new Error("At least one entry is required."); diff --git a/src/commands/channels/shared.ts b/src/commands/channels/shared.ts index 9ea60786390..d3597fefbf2 100644 --- a/src/commands/channels/shared.ts +++ b/src/commands/channels/shared.ts @@ -1,8 +1,6 @@ import { type ChannelId, getChannelPlugin } from "../../channels/plugins/index.js"; -import { - type CommandSecretResolutionMode, - resolveCommandSecretRefsViaGateway, -} from "../../cli/command-secret-gateway.js"; +import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; +import type { CommandSecretResolutionMode } 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"; @@ -28,16 +26,14 @@ export async function requireValidConfig( if (!cfg) { return null; } - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { effectiveConfig } = await resolveCommandConfigWithSecrets({ config: cfg, commandName: secretResolution?.commandName ?? "channels", targetIds: getChannelsCommandSecretTargetIds(), mode: secretResolution?.mode, + runtime, }); - for (const entry of diagnostics) { - runtime.log(`[secrets] ${entry}`); - } - return resolvedConfig; + return effectiveConfig; } export function formatAccountLabel(params: { accountId: string; name?: string }) { diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 058df802671..abf49f4d6ce 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -8,8 +8,8 @@ import { buildReadOnlySourceChannelAccountSnapshot, } from "../../channels/plugins/status.js"; import type { ChannelAccountSnapshot } from "../../channels/plugins/types.js"; +import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.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"; @@ -311,15 +311,13 @@ export async function channelsStatusCommand( if (!cfg) { return; } - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { resolvedConfig } = await resolveCommandConfigWithSecrets({ config: cfg, commandName: "channels status", targetIds: getChannelsCommandSecretTargetIds(), mode: "read_only_status", + runtime, }); - for (const entry of diagnostics) { - runtime.log(`[secrets] ${entry}`); - } const snapshot = await readConfigFileSnapshot(); const mode = cfg.gateway?.mode === "remote" ? "remote" : "local"; runtime.log( diff --git a/src/commands/message.ts b/src/commands/message.ts index 07100da15dc..a316e27b150 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -3,13 +3,12 @@ import { CHANNEL_MESSAGE_ACTION_NAMES, type ChannelMessageActionName, } from "../channels/plugins/types.js"; -import { resolveCommandSecretRefsViaGateway } from "../cli/command-secret-gateway.js"; +import { resolveCommandConfigWithSecrets } from "../cli/command-config-resolution.js"; import { getScopedChannelsCommandSecretTargets } from "../cli/command-secret-targets.js"; import { resolveMessageSecretScope } from "../cli/message-secret-scope.js"; import { createOutboundSendDeps, type CliDeps } from "../cli/outbound-send-deps.js"; import { withProgress } from "../cli/progress.js"; import { loadConfig } from "../config/config.js"; -import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import type { OutboundSendDeps } from "../infra/outbound/deliver.js"; import { runMessageAction } from "../infra/outbound/message-action-runner.js"; import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js"; @@ -33,19 +32,14 @@ export async function messageCommand( channel: scope.channel, accountId: scope.accountId, }); - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { effectiveConfig: cfg } = await resolveCommandConfigWithSecrets({ config: loadedRaw, commandName: "message", targetIds: scopedTargets.targetIds, ...(scopedTargets.allowedPaths ? { allowedPaths: scopedTargets.allowedPaths } : {}), + runtime, + autoEnable: true, }); - const cfg = applyPluginAutoEnable({ - config: resolvedConfig, - env: process.env, - }).config; - for (const entry of diagnostics) { - runtime.log(`[secrets] ${entry}`); - } const rawAction = typeof opts.action === "string" ? opts.action.trim() : ""; const actionInput = rawAction || "send"; const actionMatch = (CHANNEL_MESSAGE_ACTION_NAMES as readonly string[]).find( diff --git a/src/commands/models/load-config.ts b/src/commands/models/load-config.ts index a565bceb5e8..b60cc1687a3 100644 --- a/src/commands/models/load-config.ts +++ b/src/commands/models/load-config.ts @@ -1,3 +1,4 @@ +import { resolveCommandConfigWithSecrets } from "../../cli/command-config-resolution.js"; import type { RuntimeEnv } from "../../runtime.js"; import { getRuntimeConfig, @@ -5,7 +6,6 @@ import { setRuntimeConfigSnapshot, type OpenClawConfig, getModelsCommandSecretTargetIds, - resolveCommandSecretRefsViaGateway, } from "./load-config.runtime.js"; export type LoadedModelsConfig = { @@ -32,16 +32,12 @@ export async function loadModelsConfigWithSource(params: { }): Promise { const runtimeConfig = getRuntimeConfig(); const sourceConfig = await loadSourceConfigSnapshot(runtimeConfig); - const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({ + const { resolvedConfig, diagnostics } = await resolveCommandConfigWithSecrets({ config: runtimeConfig, commandName: params.commandName, targetIds: getModelsCommandSecretTargetIds(), + runtime: params.runtime, }); - if (params.runtime) { - for (const entry of diagnostics) { - params.runtime.log(`[secrets] ${entry}`); - } - } setRuntimeConfigSnapshot(resolvedConfig, sourceConfig); return { sourceConfig,