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

@@ -69,8 +69,10 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
saToken = mockStatic(SaTokenUtil.class);
saToken.when(SaTokenUtil::getLoginAccount).thenReturn(account);
when(skillMapper.updateApprovalState(any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publish(any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publish(any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillMapper.publishApproved(any(), any(), any(), any(), any(), any(), any(), any())).thenReturn(1);
when(skillService.extractContentSnapshot(any())).thenAnswer(invocation -> invocation.getArgument(0));
when(skillService.extractToolBindingsSnapshot(any())).thenReturn(Map.of());
when(skillMapper.markOfflineApproved(any(), any(), any())).thenReturn(1);
when(skillMapper.restoreApprovalState(any(), any(), any(), any())).thenReturn(1);
handler = new SkillApprovalSubjectHandler(
@@ -124,7 +126,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID);
verify(skillMapper).publish(
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), any(Date.class),
eq(SKILL_ID), eq(BigInteger.ONE), same(candidate), eq(Map.of()), any(Date.class),
eq(OPERATOR_ID), isNull());
verify(skillService).releaseSnapshotContents(previous);
verify(skillService, never()).releaseSnapshotContents(candidate);
@@ -228,6 +230,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
eq(BigInteger.ONE),
eq(instanceId),
same(candidate),
eq(Map.of()),
any(Date.class),
eq(OPERATOR_ID),
eq("candidate-hash"));
@@ -253,7 +256,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
BigInteger.valueOf(99)));
assertEquals(409, exception.getHttpStatus());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any());
}
/**
@@ -273,7 +276,7 @@ public class SkillApprovalSubjectHandlerContentReferenceTest {
handler.applyApprovedAction(
ApprovalActionType.PUBLISH.getCode(), SKILL_ID, candidate, OPERATOR_ID, instanceId);
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any());
verify(skillMapper, never()).publishApproved(any(), any(), any(), any(), any(), any(), any(), any());
verify(skillService, never()).releaseSnapshotContents(any());
}

View File

@@ -0,0 +1,128 @@
package tech.easyflow.skill.service;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.MockedStatic;
import tech.easyflow.ai.entity.Workflow;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.ai.mapper.PluginMapper;
import tech.easyflow.ai.permission.McpAccessPermissionChecker;
import tech.easyflow.ai.service.McpService;
import tech.easyflow.ai.service.PluginItemService;
import tech.easyflow.ai.service.PluginVisibilityService;
import tech.easyflow.ai.service.WorkflowService;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.satoken.util.SaTokenUtil;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.vo.SkillToolOptionPage;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.List;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doThrow;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Skill Tool 候选权限边界测试。
*/
public class SkillToolOptionQueryServiceTest {
/**
* 验证聚合查询在缺少 MCP 权限时仍返回其他已授权候选。
*/
@Test
public void allShouldOmitMcpWithoutBlockingOtherCandidates() {
Dependencies dependencies = new Dependencies();
LoginAccount account = account();
Workflow workflow = new Workflow();
workflow.setId(BigInteger.TEN);
workflow.setTenantId(account.getTenantId());
workflow.setTitle("合同审批");
workflow.setDescription("审批合同");
workflow.setPublishStatus(PublishStatus.PUBLISHED.getCode());
// tb_workflow.status 是历史字段,线上可用性以发布状态为准。
workflow.setStatus(0);
when(dependencies.workflowService.list(any(QueryWrapper.class))).thenReturn(List.of(workflow));
when(dependencies.resourceAccessService.canAccess(any(), any(), any())).thenReturn(true);
when(dependencies.pluginMapper.selectListByQuery(any(QueryWrapper.class))).thenReturn(List.of());
when(dependencies.mcpAccessPermissionChecker.canUseMcp()).thenReturn(false);
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(account);
SkillToolOptionPage result = dependencies.service().page(null, "ALL", 1, 20);
Assert.assertEquals(1L, result.total());
Assert.assertEquals("WORKFLOW", result.records().get(0).toolType());
verify(dependencies.mcpService, never()).list(any(QueryWrapper.class));
}
}
/**
* 验证显式查询 MCP 时仍严格要求 MCP 权限。
*/
@Test
public void explicitMcpShouldRejectMissingPermission() {
Dependencies dependencies = new Dependencies();
doThrow(new BusinessException(403, 403, "无权限查询或使用 MCP"))
.when(dependencies.mcpAccessPermissionChecker).assertCanUseMcp();
try (MockedStatic<SaTokenUtil> login = mockStatic(SaTokenUtil.class)) {
login.when(SaTokenUtil::getLoginAccount).thenReturn(account());
try {
dependencies.service().page(null, "MCP", 1, 20);
Assert.fail("显式 MCP 查询必须校验权限");
} catch (BusinessException exception) {
Assert.assertEquals(403, exception.getHttpStatus());
}
}
}
private LoginAccount account() {
LoginAccount account = new LoginAccount();
account.setId(BigInteger.ONE);
account.setTenantId(BigInteger.valueOf(42));
return account;
}
/**
* 查询服务依赖夹具。
*/
private static final class Dependencies {
private final WorkflowService workflowService = mock(WorkflowService.class);
private final PluginItemService pluginItemService = mock(PluginItemService.class);
private final PluginMapper pluginMapper = mock(PluginMapper.class);
private final PluginVisibilityService pluginVisibilityService = mock(PluginVisibilityService.class);
private final McpService mcpService = mock(McpService.class);
private final McpAccessPermissionChecker mcpAccessPermissionChecker =
mock(McpAccessPermissionChecker.class);
private final SkillToolResourceService resourceService = mock(SkillToolResourceService.class);
private final ResourceAccessService resourceAccessService = mock(ResourceAccessService.class);
/**
* 创建待测服务。
*
* @return 待测服务
*/
private SkillToolOptionQueryService service() {
return new SkillToolOptionQueryService(
workflowService,
pluginItemService,
pluginMapper,
pluginVisibilityService,
mcpService,
mcpAccessPermissionChecker,
resourceService,
resourceAccessService
);
}
}
}

