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

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