diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java index f3422854..bfe21503 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/SkillFileToolTest.java @@ -2,12 +2,17 @@ package vip.mate.tool.builtin; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ToolContext; +import org.springframework.test.util.ReflectionTestUtils; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.llm.routing.AgentBindingResolver; import vip.mate.skill.runtime.SkillFileAccessPolicy; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.skill.usage.SkillUsageService; import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -30,7 +35,7 @@ class SkillFileToolTest { skill("ckjia-shopping", "mcp", false), skill("claude-code", "acp", false))); - String result = tool.listAvailableSkills("code", "acp", "ready", 1); + String result = tool.listAvailableSkills("code", "acp", "ready", 1, null); assertTrue(result.contains("claude-code")); assertFalse(result.contains("ckjia-shopping")); @@ -136,6 +141,95 @@ class SkillFileToolTest { "No truncation banner should appear when caller did not opt into pagination"); } + @Test + @DisplayName("listAvailableSkills hides skills the agent is not bound to") + void listAvailableSkillsRespectsAgentBindings() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill bound = skill("alpha-skill", "database", true); + ResolvedSkill unbound = skill("beta-skill", "database", true); + when(runtimeService.getActiveSkills()).thenReturn(List.of(bound, unbound)); + // Agent 42 is bound only to alpha-skill; beta-skill must not surface. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(bound.getId())); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.listAvailableSkills(null, null, null, 20, ctx); + + assertTrue(result.contains("alpha-skill")); + assertFalse(result.contains("beta-skill")); + } + + @Test + @DisplayName("readSkillFile denies a skill the agent is not bound to") + void readSkillFileDeniesUnboundSkill() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + beta.setContent("# Beta\nsecret body"); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + // Bound to some other skill id, never beta's. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.readSkillFile("beta-skill", "SKILL.md", null, null, ctx); + + assertTrue(result.contains("is not available for this agent")); + assertFalse(result.contains("secret body")); + } + + @Test + @DisplayName("listSkillFiles denies a skill the agent is not bound to") + void listSkillFilesDeniesUnboundSkill() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(Set.of(999L)); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.listSkillFiles("beta-skill", ctx); + + assertTrue(result.contains("is not available for this agent")); + } + + @Test + @DisplayName("agents with no explicit bindings keep full skill access") + void readSkillFileAllowsWhenAgentHasNoBindings() { + SkillRuntimeService runtimeService = mock(SkillRuntimeService.class); + SkillFileAccessPolicy accessPolicy = mock(SkillFileAccessPolicy.class); + SkillUsageService usageService = mock(SkillUsageService.class); + AgentBindingResolver bindingResolver = mock(AgentBindingResolver.class); + SkillFileTool tool = new SkillFileTool(runtimeService, accessPolicy, usageService); + ReflectionTestUtils.setField(tool, "agentBindingResolver", bindingResolver); + + ResolvedSkill beta = skill("beta-skill", "database", true); + beta.setContent("# Beta\nvisible body"); + when(runtimeService.findActiveSkill("beta-skill")).thenReturn(beta); + // null == no explicit binding restriction → inherit every enabled skill. + when(bindingResolver.getBoundSkillIds(42L)).thenReturn(null); + + ToolContext ctx = ChatOrigin.EMPTY.withAgent(42L).toToolContext(); + String result = tool.readSkillFile("beta-skill", "SKILL.md", null, null, ctx); + + assertTrue(result.contains("visible body")); + assertFalse(result.contains("is not available for this agent")); + } + private static ResolvedSkill skill(String name, String source, boolean builtin) { return ResolvedSkill.builder() .id((long) name.hashCode())