View File

@@ -0,0 +1,108 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.Test;
import org.springframework.beans.factory.ObjectProvider;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.service.SkillCategoryService;
import tech.easyflow.skill.service.SkillReferenceProvider;
import tech.easyflow.skill.service.SkillResourceService;
import tech.easyflow.skill.service.SkillToolBindingService;
import tech.easyflow.skill.store.DBSkillContentStore;
import tech.easyflow.system.service.CategoryPermissionService;
import tech.easyflow.system.service.ResourceAccessService;
import java.lang.reflect.Method;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
/**
* Skill 组合发布快照 hash 测试。
*/
public class SkillServiceImplSnapshotHashTest {
/**
* 含 MCP Manifest POJO 的组合快照经过 JSON 持久化后仍应通过校验。
*
* @throws Exception JSON 或反射调用失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldVerifyAggregateSnapshotAfterJsonRoundTrip() throws Exception {
ObjectMapper objectMapper = new ObjectMapper();
SkillToolBindingService toolBindingService = mock(SkillToolBindingService.class);
SkillServiceImpl service = service(objectMapper, toolBindingService);
Map<String, Object> contentSnapshot = new LinkedHashMap<>();
contentSnapshot.put("schemaVersion", 2);
contentSnapshot.put("name", "l21-mcp-docs");
contentSnapshot.put("snapshotHash", hash(service, contentSnapshot));
Map<String, Object> binding = new LinkedHashMap<>();
binding.put("toolType", "MCP");
McpToolManifestEntry manifestEntry = new McpToolManifestEntry();
manifestEntry.setName("query-docs");
manifestEntry.setDescription("查询文档");
manifestEntry.setInputSchema(Map.of("type", "object"));
manifestEntry.setOutputSchema(Map.of());
binding.put("mcpToolManifest", List.of(manifestEntry));
Map<String, Object> toolSnapshot = new LinkedHashMap<>();
toolSnapshot.put("schemaVersion", 1);
toolSnapshot.put("bindings", List.of(binding));
toolSnapshot.put("snapshotHash", "verified-by-tool-service");
Map<String, Object> aggregate = new LinkedHashMap<>(contentSnapshot);
Object contentHash = aggregate.remove("snapshotHash");
aggregate.put("contentSnapshotHash", contentHash);
aggregate.put("platformToolBindings", toolSnapshot);
aggregate.put("toolBindingsHash", toolSnapshot.get("snapshotHash"));
Skill persistedSkill = new Skill();
persistedSkill.setPublishedSnapshotJson(objectMapper.readValue(
objectMapper.writeValueAsBytes(contentSnapshot), Map.class));
persistedSkill.setPublishedToolBindingsJson(objectMapper.readValue(
objectMapper.writeValueAsBytes(toolSnapshot), Map.class));
persistedSkill.setSnapshotHash(hash(service, aggregate));
service.assertPublishedAggregateHash(persistedSkill);
}
/**
* 创建仅用于快照校验的服务。
*
* @param objectMapper JSON 映射器
* @param toolBindingService Tool 快照服务
* @return Skill 服务
*/
@SuppressWarnings("unchecked")
private SkillServiceImpl service(ObjectMapper objectMapper,
SkillToolBindingService toolBindingService) {
return new SkillServiceImpl(
mock(SkillCategoryService.class),
mock(SkillResourceService.class),
toolBindingService,
mock(DBSkillContentStore.class),
mock(ResourceAccessService.class),
mock(CategoryPermissionService.class),
objectMapper,
mock(ObjectProvider.class));
}
/**
* 调用生产代码的统一快照 hash 算法。
*
* @param service Skill 服务
* @param value 待计算结构
* @return SHA-256 hash
* @throws Exception 反射调用失败时抛出
*/
private String hash(SkillServiceImpl service, Object value) throws Exception {
Method method = SkillServiceImpl.class.getDeclaredMethod("hashJson", Object.class);
method.setAccessible(true);
return (String) method.invoke(service, value);
}
}

