feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -24,6 +24,7 @@ import tech.easyflow.common.ai.plugin.PluginParamConverter;
import tech.easyflow.common.filestorage.FileStorageManager;
import tech.easyflow.common.filestorage.FileStorageService;
import tech.easyflow.common.util.SpringContextUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import com.easyagents.flow.core.util.IoBulkhead;
import java.io.*;
@@ -32,6 +33,8 @@ import java.math.BigInteger;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class PluginTool extends BaseTool {
@@ -43,6 +46,8 @@ public class PluginTool extends BaseTool {
private transient PluginItem pluginItemSnapshot;
private transient Plugin pluginSnapshot;
private static final Logger logger = LoggerFactory.getLogger(PluginTool.class);
private static final Pattern INPUT_REFERENCE =
Pattern.compile("^\\$\\{input:([A-Za-z0-9_.-]+)}$");
public PluginTool() {
@@ -175,18 +180,18 @@ public class PluginTool extends BaseTool {
List<Map<String, Object>> headers = getDataList(plugin.getHeaders());
Map<String, Object> headersMap = new HashMap<>();
for (Map<String, Object> header : headers) {
headersMap.put((String) header.get("label"), header.get("value"));
headersMap.put((String) header.get("label"), resolveInputReference(header.get("value")));
}
List<PluginParam> params = new ArrayList<>();
String authType = plugin.getAuthType();
if (!StrUtil.isEmpty(authType) && "apiKey".equals(plugin.getAuthType())){
if ("headers".equals(plugin.getPosition())){
headersMap.put(plugin.getTokenKey(), plugin.getTokenValue());
headersMap.put(plugin.getTokenKey(), resolveInputReference(plugin.getTokenValue()));
} else {
PluginParam pluginParam = new PluginParam();
pluginParam.setName(plugin.getTokenKey());
pluginParam.setDefaultValue(plugin.getTokenValue());
pluginParam.setDefaultValue(resolveInputReference(plugin.getTokenValue()));
pluginParam.setEnabled(true);
pluginParam.setRequired(true);
pluginParam.setMethod("query");
@@ -407,6 +412,29 @@ public class PluginTool extends BaseTool {
return true;
}
/**
* 在实际调用前解析服务端插件输入引用,避免发布快照持久化明文凭据。
*
* @param rawValue 快照中的字段值
* @return 原值或服务端解析后的凭据
* @throws BusinessException 引用未配置时抛出
*/
private Object resolveInputReference(Object rawValue) {
if (!(rawValue instanceof String text)) {
return rawValue;
}
Matcher matcher = INPUT_REFERENCE.matcher(text.trim());
if (!matcher.matches()) {
return rawValue;
}
String key = matcher.group(1);
String value = System.getProperty("plugin.input." + key);
if (value == null || value.isBlank()) {
throw new BusinessException("插件输入变量未解析:" + key);
}
return value;
}
private void processParamWithChildren(Map<String, Object> paramDef, Map<String, Object> argsMap, List<PluginParam> params) {
boolean enabled = (boolean) paramDef.get("enabled");
if (!enabled){

View File

@@ -11,6 +11,7 @@ import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditEvent;
import tech.easyflow.ai.easyagentsflow.event.WorkflowExecutionAuditProducer;
import tech.easyflow.ai.easyagentsflow.repository.FrozenWorkflowDefinitionRegistry;
import tech.easyflow.ai.easyagentsflow.support.PublishedWorkflowDefinitionIds;
import tech.easyflow.ai.easyagentsflow.support.WorkflowExecutionStepKey;
import tech.easyflow.ai.entity.Workflow;
@@ -35,6 +36,8 @@ public class ChainEventListenerForSave implements ChainEventListener {
private WorkflowExecResultService workflowExecResultService;
@Resource
private WorkflowExecutionAuditProducer auditProducer;
@Resource
private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry;
@Override
public void onEvent(Event event, Chain chain) {
@@ -245,6 +248,9 @@ public class ChainEventListenerForSave implements ChainEventListener {
return null;
}
String definitionId = definition.getId();
if (frozenWorkflowDefinitionRegistry.isFrozen(definitionId)) {
return frozenWorkflowDefinitionRegistry.getWorkflow(definitionId);
}
String workflowId = PublishedWorkflowDefinitionIds.unwrap(definitionId);
try {
java.math.BigInteger id = new java.math.BigInteger(workflowId);

View File

@@ -0,0 +1,98 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.WorkflowNode;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 构建并校验 Agent 与 Skill 使用的 Workflow 冻结快照。
*
* <p>Agent Tool 当前按同步调用执行,因此发布投影只能接受不会依赖子工作流热读、
* 也不会在内部等待人工确认的定义。独立 Workflow 的发布能力不受此组件限制。</p>
*/
@Component
public class AgentWorkflowSnapshotFactory {
private final ChainParser chainParser;
private final WorkflowDatacenterContentService contentService;
/**
* 创建 Workflow 冻结快照工厂。
*
* @param chainParser 工作流定义解析器
* @param contentService 数据中枢内容准备服务
*/
public AgentWorkflowSnapshotFactory(ChainParser chainParser,
WorkflowDatacenterContentService contentService) {
this.chainParser = chainParser;
this.contentService = contentService;
}
/**
* 编译并校验一份 Agent 可执行的 Workflow 定义。
*
* @param workflow 包含完整 content 的 Workflow
* @return 已准备内容和解析后的定义
* @throws BusinessException 快照不完整或包含同步 Tool 不支持的节点时抛出
*/
public PreparedWorkflow prepare(Workflow workflow) {
if (workflow == null || workflow.getId() == null
|| workflow.getContent() == null || workflow.getContent().isBlank()) {
throw new BusinessException(409, 4092, "绑定工作流快照不完整,请重新发布工作流");
}
String preparedContent = contentService.prepareContent(workflow.getContent());
ChainDefinition definition;
try {
definition = chainParser.parse(preparedContent);
} catch (BusinessException exception) {
throw exception;
} catch (RuntimeException exception) {
throw new BusinessException(409, 4092, "绑定工作流定义无效,请修复后重新发布", exception);
}
if (definition.getNodes() != null
&& definition.getNodes().stream().anyMatch(WorkflowNode.class::isInstance)) {
throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持子工作流节点");
}
if (definition.getNodes() != null
&& definition.getNodes().stream().anyMatch(ConfirmNode.class::isInstance)) {
throw new BusinessException(409, 4092, "Agent 或 Skill 绑定的工作流暂不支持内部确认节点");
}
return new PreparedWorkflow(preparedContent, definition);
}
/**
* 构建字段白名单 Workflow 冻结快照。
*
* @param workflow 已发布 Workflow
* @return 仅包含 Runtime 所需字段的快照
* @throws BusinessException Workflow 不兼容同步 Agent Tool 时抛出
*/
public Map<String, Object> snapshot(Workflow workflow) {
PreparedWorkflow prepared = prepare(workflow);
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", workflow.getId());
snapshot.put("title", workflow.getTitle());
snapshot.put("description", workflow.getDescription());
snapshot.put("englishName", workflow.getEnglishName());
snapshot.put("revision", workflow.getRevision());
snapshot.put("content", prepared.content());
return snapshot;
}
/**
* Agent 可执行 Workflow 的准备结果。
*
* @param content 已完成服务端占位处理的定义内容
* @param definition 解析后的工作流定义
*/
public record PreparedWorkflow(String content, ChainDefinition definition) {
}
}

View File

@@ -22,9 +22,18 @@ public class ChainDefinitionRepositoryImpl implements ChainDefinitionRepository
private WorkflowDatacenterContentService workflowDatacenterContentService;
@Resource
private WorkflowDefinitionCache workflowDefinitionCache;
@Resource
private FrozenWorkflowDefinitionRegistry frozenWorkflowDefinitionRegistry;
@Override
public ChainDefinition getChainDefinitionById(String id) {
ChainDefinition frozen = frozenWorkflowDefinitionRegistry.get(id);
if (frozen != null) {
return frozen;
}
if (frozenWorkflowDefinitionRegistry.isFrozen(id)) {
throw new IllegalStateException("Frozen workflow definition is not registered: " + id);
}
return workflowDefinitionCache.get(id, () -> loadAndCompile(id));
}

View File

@@ -0,0 +1,114 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Workflow;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 当前进程内的已发布 Agent 工作流冻结定义注册表。
*
* <p>正式 Agent 编译时使用发布快照中的工作流内容生成内容寻址定义,执行阶段只读取该定义,
* 不再按工作流 ID 回查当前发布版本。注册表按访问顺序有界保留Agent 再次编译时可无损重建。</p>
*/
@Component
public class FrozenWorkflowDefinitionRegistry {
private static final String PREFIX = "agent-frozen:";
private static final int MAX_ENTRIES = 512;
private final AgentWorkflowSnapshotFactory snapshotFactory;
private final Map<String, ChainDefinition> definitions =
new LinkedHashMap<>(32, 0.75F, true);
private final Map<String, Workflow> workflows =
new LinkedHashMap<>(32, 0.75F, true);
/**
* 创建冻结定义注册表。
*
* @param snapshotFactory Agent Workflow 冻结快照工厂
*/
public FrozenWorkflowDefinitionRegistry(AgentWorkflowSnapshotFactory snapshotFactory) {
this.snapshotFactory = snapshotFactory;
}
/**
* 注册一份工作流快照并返回内容寻址定义 ID。
*
* @param workflow 包含完整 content 的工作流快照
* @return 冻结定义 ID
* @throws tech.easyflow.common.web.exceptions.BusinessException 工作流快照不完整或不兼容时抛出
*/
public String register(Workflow workflow) {
AgentWorkflowSnapshotFactory.PreparedWorkflow prepared = snapshotFactory.prepare(workflow);
String preparedContent = prepared.content();
String id = PREFIX + workflow.getId() + ":" + sha256(preparedContent);
synchronized (definitions) {
if (definitions.containsKey(id)) {
definitions.get(id);
return id;
}
ChainDefinition definition = prepared.definition();
definition.setId(id);
definition.setName(workflow.getEnglishName());
definition.setDescription(workflow.getDescription());
definitions.put(id, definition);
workflows.put(id, workflow);
while (definitions.size() > MAX_ENTRIES) {
String eldest = definitions.keySet().iterator().next();
definitions.remove(eldest);
workflows.remove(eldest);
}
}
return id;
}
/**
* 获取已注册冻结定义。
*
* @param definitionId 定义 ID
* @return 冻结定义;不存在时返回 null
*/
public ChainDefinition get(String definitionId) {
synchronized (definitions) {
return definitions.get(definitionId);
}
}
/**
* 获取冻结定义对应的工作流快照,用于执行审计展示。
*
* @param definitionId 冻结定义 ID
* @return 工作流快照;不存在时返回 null
*/
public Workflow getWorkflow(String definitionId) {
synchronized (definitions) {
return workflows.get(definitionId);
}
}
/**
* 判断定义 ID 是否属于冻结 Agent 工作流。
*
* @param definitionId 定义 ID
* @return 是否冻结定义
*/
public boolean isFrozen(String definitionId) {
return definitionId != null && definitionId.startsWith(PREFIX);
}
private String sha256(String content) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(content.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}

View File

@@ -0,0 +1,192 @@
package tech.easyflow.ai.mcp;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 构建可持久化的 MCP 受控连接快照。
*
* <p>连接拓扑可冻结,凭据值只能使用 {@code ${input:key}} 服务端引用,
* 避免发布与审批快照复制已经解析的令牌、Header 或查询参数。</p>
*/
@Component
public class McpConnectionSnapshotFactory {
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
private static final Pattern INPUT_REFERENCE =
Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$");
private static final Pattern SENSITIVE_NAME = Pattern.compile(
".*(token|secret|password|passwd|api[_-]?key|authorization|cookie|credential|private[_-]?key).*",
Pattern.CASE_INSENSITIVE);
private static final Set<String> PUBLIC_HEADERS = Set.of(
"accept", "accept-language", "content-type", "user-agent");
private final ObjectMapper objectMapper;
/**
* 创建快照工厂。
*
* @param objectMapper JSON 映射器
*/
public McpConnectionSnapshotFactory(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 构建字段白名单 MCP 连接快照并校验凭据引用。
*
* @param mcp MCP 资源
* @return 仅供服务端 Runtime 使用的连接快照
* @throws BusinessException 配置包含明文凭据或格式无效时抛出
*/
public Map<String, Object> snapshot(Mcp mcp) {
if (mcp == null || mcp.getId() == null) {
throw new BusinessException("MCP 资源不能为空");
}
validateCredentialReferences(mcp.getConfigJson());
Map<String, Object> snapshot = new LinkedHashMap<>();
snapshot.put("id", mcp.getId());
snapshot.put("title", mcp.getTitle());
snapshot.put("description", mcp.getDescription());
snapshot.put("transportType", mcp.getTransportType());
snapshot.put("approvalRequired", Boolean.TRUE.equals(mcp.getApprovalRequired()));
snapshot.put("configJson", mcp.getConfigJson());
snapshot.put("configHash", sha256(mcp.getConfigJson()));
return snapshot;
}
private void validateCredentialReferences(String configJson) {
if (configJson == null || configJson.isBlank()) {
throw new BusinessException("MCP 配置 JSON 不能为空");
}
Map<String, Object> config;
try {
config = objectMapper.readValue(configJson, MAP_TYPE);
} catch (Exception exception) {
throw new BusinessException("MCP 配置 JSON 格式错误");
}
Map<String, Object> servers = map(config.get("mcpServers"), "mcpServers");
for (Map.Entry<String, Object> entry : servers.entrySet()) {
Map<String, Object> server = map(entry.getValue(), "MCP 服务 " + entry.getKey());
validateMap(server.get("headers"), "headers", true);
validateMap(server.get("queryParams"), "queryParams", true);
validateMap(server.get("env"), "env", false);
validateUrl(server.get("url"));
validateArgs(server.get("args"));
validateNestedSensitiveValues(server, "mcpServers." + entry.getKey());
}
}
private void validateMap(Object value, String field, boolean requireReferenceForAll) {
if (value == null) {
return;
}
Map<String, Object> values = map(value, field);
for (Map.Entry<String, Object> entry : values.entrySet()) {
String key = entry.getKey();
String text = entry.getValue() == null ? "" : String.valueOf(entry.getValue()).trim();
boolean publicHeader = "headers".equals(field)
&& PUBLIC_HEADERS.contains(key.toLowerCase(Locale.ROOT));
boolean requiresReference = (requireReferenceForAll && !publicHeader)
|| SENSITIVE_NAME.matcher(key).matches();
if (requiresReference && !text.isEmpty() && !INPUT_REFERENCE.matcher(text).matches()) {
throw new BusinessException("MCP " + field + " 中的凭据必须使用 ${input:key} 引用:" + key);
}
}
}
private void validateUrl(Object value) {
if (value == null) {
return;
}
String lower = String.valueOf(value).toLowerCase(Locale.ROOT);
if (lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&$][^&]*.*")
|| lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*")) {
throw new BusinessException("MCP URL 不能包含明文凭据,请使用 ${input:key} 引用");
}
}
private void validateArgs(Object value) {
if (!(value instanceof List<?> args)) {
return;
}
for (int index = 0; index < args.size(); index++) {
Object raw = args.get(index);
String arg = raw == null ? "" : String.valueOf(raw);
if (!SENSITIVE_NAME.matcher(arg).matches()) {
continue;
}
int separator = arg.indexOf('=');
if (separator >= 0 && INPUT_REFERENCE.matcher(arg.substring(separator + 1).trim()).matches()) {
continue;
}
if (separator < 0 && index + 1 < args.size()
&& INPUT_REFERENCE.matcher(String.valueOf(args.get(index + 1)).trim()).matches()) {
index++;
continue;
}
throw new BusinessException("MCP 启动参数不能包含明文凭据,请使用 ${input:key} 引用");
}
}
/**
* 递归检查扩展配置,防止未知或嵌套敏感字段绕过固定字段校验。
*
* @param value 当前配置值
* @param path 配置路径
*/
private void validateNestedSensitiveValues(Object value, String path) {
if (value instanceof Map<?, ?> values) {
for (Map.Entry<?, ?> entry : values.entrySet()) {
String key = String.valueOf(entry.getKey());
Object nested = entry.getValue();
String nestedPath = path + "." + key;
if (SENSITIVE_NAME.matcher(key).matches()
&& (nested == null || !INPUT_REFERENCE.matcher(String.valueOf(nested).trim()).matches())) {
throw new BusinessException("MCP 敏感配置必须使用 ${input:key} 引用:" + nestedPath);
}
if (!SENSITIVE_NAME.matcher(key).matches()) {
validateNestedSensitiveValues(nested, nestedPath);
}
}
return;
}
if (value instanceof List<?> values) {
for (int index = 0; index < values.size(); index++) {
validateNestedSensitiveValues(values.get(index), path + "[" + index + "]");
}
}
}
private Map<String, Object> map(Object value, String field) {
if (!(value instanceof Map<?, ?> raw)) {
throw new BusinessException("MCP 配置字段必须是对象:" + field);
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
private String sha256(String value) {
try {
return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256")
.digest(value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 is unavailable", exception);
}
}
}

View File

@@ -0,0 +1,278 @@
package tech.easyflow.ai.mcp;
import com.easyagents.agent.runtime.mcp.McpSpec;
import com.easyagents.agent.runtime.mcp.McpTransportType;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 将 EasyFlow MCP 配置映射为无业务状态的运行时连接声明。
*/
@Component
public class McpRuntimeSpecFactory {
private static final Pattern INPUT_PATTERN = Pattern.compile("\\$\\{input:([A-Za-z0-9_.-]+)}");
private static final TypeReference<Map<String, Object>> MAP_TYPE = new TypeReference<>() { };
private final ObjectMapper objectMapper;
/**
* 创建 MCP 运行声明工厂。
*
* @param objectMapper JSON 映射器
*/
public McpRuntimeSpecFactory(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 构建 MCP 运行连接声明。
*
* @param mcp MCP 资源
* @param requireUniqueServer 是否要求配置中只有一个服务
* @return MCP 运行连接声明
* @throws BusinessException 配置为空、格式错误、多服务或输入变量未解析时抛出
*/
public McpSpec build(Mcp mcp, boolean requireUniqueServer) {
if (mcp == null || mcp.getId() == null) {
throw new BusinessException("MCP 资源不能为空");
}
Map<String, Object> config = parseConfig(mcp.getConfigJson());
Map<String, Object> servers = mapValue(config, "mcpServers");
if (servers.isEmpty()) {
throw new BusinessException("MCP 配置 JSON 中没有找到任何 MCP 服务");
}
if (requireUniqueServer && servers.size() != 1) {
throw new BusinessException(409, 4092, "MCP 配置必须且只能包含一个服务,请拆分后重试");
}
Map.Entry<String, Object> server = servers.entrySet().iterator().next();
if (!(server.getValue() instanceof Map<?, ?> rawServer)) {
throw new BusinessException("MCP 服务配置必须是对象:" + server.getKey());
}
Map<String, Object> serverConfig = new LinkedHashMap<>();
rawServer.forEach((key, value) -> serverConfig.put(String.valueOf(key), value));
McpSpec spec = new McpSpec();
spec.setName("mcp_" + safeSegment(mcp.getId().toString()));
spec.setDescription(firstNonBlank(mcp.getDescription(), mcp.getTitle()));
spec.setTransportType(McpTransportType.from(firstNonBlank(
mcp.getTransportType(), stringValue(serverConfig, "transport", null))));
spec.setCommand(resolveInput(stringValue(serverConfig, "command", null)));
spec.setArgs(resolveInputs(stringListValue(serverConfig, "args")));
spec.setEnv(resolveInputMap(stringMapValue(serverConfig, "env")));
spec.setUrl(resolveInput(stringValue(serverConfig, "url", null)));
spec.setHeaders(resolveInputMap(stringMapValue(serverConfig, "headers")));
spec.setQueryParams(resolveInputMap(stringMapValue(serverConfig, "queryParams")));
Duration timeout = durationValue(serverConfig, "timeout");
if (timeout != null) {
spec.setTimeout(timeout);
}
Duration initializationTimeout = durationValue(serverConfig, "initializationTimeout");
if (initializationTimeout != null) {
spec.setInitializationTimeout(initializationTimeout);
}
spec.getMetadata().put("mcpId", mcp.getId().toString());
spec.getMetadata().put("mcpTitle", mcp.getTitle());
spec.getMetadata().put("serverName", server.getKey());
return spec;
}
/**
* 解析 MCP JSON。
*
* @param configJson MCP JSON
* @return 配置 Map
*/
private Map<String, Object> parseConfig(String configJson) {
if (configJson == null || configJson.isBlank()) {
throw new BusinessException("MCP 配置 JSON 不能为空");
}
try {
return objectMapper.readValue(configJson, MAP_TYPE);
} catch (Exception exception) {
throw new BusinessException("MCP 配置 JSON 格式错误");
}
}
/**
* 读取对象字段。
*
* @param source 配置 Map
* @param key 字段名
* @return 对象 Map
*/
private Map<String, Object> mapValue(Map<String, Object> source, String key) {
Object value = source == null ? null : source.get(key);
if (value == null) {
return new LinkedHashMap<>();
}
if (!(value instanceof Map<?, ?> raw)) {
throw new BusinessException("MCP 配置字段必须是对象:" + key);
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((rawKey, rawValue) -> result.put(String.valueOf(rawKey), rawValue));
return result;
}
/**
* 读取字符串数组字段。
*
* @param source 配置 Map
* @param key 字段名
* @return 字符串数组
*/
private List<String> stringListValue(Map<String, Object> source, String key) {
Object value = source == null ? null : source.get(key);
if (value == null) {
return new ArrayList<>();
}
if (!(value instanceof Collection<?> collection)) {
throw new BusinessException("MCP 配置字段必须是数组:" + key);
}
List<String> result = new ArrayList<>();
collection.stream().filter(item -> item != null).forEach(item -> result.add(String.valueOf(item)));
return result;
}
/**
* 读取字符串 Map 字段。
*
* @param source 配置 Map
* @param key 字段名
* @return 字符串 Map
*/
private Map<String, String> stringMapValue(Map<String, Object> source, String key) {
Map<String, Object> raw = mapValue(source, key);
Map<String, String> result = new LinkedHashMap<>();
raw.forEach((name, value) -> {
if (value != null) {
result.put(name, String.valueOf(value));
}
});
return result;
}
/**
* 读取字符串字段。
*
* @param source 配置 Map
* @param key 字段名
* @param fallback 默认值
* @return 字符串值
*/
private String stringValue(Map<String, Object> source, String key, String fallback) {
Object value = source == null ? null : source.get(key);
if (value == null || String.valueOf(value).isBlank()) {
return fallback;
}
return String.valueOf(value);
}
/**
* 读取秒数或 ISO-8601 Duration。
*
* @param source 配置 Map
* @param key 字段名
* @return Duration 或 null
*/
private Duration durationValue(Map<String, Object> source, String key) {
Object value = source == null ? null : source.get(key);
if (value == null || String.valueOf(value).isBlank()) {
return null;
}
if (value instanceof Number number) {
return Duration.ofSeconds(number.longValue());
}
try {
return Duration.parse(String.valueOf(value).trim());
} catch (Exception ignored) {
try {
return Duration.ofSeconds(Long.parseLong(String.valueOf(value).trim()));
} catch (NumberFormatException exception) {
throw new BusinessException("MCP 配置字段必须是秒数或 Duration" + key);
}
}
}
/**
* 解析数组中的 MCP 输入变量。
*
* @param values 原值
* @return 已解析值
*/
private List<String> resolveInputs(List<String> values) {
List<String> result = new ArrayList<>();
values.forEach(value -> result.add(resolveInput(value)));
return result;
}
/**
* 解析 Map 中的 MCP 输入变量。
*
* @param values 原值
* @return 已解析值
*/
private Map<String, String> resolveInputMap(Map<String, String> values) {
Map<String, String> result = new LinkedHashMap<>();
values.forEach((key, value) -> result.put(key, resolveInput(value)));
return result;
}
/**
* 从系统属性解析 MCP 输入变量。
*
* @param value 原值
* @return 已解析值
*/
private String resolveInput(String value) {
if (value == null || value.isBlank()) {
return value;
}
Matcher matcher = INPUT_PATTERN.matcher(value);
StringBuffer result = new StringBuffer();
while (matcher.find()) {
String key = matcher.group(1);
String replacement = System.getProperty("mcp.input." + key);
if (replacement == null || replacement.isBlank()) {
throw new BusinessException("MCP 输入变量未解析:" + key);
}
matcher.appendReplacement(result, Matcher.quoteReplacement(replacement));
}
matcher.appendTail(result);
return result.toString();
}
/**
* 生成安全名称片段。
*
* @param value 原值
* @return 安全片段
*/
private String safeSegment(String value) {
String normalized = value.trim().replaceAll("[^A-Za-z0-9_-]", "_").replaceAll("_+", "_");
return normalized.isBlank() ? "resource" : normalized;
}
/**
* 获取首个非空文本。
*
* @param first 首选值
* @param second 备选值
* @return 非空文本
*/
private String firstNonBlank(String first, String second) {
return first == null || first.isBlank() ? second : first;
}
}

View File

@@ -0,0 +1,111 @@
package tech.easyflow.ai.plugin;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 构建可持久化的插件受控连接快照。
*/
@Component
public class PluginConnectionSnapshotFactory {
private static final TypeReference<List<Map<String, Object>>> HEADER_LIST_TYPE = new TypeReference<>() { };
private static final Pattern INPUT_REFERENCE =
Pattern.compile("^\\$\\{input:[A-Za-z0-9_.-]+}$");
private static final Set<String> PUBLIC_HEADERS = Set.of(
"accept", "accept-language", "content-type", "user-agent");
private final ObjectMapper objectMapper;
/**
* 创建插件连接快照工厂。
*
* @param objectMapper JSON 映射器
*/
public PluginConnectionSnapshotFactory(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
/**
* 构建字段白名单快照,并拒绝把插件凭据明文复制到发布快照。
*
* @param plugin 插件资源
* @return 服务端 Runtime 使用的连接快照
* @throws BusinessException 配置缺失或包含明文凭据时抛出
*/
public Map<String, Object> snapshot(Plugin plugin) {
if (plugin == null || plugin.getId() == null) {
throw new BusinessException("插件资源不能为空");
}
validateBaseUrl(plugin.getBaseUrl());
validateHeaders(plugin.getHeaders());
if ("apiKey".equalsIgnoreCase(plugin.getAuthType())
&& !isInputReference(plugin.getTokenValue())) {
throw new BusinessException("插件鉴权值必须使用 ${input:key} 引用");
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("id", plugin.getId());
result.put("alias", plugin.getAlias());
result.put("name", plugin.getName());
result.put("description", plugin.getDescription());
result.put("baseUrl", plugin.getBaseUrl());
result.put("authType", plugin.getAuthType());
result.put("position", plugin.getPosition());
result.put("headers", plugin.getHeaders());
result.put("tokenKey", plugin.getTokenKey());
result.put("tokenValue", plugin.getTokenValue());
return result;
}
private void validateBaseUrl(String value) {
if (value == null || value.isBlank()) {
throw new BusinessException("插件基础地址不能为空");
}
String lower = value.toLowerCase(Locale.ROOT);
if (lower.matches("^[a-z][a-z0-9+.-]*://[^/@]+:[^/@]+@.*")
|| lower.matches(".*[?&](token|secret|password|api[_-]?key|authorization)=[^&]+.*")) {
throw new BusinessException("插件基础地址不能包含明文凭据");
}
}
private void validateHeaders(String headersJson) {
if (headersJson == null || headersJson.isBlank()) {
return;
}
List<Map<String, Object>> headers;
try {
headers = objectMapper.readValue(headersJson, HEADER_LIST_TYPE);
} catch (Exception exception) {
throw new BusinessException("插件请求头格式错误");
}
for (Map<String, Object> header : headers) {
String name = text(header.get("label"));
String value = text(header.get("value"));
if (name == null || name.isBlank()) {
throw new BusinessException("插件请求头名称不能为空");
}
if (!PUBLIC_HEADERS.contains(name.toLowerCase(Locale.ROOT))
&& value != null && !value.isBlank() && !isInputReference(value)) {
throw new BusinessException("插件请求头凭据必须使用 ${input:key} 引用:" + name);
}
}
}
private boolean isInputReference(String value) {
return value != null && INPUT_REFERENCE.matcher(value.trim()).matches();
}
private String text(Object value) {
return value == null ? null : String.valueOf(value).trim();
}
}

View File

@@ -210,6 +210,14 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
protected void afterOffline(BigInteger resourceId) {
}
/**
* 下线真正生效前的二次引用检查钩子。
*
* @param resourceId 资源 ID
*/
protected void beforeOffline(BigInteger resourceId) {
}
/**
* 删除成功前的额外副作用。
*
@@ -344,6 +352,7 @@ public abstract class AbstractAiResourceLifecycleHandler<T> implements ApprovalS
return;
}
if (normalizedAction == ApprovalActionType.OFFLINE) {
beforeOffline(resourceId);
markResourceOffline(resourceId);
afterOffline(resourceId);
return;

View File

@@ -7,6 +7,7 @@ import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider;
import tech.easyflow.ai.vo.OfflineImpactCheckVo;
@@ -34,6 +35,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
private final ResourceOfflineImpactService resourceOfflineImpactService;
private final WorkflowPluginBindingService workflowPluginBindingService;
private final WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver;
private final AgentResourceReferenceService agentResourceReferenceService;
private final List<WorkflowScheduleReferenceProvider> workflowScheduleReferenceProviders;
public WorkflowApprovalSubjectHandler(WorkflowService workflowService,
@@ -42,6 +44,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
ResourceOfflineImpactService resourceOfflineImpactService,
WorkflowPluginBindingService workflowPluginBindingService,
WorkflowPluginSnapshotResolver workflowPluginSnapshotResolver,
AgentResourceReferenceService agentResourceReferenceService,
ObjectMapper objectMapper,
List<WorkflowScheduleReferenceProvider> workflowScheduleReferenceProviders) {
super(approvalInstanceService, objectMapper);
@@ -50,6 +53,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
this.resourceOfflineImpactService = resourceOfflineImpactService;
this.workflowPluginBindingService = workflowPluginBindingService;
this.workflowPluginSnapshotResolver = workflowPluginSnapshotResolver;
this.agentResourceReferenceService = agentResourceReferenceService;
this.workflowScheduleReferenceProviders = workflowScheduleReferenceProviders == null
? List.of()
: List.copyOf(workflowScheduleReferenceProviders);
@@ -186,14 +190,12 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
if (impact.isHasPluginBindings()) {
snapshot.put("pluginBindings", impact.getPluginBindings());
}
agentResourceReferenceService.assertWorkflowUnused(resource.getId());
}
@Override
protected void validateDelete(Workflow resource, PublishStatus currentStatus) {
OfflineImpactCheckVo impact = resourceOfflineImpactService.checkWorkflowImpact(resource.getId());
if (impact.isHasAgentBindings()) {
throw new BusinessException("此工作流仍被智能体使用,请先取消绑定后再删除");
}
agentResourceReferenceService.assertWorkflowUnused(resource.getId());
OfflineImpactBindingVo scheduledJob = findFirstScheduledJobReference(resource.getId());
if (scheduledJob != null) {
String jobName = scheduledJob.getTitle() == null ? "未命名任务" : scheduledJob.getTitle();
@@ -235,7 +237,7 @@ public class WorkflowApprovalSubjectHandler extends AbstractAiResourceLifecycleH
}
@Override
protected void afterOffline(BigInteger resourceId) {
resourceOfflineImpactService.unbindWorkflowFromAgents(resourceId);
protected void beforeOffline(BigInteger resourceId) {
agentResourceReferenceService.assertWorkflowUnused(resourceId);
}
}

View File

@@ -19,6 +19,21 @@ public interface AgentResourceReferenceService {
*/
List<OfflineImpactBindingVo> listAgentsByWorkflowId(BigInteger workflowId);
/**
* 查询引用指定工作流的 Skill。
*
* @param workflowId 工作流 ID
* @return Skill 摘要列表
*/
List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId);
/**
* 校验工作流没有被 Agent、Skill 草稿或有效发布快照引用。
*
* @param workflowId 工作流 ID
*/
void assertWorkflowUnused(BigInteger workflowId);
/**
* 查询引用指定知识库的 Agent。
*

View File

@@ -0,0 +1,21 @@
package tech.easyflow.ai.service;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import java.math.BigInteger;
import java.util.List;
/**
* Skill 对平台 Tool 资源引用的模块扩展点。
*/
public interface SkillToolReferenceProvider {
/** @param workflowId 工作流 ID @return 引用该工作流的 Skill 摘要 */
List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId);
/** @param pluginItemId 插件工具 ID @return 引用该插件工具的 Skill 摘要 */
List<OfflineImpactBindingVo> listSkillsByPluginItemId(BigInteger pluginItemId);
/** @param mcpId MCP ID @return 引用该 MCP 的 Skill 摘要 */
List<OfflineImpactBindingVo> listSkillsByMcpId(BigInteger mcpId);
}

View File

@@ -1,8 +1,10 @@
package tech.easyflow.ai.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.ObjectProvider;
import tech.easyflow.ai.service.AgentResourceBindingProvider;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.SkillToolReferenceProvider;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
import tech.easyflow.common.web.exceptions.BusinessException;
@@ -20,15 +22,19 @@ import java.util.function.Function;
@Service
public class AgentResourceReferenceServiceImpl implements AgentResourceReferenceService {
private final List<AgentResourceBindingProvider> providers;
private final ObjectProvider<AgentResourceBindingProvider> providers;
private final ObjectProvider<SkillToolReferenceProvider> skillProviders;
/**
* 创建 Agent 资源引用查询服务。
*
* @param providers Agent 资源绑定提供者
* @param skillProviders Skill Tool 引用提供者
*/
public AgentResourceReferenceServiceImpl(List<AgentResourceBindingProvider> providers) {
this.providers = providers == null ? List.of() : List.copyOf(providers);
public AgentResourceReferenceServiceImpl(ObjectProvider<AgentResourceBindingProvider> providers,
ObjectProvider<SkillToolReferenceProvider> skillProviders) {
this.providers = providers;
this.skillProviders = skillProviders;
}
/**
@@ -39,6 +45,19 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
return merge(provider -> provider.listAgentsByWorkflowId(workflowId));
}
/** {@inheritDoc} */
@Override
public List<OfflineImpactBindingVo> listSkillsByWorkflowId(BigInteger workflowId) {
return mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId));
}
/** {@inheritDoc} */
@Override
public void assertWorkflowUnused(BigInteger workflowId) {
assertUnused(merge(provider -> provider.listAgentsByWorkflowId(workflowId)), "工作流");
assertUnused(mergeSkills(provider -> provider.listSkillsByWorkflowId(workflowId)), "工作流");
}
/**
* {@inheritDoc}
*/
@@ -60,6 +79,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
merge(provider -> provider.listAgentsByPluginItemId(pluginItemId)),
"插件工具"
);
assertUnused(mergeSkills(provider -> provider.listSkillsByPluginItemId(pluginItemId)), "插件工具");
}
}
@@ -69,6 +89,7 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
@Override
public void assertMcpUnused(BigInteger mcpId) {
assertUnused(merge(provider -> provider.listAgentsByMcpId(mcpId)), "MCP");
assertUnused(mergeSkills(provider -> provider.listSkillsByMcpId(mcpId)), "MCP");
}
/**
@@ -127,6 +148,23 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
return new ArrayList<>(merged.values());
}
private List<OfflineImpactBindingVo> mergeSkills(
Function<SkillToolReferenceProvider, List<OfflineImpactBindingVo>> loader) {
Map<BigInteger, OfflineImpactBindingVo> merged = new LinkedHashMap<>();
for (SkillToolReferenceProvider provider : skillProviders.orderedStream().toList()) {
List<OfflineImpactBindingVo> bindings = loader.apply(provider);
if (bindings == null) {
continue;
}
for (OfflineImpactBindingVo binding : bindings) {
if (binding != null && binding.getId() != null) {
merged.putIfAbsent(binding.getId(), binding);
}
}
}
return new ArrayList<>(merged.values());
}
/**
* 校验资源未被任何 Agent 引用。
*
@@ -139,8 +177,8 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
}
String agentTitle = bindings.get(0).getTitle();
throw new BusinessException(
resourceLabel + "仍被智能体“" + (agentTitle == null ? "未命名智能体" : agentTitle)
+ "使用,请先取消绑定或重新发布智能体后再删除"
resourceLabel + "仍被" + (agentTitle == null ? "其他资源" : agentTitle)
+ "使用,请先取消绑定或重新发布后再操作"
);
}
@@ -150,9 +188,12 @@ public class AgentResourceReferenceServiceImpl implements AgentResourceReference
* @return Agent 资源绑定提供者
*/
private List<AgentResourceBindingProvider> requireProviders() {
if (providers.isEmpty()) {
List<AgentResourceBindingProvider> resolvedProviders = providers == null
? List.of()
: providers.orderedStream().toList();
if (resolvedProviders.isEmpty()) {
throw new BusinessException("Agent 资源引用检查服务不可用,请稍后重试");
}
return providers;
return resolvedProviders;
}
}

View File

@@ -59,17 +59,21 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
@Override
public OfflineImpactCheckVo checkWorkflowImpact(BigInteger workflowId) {
List<OfflineImpactBindingVo> agentBindings = listAgentsByWorkflowId(workflowId);
List<OfflineImpactBindingVo> skillBindings =
agentResourceReferenceService.listSkillsByWorkflowId(workflowId);
List<OfflineImpactBindingVo> pluginBindings =
workflowPluginDependencyService.listPluginsByWorkflowId(workflowId);
OfflineImpactCheckVo result = new OfflineImpactCheckVo();
result.setCanProceed(true);
result.setCanProceed(agentBindings.isEmpty() && skillBindings.isEmpty() && pluginBindings.isEmpty());
result.setAgentBindings(agentBindings);
result.setHasAgentBindings(!agentBindings.isEmpty());
result.setSkillBindings(skillBindings);
result.setHasSkillBindings(!skillBindings.isEmpty());
result.setPluginBindings(pluginBindings);
result.setHasPluginBindings(!pluginBindings.isEmpty());
result.setWorkflowUsages(Collections.emptyList());
result.setHasWorkflowUsages(false);
result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, pluginBindings));
result.setMessage(resolveWorkflowOfflineImpactMessage(agentBindings, skillBindings, pluginBindings));
return result;
}
@@ -167,21 +171,27 @@ public class ResourceOfflineImpactServiceImpl implements ResourceOfflineImpactSe
* 生成工作流下线影响提示。
*
* @param agentBindings Agent 绑定
* @param skillBindings Skill 绑定
* @param pluginBindings 插件绑定
* @return 提示信息
*/
private String resolveWorkflowOfflineImpactMessage(List<OfflineImpactBindingVo> agentBindings,
List<OfflineImpactBindingVo> skillBindings,
List<OfflineImpactBindingVo> pluginBindings) {
if (!pluginBindings.isEmpty() && !agentBindings.isEmpty()) {
return "当前工作流被插件和智能体引用,下线后插件将不可用,智能体将自动解绑";
List<String> referenceTypes = new ArrayList<>(3);
if (!agentBindings.isEmpty()) {
referenceTypes.add("智能体");
}
if (!skillBindings.isEmpty()) {
referenceTypes.add("Skill");
}
if (!pluginBindings.isEmpty()) {
return "当前工作流被插件引用,下线后相关插件将不可用";
referenceTypes.add("插件");
}
if (!agentBindings.isEmpty()) {
return "当前工作流下线成功后,将自动从相关智能体中解绑";
if (!referenceTypes.isEmpty()) {
return "当前工作流仍被" + String.join("", referenceTypes) + "引用,请先取消引用后再下线";
}
return "当前工作流下线后不会影响已有绑定";
return "当前工作流可以下线";
}
/**

View File

@@ -16,12 +16,16 @@ public class OfflineImpactCheckVo {
private boolean hasPluginBindings;
private boolean hasSkillBindings;
private List<OfflineImpactBindingVo> agentBindings = new ArrayList<>();
private List<OfflineImpactBindingVo> workflowUsages = new ArrayList<>();
private List<OfflineImpactBindingVo> pluginBindings = new ArrayList<>();
private List<OfflineImpactBindingVo> skillBindings = new ArrayList<>();
private String message;
/**
@@ -130,6 +134,42 @@ public class OfflineImpactCheckVo {
this.pluginBindings = pluginBindings;
}
/**
* 是否存在 Skill 绑定。
*
* @return 是否存在 Skill 绑定
*/
public boolean isHasSkillBindings() {
return hasSkillBindings;
}
/**
* 设置是否存在 Skill 绑定。
*
* @param hasSkillBindings 是否存在 Skill 绑定
*/
public void setHasSkillBindings(boolean hasSkillBindings) {
this.hasSkillBindings = hasSkillBindings;
}
/**
* 获取 Skill 绑定列表。
*
* @return Skill 绑定列表
*/
public List<OfflineImpactBindingVo> getSkillBindings() {
return skillBindings;
}
/**
* 设置 Skill 绑定列表。
*
* @param skillBindings Skill 绑定列表
*/
public void setSkillBindings(List<OfflineImpactBindingVo> skillBindings) {
this.skillBindings = skillBindings;
}
/**
* 获取提示信息。
*

View File

@@ -0,0 +1,94 @@
package tech.easyflow.ai.easyagentsflow.repository;
import com.easyagents.flow.core.chain.ChainDefinition;
import com.easyagents.flow.core.node.ConfirmNode;
import com.easyagents.flow.core.parser.ChainParser;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.easyagentsflow.service.WorkflowDatacenterContentService;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.node.WorkflowNode;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
/**
* Agent Workflow 冻结快照兼容性测试。
*/
public class AgentWorkflowSnapshotFactoryTest {
/**
* 验证快照只保留 Runtime 白名单字段并使用准备后的内容。
*/
@Test
public void shouldBuildWhitelistedSnapshotFromPreparedContent() {
ChainDefinition definition = new ChainDefinition();
Workflow workflow = workflow();
AgentWorkflowSnapshotFactory factory = factory(definition);
Map<String, Object> snapshot = factory.snapshot(workflow);
Assert.assertEquals(workflow.getId(), snapshot.get("id"));
Assert.assertEquals("prepared-content", snapshot.get("content"));
Assert.assertEquals(6, snapshot.size());
Assert.assertFalse(snapshot.containsKey("tenantId"));
Assert.assertFalse(snapshot.containsKey("publishedSnapshotJson"));
}
/**
* 验证 Skill 或 Agent 发布投影会提前拒绝子工作流节点。
*/
@Test
public void shouldRejectSubWorkflowNode() {
ChainDefinition definition = new ChainDefinition();
definition.addNode(new WorkflowNode());
assertConflict(factory(definition), "子工作流节点");
}
/**
* 验证 Skill 或 Agent 发布投影会提前拒绝内部确认节点。
*/
@Test
public void shouldRejectConfirmNode() {
ChainDefinition definition = new ChainDefinition();
definition.addNode(new ConfirmNode());
assertConflict(factory(definition), "内部确认节点");
}
private AgentWorkflowSnapshotFactory factory(ChainDefinition definition) {
ChainParser parser = mock(ChainParser.class);
WorkflowDatacenterContentService contentService = mock(WorkflowDatacenterContentService.class);
when(contentService.prepareContent("raw-content")).thenReturn("prepared-content");
when(parser.parse("prepared-content")).thenReturn(definition);
return new AgentWorkflowSnapshotFactory(parser, contentService);
}
private Workflow workflow() {
Workflow workflow = new Workflow();
workflow.setId(BigInteger.ONE);
workflow.setTitle("合同审查");
workflow.setDescription("审查合同风险");
workflow.setEnglishName("contract_review");
workflow.setRevision(3);
workflow.setContent("raw-content");
workflow.setTenantId(BigInteger.TEN);
workflow.setPublishedSnapshotJson(Map.of("secret", "hidden"));
return workflow;
}
private void assertConflict(AgentWorkflowSnapshotFactory factory, String message) {
try {
factory.snapshot(workflow());
Assert.fail("Expected incompatible workflow to be rejected");
} catch (BusinessException exception) {
Assert.assertEquals(409, exception.getHttpStatus());
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(message));
}
}
}

View File

@@ -8,6 +8,7 @@ import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.plugin.workflow.binding.WorkflowPluginBindingService;
import tech.easyflow.ai.plugin.workflow.snapshot.WorkflowPluginSnapshotResolver;
import tech.easyflow.ai.service.ResourceOfflineImpactService;
import tech.easyflow.ai.service.AgentResourceReferenceService;
import tech.easyflow.ai.service.WorkflowScheduleReferenceProvider;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.ai.vo.OfflineImpactBindingVo;
@@ -50,6 +51,7 @@ public class WorkflowApprovalSubjectHandlerTest {
offlineImpactService,
mock(WorkflowPluginBindingService.class),
mock(WorkflowPluginSnapshotResolver.class),
mock(AgentResourceReferenceService.class),
new ObjectMapper(),
List.of(scheduleReferenceProvider)
);
@@ -87,6 +89,7 @@ public class WorkflowApprovalSubjectHandlerTest {
offlineImpactService,
mock(WorkflowPluginBindingService.class),
mock(WorkflowPluginSnapshotResolver.class),
mock(AgentResourceReferenceService.class),
new ObjectMapper(),
List.of(scheduleReferenceProvider)
);

View File

@@ -0,0 +1,122 @@
package tech.easyflow.ai.security;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Assert;
import org.junit.Test;
import tech.easyflow.ai.entity.Mcp;
import tech.easyflow.ai.entity.Plugin;
import tech.easyflow.ai.mcp.McpConnectionSnapshotFactory;
import tech.easyflow.ai.plugin.PluginConnectionSnapshotFactory;
import tech.easyflow.common.web.exceptions.BusinessException;
import java.math.BigInteger;
import java.util.Map;
/**
* 连接资源发布快照的凭据边界测试。
*/
public class ConnectionSnapshotFactoryTest {
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* MCP 快照应保留拓扑和服务端输入引用,同时拒绝复制明文凭据。
*/
@Test
public void mcpSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
McpConnectionSnapshotFactory factory = new McpConnectionSnapshotFactory(objectMapper);
Mcp mcp = mcp("""
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
"headers":{"Authorization":"${input:mcp.token}"},
"queryParams":{"tenant":"${input:mcp.tenant}"}}}}
""");
Map<String, Object> snapshot = factory.snapshot(mcp);
Assert.assertEquals(mcp.getId(), snapshot.get("id"));
Assert.assertTrue(String.valueOf(snapshot.get("configJson")).contains("${input:mcp.token}"));
Assert.assertNotNull(snapshot.get("configHash"));
assertBusinessFailure(() -> factory.snapshot(mcp("""
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
"headers":{"Authorization":"Bearer plaintext-secret"}}}}
""")), "必须使用");
assertBusinessFailure(() -> factory.snapshot(mcp("""
{"mcpServers":{"demo":{"url":"https://mcp.example.test/api",
"extension":{"nestedApiKey":"plaintext-secret"}}}}
""")), "敏感配置");
}
/**
* Plugin 快照应只接受服务端输入引用形式的鉴权值和私有请求头。
*/
@Test
public void pluginSnapshotShouldKeepReferencesAndRejectPlaintextCredentials() {
PluginConnectionSnapshotFactory factory = new PluginConnectionSnapshotFactory(objectMapper);
Plugin plugin = plugin("${input:plugin.token}",
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]");
Map<String, Object> snapshot = factory.snapshot(plugin);
Assert.assertEquals("${input:plugin.token}", snapshot.get("tokenValue"));
Assert.assertFalse(snapshot.containsKey("tenantId"));
assertBusinessFailure(() -> factory.snapshot(plugin(
"plaintext-secret",
"[{\"label\":\"Authorization\",\"value\":\"${input:plugin.header}\"}]")),
"鉴权值");
assertBusinessFailure(() -> factory.snapshot(plugin(
"${input:plugin.token}",
"[{\"label\":\"X-Secret\",\"value\":\"plaintext-secret\"}]")),
"请求头凭据");
}
/**
* 创建测试 MCP。
*
* @param configJson MCP 配置
* @return MCP
*/
private Mcp mcp(String configJson) {
Mcp mcp = new Mcp();
mcp.setId(BigInteger.ONE);
mcp.setTitle("测试 MCP");
mcp.setTransportType("SSE");
mcp.setConfigJson(configJson);
return mcp;
}
/**
* 创建测试 Plugin。
*
* @param tokenValue 鉴权值
* @param headers 请求头 JSON
* @return Plugin
*/
private Plugin plugin(String tokenValue, String headers) {
Plugin plugin = new Plugin();
plugin.setId(BigInteger.TWO);
plugin.setName("测试插件");
plugin.setBaseUrl("https://plugin.example.test/api");
plugin.setAuthType("apiKey");
plugin.setPosition("headers");
plugin.setTokenKey("Authorization");
plugin.setTokenValue(tokenValue);
plugin.setHeaders(headers);
plugin.setTenantId(99L);
return plugin;
}
/**
* 断言业务校验失败且消息可定位。
*
* @param action 待执行动作
* @param messageFragment 消息片段
*/
private void assertBusinessFailure(Runnable action, String messageFragment) {
try {
action.run();
Assert.fail("Expected credential validation failure");
} catch (BusinessException exception) {
Assert.assertTrue(exception.getMessage(), exception.getMessage().contains(messageFragment));
}
}
}

View File

@@ -41,6 +41,7 @@ public class ResourceOfflineImpactServiceImplTest {
BigInteger workflowId = BigInteger.valueOf(10);
OfflineImpactBindingVo binding = binding(BigInteger.ONE, "测试智能体");
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(List.of(binding));
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
when(pluginDependencyService.listPluginsByWorkflowId(workflowId))
.thenReturn(Collections.emptyList());
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
@@ -50,11 +51,37 @@ public class ResourceOfflineImpactServiceImplTest {
service.unbindWorkflowFromAgents(workflowId);
Assert.assertTrue(result.isHasAgentBindings());
Assert.assertFalse(result.isCanProceed());
Assert.assertEquals(List.of(binding), result.getAgentBindings());
Assert.assertTrue(result.getMessage().contains("智能体"));
verify(referenceService).unbindWorkflow(workflowId);
}
/**
* 验证 Skill 引用会直接阻止工作流下线并返回可处理摘要。
*/
@Test
public void shouldBlockWorkflowOfflineWhenSkillReferencesIt() {
WorkflowService workflowService = mock(WorkflowService.class);
DocumentCollectionService documentCollectionService = mock(DocumentCollectionService.class);
WorkflowPluginDependencyService pluginDependencyService = mock(WorkflowPluginDependencyService.class);
AgentResourceReferenceService referenceService = mock(AgentResourceReferenceService.class);
BigInteger workflowId = BigInteger.valueOf(10);
OfflineImpactBindingVo skill = binding(BigInteger.valueOf(3), "合同审查 Skill");
when(referenceService.listAgentsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
when(referenceService.listSkillsByWorkflowId(workflowId)).thenReturn(List.of(skill));
when(pluginDependencyService.listPluginsByWorkflowId(workflowId)).thenReturn(Collections.emptyList());
ResourceOfflineImpactServiceImpl service = new ResourceOfflineImpactServiceImpl(
workflowService, documentCollectionService, pluginDependencyService, referenceService);
OfflineImpactCheckVo result = service.checkWorkflowImpact(workflowId);
Assert.assertFalse(result.isCanProceed());
Assert.assertTrue(result.isHasSkillBindings());
Assert.assertEquals(List.of(skill), result.getSkillBindings());
Assert.assertTrue(result.getMessage().contains("Skill"));
}
/**
* 验证知识库影响结果使用 Agent 绑定字段并委托 Agent 解绑。
*/

View File

@@ -91,4 +91,73 @@ public class ChatAssistantAccumulatorTest {
Assert.assertEquals("mcp_123_search", toolCalls.get(0).get("name"));
Assert.assertEquals("知识库 MCP - search", toolCalls.get(0).get("toolDisplayName"));
}
/**
* Skill 状态应按稳定键原位更新、剔除内部字段并持久化为可回放终态。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldPersistWhitelistedSkillInvocationTerminalState() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendSkillInvocationStatus(Map.of(
"statusKey", "skill-invocation:round-1:skill-1",
"status", "RUNNING",
"skillId", "skill-1",
"skillDisplayName", "合同审查",
"internalSnapshot", "must-not-leak"));
accumulator.appendSkillInvocationStatus(Map.of(
"statusKey", "skill-invocation:round-1:skill-1",
"status", "SUCCESS",
"skillId", "skill-1",
"skillDisplayName", "合同审查"));
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
.buildPayload("完成")
.get("skillInvocationStatuses");
Assert.assertEquals(1, statuses.size());
Assert.assertEquals("SUCCESS", statuses.get(0).get("status"));
Assert.assertFalse(statuses.get(0).containsKey("internalSnapshot"));
}
/**
* 流式运行异常时仍在执行的 Skill 应收口为可恢复失败状态。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldFinalizePendingSkillInvocationAfterRunFailure() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendSkillInvocationStatus(Map.of(
"statusKey", "skill-invocation:request-1:skill-1",
"status", "RUNNING",
"skillId", "skill-1"));
accumulator.finalizePendingSkillInvocations("FAILED", "本轮运行失败");
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
.buildPayload(null)
.get("skillInvocationStatuses");
Assert.assertEquals("FAILED", statuses.get(0).get("status"));
Assert.assertEquals("本轮运行失败", statuses.get(0).get("message"));
}
/**
* 正常流结束但缺失终态事件时应落为未完成,避免历史页长期显示运行中。
*/
@Test
@SuppressWarnings("unchecked")
public void shouldConvertDanglingRunningSkillInvocationToIncomplete() {
ChatAssistantAccumulator accumulator = new ChatAssistantAccumulator();
accumulator.appendSkillInvocationStatus(Map.of(
"statusKey", "skill-invocation:round-1:skill-1",
"status", "RUNNING",
"skillId", "skill-1"));
List<Map<String, Object>> statuses = (List<Map<String, Object>>) accumulator
.buildPayload(null)
.get("skillInvocationStatuses");
Assert.assertEquals("INCOMPLETE", statuses.get(0).get("status"));
Assert.assertEquals("技能调用未完成", statuses.get(0).get("message"));
}
}