View File

@@ -0,0 +1,200 @@
package tech.easyflow.skill.service.impl;
import com.easyagents.agent.runtime.mcp.McpToolManifestEntry;
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.Workflow;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillToolBinding;
import tech.easyflow.skill.mapper.SkillMapper;
import tech.easyflow.skill.service.SkillToolResourceService;
import tech.easyflow.system.service.ResourceAccessService;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* Skill Tool 绑定发布快照测试。
*/
public class SkillToolBindingServiceImplTest {
/**
* 发布快照应包含资源冻结数据,并拒绝任何后续篡改。
*/
@Test
public void shouldBuildAndVerifyFrozenToolSnapshot() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding workflowBinding = binding("WORKFLOW", 100, null);
Workflow workflow = new Workflow();
workflow.setId(BigInteger.valueOf(100));
workflow.setTitle("合同审查流程");
when(resources.requireWorkflow(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(workflowBinding))).thenReturn(workflow);
when(resources.snapshotWorkflow(workflow)).thenReturn(Map.of(
"id", BigInteger.valueOf(100),
"content", "{\"nodes\":[]}"));
SkillToolBindingServiceImpl service = service(resources, List.of(workflowBinding));
Map<String, Object> snapshot = service.buildPublishSnapshot(skill());
service.assertPublishedSnapshotHash(snapshot);
verify(resources).requireWorkflow(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(workflowBinding));
Map<String, Object> tampered = new LinkedHashMap<>(snapshot);
tampered.put("schemaVersion", 2);
Assert.assertThrows(BusinessException.class,
() -> service.assertPublishedSnapshotHash(tampered));
}
/**
* 发布快照经过数据库 JSON 持久化后仍应保持同一 hash。
*
* @throws Exception JSON 往返失败时抛出
*/
@Test
@SuppressWarnings("unchecked")
public void shouldVerifyMcpSnapshotAfterJsonRoundTrip() throws Exception {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash");
Mcp mcp = mcp();
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp, List.of(manifest("search")), "manifest-hash"));
when(resources.snapshotMcpConnection(mcp)).thenReturn(Map.of(
"id", mcp.getId(),
"configJson", "{\"mcpServers\":{}}"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
ObjectMapper mapper = new ObjectMapper();
Map<String, Object> snapshot = service.buildPublishSnapshot(skill());
Map<String, Object> persisted = mapper.readValue(
mapper.writeValueAsBytes(snapshot), Map.class);
service.assertPublishedSnapshotHash(persisted);
}
/**
* MCP 清单变化后发布必须失败,要求用户重新保存并确认绑定。
*/
@Test
public void shouldRejectChangedMcpManifestAtPublish() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "old-hash");
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp(), List.of(manifest("search")), "new-hash"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
BusinessException exception = Assert.assertThrows(BusinessException.class,
() -> service.buildPublishSnapshot(skill()));
Assert.assertTrue(exception.getMessage().contains("清单已变化"));
}
/**
* 单个 Skill 展开后的实际 MCP Tool 数量不得超过二十个。
*/
@Test
public void shouldRejectMoreThanTwentyExpandedTools() {
SkillToolResourceService resources = mock(SkillToolResourceService.class);
SkillToolBinding mcpBinding = binding("MCP", 200, "manifest-hash");
List<McpToolManifestEntry> manifest = new ArrayList<>();
for (int index = 1; index <= 21; index++) {
manifest.add(manifest("tool-" + index));
}
when(resources.requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding))).thenReturn(new SkillToolResourceService.McpResource(
mcp(), manifest, "manifest-hash"));
SkillToolBindingServiceImpl service = service(resources, List.of(mcpBinding));
BusinessException exception = Assert.assertThrows(BusinessException.class,
() -> service.buildPublishSnapshot(skill()));
Assert.assertTrue(exception.getMessage().contains("20"));
verify(resources).requireMcp(org.mockito.ArgumentMatchers.any(),
org.mockito.ArgumentMatchers.eq(mcpBinding));
}
/**
* 创建可注入固定绑定列表的服务。
*
* @param resources Tool 资源服务
* @param bindings 固定绑定
* @return 测试服务
*/
private SkillToolBindingServiceImpl service(SkillToolResourceService resources,
List<SkillToolBinding> bindings) {
return new SkillToolBindingServiceImpl(
mock(SkillMapper.class), resources, mock(ResourceAccessService.class), new ObjectMapper()) {
@Override
public List<SkillToolBinding> listBindings(BigInteger skillId) {
return bindings;
}
};
}
/**
* 创建测试 Skill。
*
* @return Skill
*/
private Skill skill() {
Skill skill = new Skill();
skill.setId(BigInteger.ONE);
skill.setTenantId(BigInteger.ONE);
return skill;
}
/**
* 创建 Tool 绑定。
*
* @param type 类型
* @param targetId 目标 ID
* @param manifestHash MCP 清单 hash
* @return 绑定
*/
private SkillToolBinding binding(String type, long targetId, String manifestHash) {
SkillToolBinding binding = new SkillToolBinding();
binding.setToolType(type);
binding.setTargetId(BigInteger.valueOf(targetId));
binding.setMcpToolManifestHash(manifestHash);
binding.setSortNo(0);
return binding;
}
/**
* 创建测试 MCP。
*
* @return MCP
*/
private Mcp mcp() {
Mcp mcp = new Mcp();
mcp.setId(BigInteger.valueOf(200));
mcp.setTitle("测试 MCP");
return mcp;
}
/**
* 创建最小 MCP Tool 清单项。
*
* @param name Tool 名称
* @return 清单项
*/
private McpToolManifestEntry manifest(String name) {
McpToolManifestEntry entry = new McpToolManifestEntry();
entry.setName(name);
entry.setDescription("测试工具");
entry.setInputSchema(Map.of("type", "object"));
return entry;
}
}

View File

@@ -0,0 +1,92 @@
package tech.easyflow.skill.service.impl;
import com.mybatisflex.core.query.QueryWrapper;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.ai.enums.PublishStatus;
import tech.easyflow.skill.entity.Skill;
import tech.easyflow.skill.entity.SkillToolBinding;
import tech.easyflow.skill.service.SkillService;
import tech.easyflow.skill.service.SkillToolBindingService;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
/**
* Skill 对平台 Tool 的生命周期引用查询测试。
*/
public class SkillToolReferenceProviderImplTest {
/**
* 草稿绑定和有效发布快照都应参与 Workflow 下线与删除影响检查。
*/
@Test
public void shouldIncludeDraftAndPublishedWorkflowReferences() {
SkillService skillService = Mockito.mock(SkillService.class);
SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class);
SkillToolBinding draftBinding = new SkillToolBinding();
draftBinding.setSkillId(BigInteger.ONE);
draftBinding.setToolType("WORKFLOW");
draftBinding.setTargetId(BigInteger.TEN);
Skill publishedProjection = skill(BigInteger.TWO, "线上 Skill");
publishedProjection.setPublishStatus(PublishStatus.PUBLISHED.getCode());
publishedProjection.setPublishedToolBindingsJson(Map.of(
"bindings", List.of(Map.of(
"toolType", "WORKFLOW",
"targetId", BigInteger.TEN))));
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(draftBinding));
Mockito.when(skillService.list(Mockito.any(QueryWrapper.class)))
.thenReturn(List.of(publishedProjection));
Mockito.when(skillService.listByIds(Mockito.anyCollection()))
.thenReturn(List.of(
skill(BigInteger.ONE, "草稿 Skill"),
skill(BigInteger.TWO, "线上 Skill")));
SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl(
skillService, bindingService);
var references = provider.listSkillsByWorkflowId(BigInteger.TEN);
Assert.assertEquals(2, references.size());
Assert.assertEquals("Skill“草稿 Skill”", references.get(0).getTitle());
Assert.assertEquals("Skill“线上 Skill”", references.get(1).getTitle());
}
/**
* 已下线发布快照不应继续阻止 Tool 生命周期操作。
*/
@Test
public void shouldIgnoreOfflinePublishedSnapshot() {
SkillService skillService = Mockito.mock(SkillService.class);
SkillToolBindingService bindingService = Mockito.mock(SkillToolBindingService.class);
Skill offline = skill(BigInteger.ONE, "已下线 Skill");
offline.setPublishStatus(PublishStatus.OFFLINE.getCode());
offline.setPublishedToolBindingsJson(Map.of(
"bindings", List.of(Map.of(
"toolType", "MCP",
"targetId", BigInteger.TEN))));
Mockito.when(bindingService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of());
Mockito.when(skillService.list(Mockito.any(QueryWrapper.class))).thenReturn(List.of(offline));
SkillToolReferenceProviderImpl provider = new SkillToolReferenceProviderImpl(
skillService, bindingService);
Assert.assertTrue(provider.listSkillsByMcpId(BigInteger.TEN).isEmpty());
}
/**
* 创建 Skill 摘要。
*
* @param id Skill ID
* @param displayName 展示名
* @return Skill
*/
private Skill skill(BigInteger id, String displayName) {
Skill skill = new Skill();
skill.setId(id);
skill.setName("skill-" + id);
skill.setDisplayName(displayName);
return skill;
}
}