From 6d5fb595d3a53b3fd8e9762395b045408d91dfd9 Mon Sep 17 00:00:00 2001 From: matevip Date: Mon, 11 May 2026 16:10:28 +0800 Subject: [PATCH] fix(agent): harden digital-employee isolation across creation, skill binding and prompt catalog --- .../java/vip/mate/agent/AgentService.java | 48 ++++++++ .../binding/service/AgentBindingService.java | 116 +++++++++++++++++- .../fact/model/FactEntityRefEntity.java | 32 ----- .../memory/fact/query/FactQueryService.java | 23 ---- .../fact/repository/FactEntityRefMapper.java | 9 -- .../mate/memory/fact/tool/FactQueryTool.java | 18 --- .../vip/mate/skill/acp/AcpSkillBridge.java | 6 + .../vip/mate/skill/model/SkillEntity.java | 12 +- .../skill/runtime/SkillRuntimeService.java | 24 ++-- .../V102__agent_unique_name_per_workspace.sql | 35 ++++++ .../h2/V103__drop_fact_entity_ref.sql | 15 +++ .../V102__agent_unique_name_per_workspace.sql | 37 ++++++ .../mysql/V103__drop_fact_entity_ref.sql | 2 + .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_en.properties | 3 + mateclaw-ui/src/i18n/locales/en-US.ts | 1 - mateclaw-ui/src/i18n/locales/zh-CN.ts | 1 - 17 files changed, 289 insertions(+), 96 deletions(-) delete mode 100644 mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java delete mode 100644 mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java index 618eab3f..45182bf8 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentService.java @@ -83,6 +83,7 @@ public class AgentService { if (agent.getAgentType() == null) { agent.setAgentType("react"); } + requireUniqueName(agent, null); agentMapper.insert(agent); publishLifecycle(agent, "spawned"); return agent; @@ -93,6 +94,19 @@ public class AgentService { // intent rather than every metadata edit. Reading the prior row // is cheap and gives us a clean diff source. AgentEntity prior = agentMapper.selectById(agent.getId()); + // Only re-validate uniqueness when the name actually changes — + // a pure metadata edit (icon, prompt, ...) shouldn't pay the + // SELECT cost or risk a false positive against the row itself. + if (prior != null + && agent.getName() != null + && !agent.getName().equals(prior.getName())) { + // Workspace cannot be moved (Controller pins it to prior.workspaceId), + // so reuse it for the lookup even if the incoming DTO left it null. + if (agent.getWorkspaceId() == null) { + agent.setWorkspaceId(prior.getWorkspaceId()); + } + requireUniqueName(agent, agent.getId()); + } agentMapper.updateById(agent); agentInstances.remove(agent.getId()); if (prior != null && prior.getEnabled() != null @@ -103,6 +117,40 @@ public class AgentService { return agent; } + /** + * Friendly business-code surface for the {@code (workspace_id, name)} + * unique index added in V102. + * + *

The wire shape is the project-wide R<T> envelope: HTTP status + * stays 200 (per the convention in {@code R.fail} and the axios + * interceptor in {@code mateclaw-ui/src/api/index.ts}); the 409 lives in + * the response body's {@code code} field so the front-end can branch + * without breaking on an axios error. Without this pre-check the + * duplicate save would surface as an opaque + * {@code DataIntegrityViolation} stack trace. + * + * @param excludeId when non-null, skip this row in the lookup so + * {@link #updateAgent} doesn't mistake the row for its + * own duplicate. + */ + private void requireUniqueName(AgentEntity agent, Long excludeId) { + if (agent.getName() == null || agent.getName().isBlank()) { + throw new MateClawException("err.agent.name_required", 400, "Agent 名称不能为空"); + } + Long workspaceId = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); + LambdaQueryWrapper q = new LambdaQueryWrapper() + .eq(AgentEntity::getWorkspaceId, workspaceId) + .eq(AgentEntity::getName, agent.getName()); + if (excludeId != null) { + q.ne(AgentEntity::getId, excludeId); + } + Long count = agentMapper.selectCount(q); + if (count != null && count > 0) { + throw new MateClawException("err.agent.duplicate_name", 409, + "工作区内已存在同名 Agent: " + agent.getName()); + } + } + public void deleteAgent(Long id) { AgentEntity prior = agentMapper.selectById(id); agentMapper.deleteById(id); diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index 438834c9..97e1c08b 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -12,7 +12,13 @@ import vip.mate.agent.binding.model.AgentToolBinding; import vip.mate.agent.binding.repository.AgentProviderPreferenceMapper; import vip.mate.agent.binding.repository.AgentSkillBindingMapper; import vip.mate.agent.binding.repository.AgentToolBindingMapper; +import vip.mate.agent.model.AgentEntity; +import vip.mate.agent.repository.AgentMapper; import vip.mate.exception.MateClawException; +import vip.mate.skill.acp.AcpSkillBridge; +import vip.mate.skill.mcp.McpSkillBridge; +import vip.mate.skill.model.SkillEntity; +import vip.mate.skill.repository.SkillMapper; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.model.ResolvedSkill; import vip.mate.tool.model.AvailableToolDTO; @@ -53,18 +59,44 @@ public class AgentBindingService { * could still be saved by hitting the API directly. */ private final AvailableToolService availableToolService; + /** + * Direct mapper access (instead of {@code AgentService}) to look up an + * agent's workspace before binding a skill. {@code AgentService} pulls + * in {@code AgentGraphBuilder}, which itself depends on + * {@code AgentBindingService} — going through the service would create a + * boot-time cycle. The mapper has no such transitive dependency. + */ + private final AgentMapper agentMapper; + /** Same reasoning as {@link #agentMapper}: skill workspace lookup. */ + private final SkillMapper skillMapper; + /** + * ACP virtual skills aren't rows in {@code mate_skill}; the bridge + * synthesizes them from {@code mate_acp_endpoint}. We need this to + * answer "what workspace does this virtual id belong to?" when an + * agent tries to bind one. MCP virtual skills don't need a bridge + * reference — {@link McpSkillBridge#isVirtualMcpSkillId(Long)} is a + * static range check, and MCP servers carry no workspace today, so + * binding any MCP virtual id is allowed for any agent. + */ + private final AcpSkillBridge acpSkillBridge; @Autowired public AgentBindingService(AgentSkillBindingMapper skillBindingMapper, AgentToolBindingMapper toolBindingMapper, AgentProviderPreferenceMapper providerPreferenceMapper, @Lazy SkillRuntimeService skillRuntimeService, - AvailableToolService availableToolService) { + AvailableToolService availableToolService, + AgentMapper agentMapper, + SkillMapper skillMapper, + AcpSkillBridge acpSkillBridge) { this.skillBindingMapper = skillBindingMapper; this.toolBindingMapper = toolBindingMapper; this.providerPreferenceMapper = providerPreferenceMapper; this.skillRuntimeService = skillRuntimeService; this.availableToolService = availableToolService; + this.agentMapper = agentMapper; + this.skillMapper = skillMapper; + this.acpSkillBridge = acpSkillBridge; } // ==================== Skill Bindings ==================== @@ -92,6 +124,7 @@ public class AgentBindingService { } public AgentSkillBinding bindSkill(Long agentId, Long skillId) { + requireSameWorkspace(agentId, skillId); // 检查是否已绑定 AgentSkillBinding existing = skillBindingMapper.selectOne( new LambdaQueryWrapper() @@ -121,6 +154,15 @@ public class AgentBindingService { * 批量设置 Agent 的 skill 绑定(替换模式) */ public void setSkillBindings(Long agentId, List skillIds) { + // Validate every incoming skill BEFORE touching the binding rows; + // a half-applied save (old bindings dropped, new set rejected + // mid-loop) would leave the agent silently un-bound from skills it + // had a moment ago. + if (skillIds != null) { + for (Long skillId : skillIds) { + requireSameWorkspace(agentId, skillId); + } + } // 删除旧绑定 skillBindingMapper.delete( new LambdaQueryWrapper() @@ -137,6 +179,78 @@ public class AgentBindingService { } } + /** + * Refuse to bind a skill that doesn't share the agent's workspace. + * Skills are per-workspace installable artifacts (each workspace has + * its own catalog under {@code mate_skill.workspace_id}); letting + * workspace A's agent bind workspace B's skill would leak capabilities + * — and prompt content — across the tenancy boundary. + * + *

Three skill id flavors to handle: + *

+ * + *

Most {@code mate_skill} rows currently sit in the default workspace + * (id=1) because skill creation doesn't yet honor the + * {@code X-Workspace-Id} header; the real-skill branch is therefore + * defense-in-depth right now and flips on automatically the moment + * workspace-scoped skill creation lands. ACP enforcement is live today. + * + * @throws MateClawException 404 if the agent or skill doesn't exist; + * 403 on a workspace mismatch. + */ + private void requireSameWorkspace(Long agentId, Long skillId) { + if (agentId == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent ID is required"); + } + if (skillId == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill ID is required"); + } + AgentEntity agent = agentMapper.selectById(agentId); + if (agent == null) { + throw new MateClawException("err.agent.not_found", 404, "Agent 不存在: " + agentId); + } + // MCP virtual: no workspace on McpServerEntity — globally bindable. + if (McpSkillBridge.isVirtualMcpSkillId(skillId)) { + return; + } + SkillEntity skill; + if (AcpSkillBridge.isVirtualAcpSkillId(skillId)) { + // ACP virtual: synthesize from the bridge so workspace_id flows + // through from mate_acp_endpoint. A null reply here means the + // backing endpoint was deleted or disabled between picker render + // and save — same surface as a deleted real skill. + skill = acpSkillBridge.findEntityById(skillId); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, + "ACP endpoint backing skill " + skillId + " is gone or disabled"); + } + } else { + skill = skillMapper.selectById(skillId); + if (skill == null) { + throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId); + } + } + long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); + long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId(); + if (agentWs != skillWs) { + throw new MateClawException("err.skill.cross_workspace_binding", 403, + "Skill " + skillId + " (workspace=" + skillWs + + ") cannot be bound to Agent " + agentId + + " (workspace=" + agentWs + ")"); + } + } + // ==================== Tool Bindings ==================== public List listToolBindings(Long agentId) { diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java deleted file mode 100644 index 2fe75991..00000000 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/model/FactEntityRefEntity.java +++ /dev/null @@ -1,32 +0,0 @@ -package vip.mate.memory.fact.model; - -import com.baomidou.mybatisplus.annotation.*; -import lombok.Data; - -import java.time.LocalDateTime; - -/** - * Entity reference for multi-hop graph queries on facts. - * - * @author MateClaw Team - */ -@Data -@TableName("mate_fact_entity_ref") -public class FactEntityRefEntity { - - @TableId(type = IdType.AUTO) - private Long id; - - private Long factId; - - private String entityName; - - /** person, tool, project, concept */ - private String entityType; - - /** subject | object */ - private String role; - - @TableField(fill = FieldFill.INSERT) - private LocalDateTime createTime; -} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java index f8a98bc5..d5ec9f0f 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/query/FactQueryService.java @@ -6,7 +6,6 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import vip.mate.memory.fact.model.FactContradictionEntity; import vip.mate.memory.fact.model.FactEntity; -import vip.mate.memory.fact.model.FactEntityRefEntity; import vip.mate.memory.fact.repository.FactMapper; import java.time.LocalDateTime; @@ -24,7 +23,6 @@ import java.util.List; public class FactQueryService { private final FactMapper factMapper; - private final vip.mate.memory.fact.repository.FactEntityRefMapper refMapper; private final vip.mate.memory.fact.repository.FactContradictionMapper contradictionMapper; /** @@ -41,27 +39,6 @@ public class FactQueryService { .last("LIMIT 20")); } - /** - * Find related facts via entity references (multi-hop). - */ - public List related(Long agentId, String entity, int hops) { - // Find fact IDs that reference this entity - List refs = refMapper.selectList( - new LambdaQueryWrapper() - .like(FactEntityRefEntity::getEntityName, entity) - .last("LIMIT 50")); - List factIds = refs.stream().map(FactEntityRefEntity::getFactId).distinct().toList(); - if (factIds.isEmpty()) return List.of(); - - return factMapper.selectList( - new LambdaQueryWrapper() - .eq(FactEntity::getAgentId, agentId) - .eq(FactEntity::getDeleted, 0) - .in(FactEntity::getId, factIds) - .orderByDesc(FactEntity::getTrust) - .last("LIMIT 20")); - } - /** * List unresolved contradictions for an agent. */ diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java deleted file mode 100644 index 0e8829a9..00000000 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/repository/FactEntityRefMapper.java +++ /dev/null @@ -1,9 +0,0 @@ -package vip.mate.memory.fact.repository; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import org.apache.ibatis.annotations.Mapper; -import vip.mate.memory.fact.model.FactEntityRefEntity; - -@Mapper -public interface FactEntityRefMapper extends BaseMapper { -} diff --git a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java index 30aea726..c2732ee7 100644 --- a/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java +++ b/mateclaw-server/src/main/java/vip/mate/memory/fact/tool/FactQueryTool.java @@ -43,24 +43,6 @@ public class FactQueryTool { .collect(Collectors.joining("\n")); } - @Tool(description = "Find facts related to an entity via entity references (multi-hop graph query).") - public String fact_related( - @ToolParam(description = "Agent ID") Long agentId, - @ToolParam(description = "Entity name") String entity, - @ToolParam(description = "Number of hops (1-3)") int hops) { - if (!properties.getFact().isProjectionEnabled()) { - return "Fact projection is disabled."; - } - List facts = queryService.related(agentId, entity, Math.min(hops, 3)); - if (facts.isEmpty()) return "No related facts found for: " + entity; - - queryService.bumpUseCount(facts.stream().map(FactEntity::getId).toList()); - - return facts.stream() - .map(f -> String.format("- %s %s %s (trust=%.2f)", f.getSubject(), f.getPredicate(), f.getObjectValue(), f.getTrust())) - .collect(Collectors.joining("\n")); - } - @Tool(description = "List unresolved fact contradictions detected during Dream consolidation.") public String fact_list_contradictions( @ToolParam(description = "Agent ID") Long agentId) { diff --git a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java index a200ec40..e5ae7a20 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/acp/AcpSkillBridge.java @@ -301,6 +301,12 @@ public class AcpSkillBridge { s.setEnabled(Boolean.TRUE.equals(ep.getEnabled())); s.setBuiltin(Boolean.TRUE.equals(ep.getBuiltin())); s.setTags("acp"); + // Carry the backing endpoint's workspace through to the virtual + // SkillEntity so binding-time tenancy checks can compare it against + // the agent's workspace. Without this the bridge synthesizes rows + // with workspaceId = null and an agent in any workspace could bind + // any ACP endpoint regardless of where the endpoint was provisioned. + s.setWorkspaceId(ep.getWorkspaceId()); s.setSecurityScanStatus("PASSED"); // ACP endpoints are user-configured external CLIs, not skill scripts s.setConfigJson(buildConfigJson(ep)); s.setManifestJson(serializeManifest(buildManifest(ep))); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java index 0199066a..44c29730 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/model/SkillEntity.java @@ -97,7 +97,17 @@ public class SkillEntity { /** 标签(逗号分隔) */ private String tags; - /** RFC-023:来源对话 ID(Agent 自治合成时记录) */ + /** + * Owning workspace. The DB column has existed since the baseline schema + * (default = 1) but the field was missing from the entity, so MyBatis + * Plus silently ignored both reads and writes. Surfacing it here lets + * binding-time tenancy checks see the value; default behavior on insert + * remains "fall through to the column DEFAULT" because the field stays + * {@code null} in the no-arg create path. + */ + private Long workspaceId; + + /** 来源对话 ID(Agent 自治合成时记录) */ private String sourceConversationId; /** diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 77dd8a07..d332cce5 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -354,16 +354,20 @@ public class SkillRuntimeService { Long agentId) { List activeSkills; if (boundSkillIds != null) { - // Per-agent 过滤:从全局 enabled skills 中按 ID 过滤。RFC-090 - // §14.1 — must use the same features-aware gate as - // refreshActiveSkills() so legacy dependencyReady drift - // doesn't silently let setup-needed manifest skills through - // (or hide partially-ready features that should be visible). - List enabledSkills = skillService.listEnabledSkills(); - activeSkills = enabledSkills.stream() - .filter(s -> boundSkillIds.contains(s.getId())) - .map(packageResolver::resolve) - .filter(SkillRuntimeService::passesActiveGate) + // Per-agent filter: pick the agent's bound subset from the + // already-merged active set (real + MCP/ACP virtual). Using + // getActiveSkills() — instead of a fresh + // skillService.listEnabledSkills() walk — is what makes bound + // virtual skills surface in the prompt catalog. The earlier + // implementation only looked at mate_skill rows, so a user + // who explicitly checked an MCP/ACP card in the agent picker + // got its tools (via AgentBindingService.getEffectiveToolNames) + // but lost the corresponding `## Skills` catalog row, which + // confused the LLM when it tried to dispatch by skill name. + // Cache-backed get + same passesActiveGate semantics, so this + // is strictly additive for real skills. + activeSkills = getActiveSkills().stream() + .filter(s -> s.getId() != null && boundSkillIds.contains(s.getId())) .collect(java.util.stream.Collectors.toList()); } else { activeSkills = getActiveSkills(); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql new file mode 100644 index 00000000..7f9e3e0a --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V102__agent_unique_name_per_workspace.sql @@ -0,0 +1,35 @@ +-- Enforce unique Agent name within a workspace. +-- +-- Before V102 the application allowed two Agents with the same name in the +-- same workspace, which made name-based routing (e.g. @-mention an Agent in +-- an IM channel) ambiguous and let an attacker shadow an existing Agent. +-- +-- Step 1 — rename pre-existing duplicates so the new index can be created +-- without an offline migration. The oldest row per (workspace_id, name) +-- keeps the original name; later rows are renamed to a fully synthetic +-- migration tag. +-- +-- The rename target intentionally drops the original name and substitutes +-- `__mate_dup_v102____`. Any deterministic transformation of +-- the original name has a non-zero collision risk against a hand-typed +-- pre-existing row that happens to match the pattern (e.g. someone named +-- their agent `foo__v102_dup__2`). A random UUID component drives the +-- collision probability to ~1/2^122, low enough to call "provably unique" +-- for a one-shot admin migration. The original name is recoverable via +-- the audit log; the row id stays embedded in the new name for traceability. +UPDATE mate_agent +SET name = CONCAT('__mate_dup_v102__', id, '__', RANDOM_UUID()) +WHERE id IN ( + SELECT a.id FROM mate_agent a + WHERE EXISTS ( + SELECT 1 FROM mate_agent b + WHERE b.workspace_id = a.workspace_id + AND b.name = a.name + AND b.id < a.id + ) +); + +-- Step 2 — DB-level guarantee. Service layer also pre-checks for friendly +-- 409 messages; this index is the racy-write safety net. +CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_workspace_name + ON mate_agent(workspace_id, name); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql b/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql new file mode 100644 index 00000000..50cd447b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V103__drop_fact_entity_ref.sql @@ -0,0 +1,15 @@ +-- Drop the dead mate_fact_entity_ref table. +-- +-- Introduced in V29 to back a multi-hop "find facts related to entity X" +-- query, but no writer was ever shipped — FactProjectionBuilder only +-- populated mate_fact, never mate_fact_entity_ref. The downstream +-- FactQueryService.related() and the fact_related agent tool therefore +-- always returned empty results, and the table was missing an agent_id +-- column that would have been needed for tenancy isolation if a writer +-- ever did land. Removing the empty table + the dead Java code (deleted +-- in the same change set) keeps the fact projection honest about what +-- it actually offers. +-- +-- If multi-hop fact graph queries become desirable later, add agent_id +-- from day one and ship the writer in the same change. +DROP TABLE IF EXISTS mate_fact_entity_ref; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql new file mode 100644 index 00000000..21867d60 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V102__agent_unique_name_per_workspace.sql @@ -0,0 +1,37 @@ +-- Enforce unique Agent name within a workspace. See H2 variant for context. +-- +-- Step 1 — rename pre-existing duplicates. MySQL forbids referencing the +-- target table in a subquery for UPDATE, so we use a self-join with a +-- derived "min id per group" table to pick which row keeps the original +-- name (the oldest by id) and rename the rest. +-- +-- The rename target drops the original name and substitutes +-- `__mate_dup_v102____`. Any deterministic transformation of +-- the original name has a non-zero collision risk against a hand-typed +-- pre-existing row that happens to match the pattern (e.g. someone named +-- their agent `foo__v102_dup__2`). A random UUID component drives the +-- collision probability to ~1/2^122 — provably unique for a one-shot +-- migration. Mirrors the H2 variant via MySQL's UUID() function. +UPDATE mate_agent t +JOIN ( + SELECT workspace_id, name, MIN(id) AS keep_id + FROM mate_agent + GROUP BY workspace_id, name + HAVING COUNT(*) > 1 +) k + ON t.workspace_id = k.workspace_id + AND t.name = k.name + AND t.id <> k.keep_id +SET t.name = CONCAT('__mate_dup_v102__', t.id, '__', UUID()); + +-- Step 2 — add the unique index, idempotent via INFORMATION_SCHEMA guard +-- (matches the V69 cron-job pattern; works on MySQL < 8.0.29 which has no +-- CREATE INDEX IF NOT EXISTS). +SET @idx_exists := (SELECT COUNT(*) FROM INFORMATION_SCHEMA.STATISTICS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'mate_agent' + AND INDEX_NAME = 'uk_agent_workspace_name'); +SET @stmt := IF(@idx_exists = 0, + 'CREATE UNIQUE INDEX uk_agent_workspace_name ON mate_agent(workspace_id, name)', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql new file mode 100644 index 00000000..9f0ce948 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V103__drop_fact_entity_ref.sql @@ -0,0 +1,2 @@ +-- Drop the dead mate_fact_entity_ref table. See H2 variant for context. +DROP TABLE IF EXISTS mate_fact_entity_ref; diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index 2d012a77..34494499 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -146,6 +146,8 @@ err.auth.user_not_found=\u7528\u6237\u4e0d\u5b58\u5728 err.auth.wrong_password=\u539f\u5bc6\u7801\u9519\u8bef err.agent.not_found=Agent\u4e0d\u5b58\u5728 err.agent.disabled=Agent \u5df2\u7981\u7528 +err.agent.name_required=Agent \u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a +err.agent.duplicate_name=\u5f53\u524d\u5de5\u4f5c\u533a\u5df2\u5b58\u5728\u540c\u540d Agent err.workspace.not_found=\u5de5\u4f5c\u533a\u4e0d\u5b58\u5728 err.workspace.slug_exists=\u5de5\u4f5c\u533a\u6807\u8bc6\u5df2\u5b58\u5728 err.workspace.cannot_modify_default=\u4e0d\u80fd\u4fee\u6539\u9ed8\u8ba4\u5de5\u4f5c\u533a\u7684\u6807\u8bc6 @@ -164,6 +166,7 @@ err.skill.not_found=\u6280\u80fd\u4e0d\u5b58\u5728 err.skill.name_required=\u6280\u80fd\u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a err.skill.name_exists=\u6280\u80fd\u540d\u79f0\u5df2\u5b58\u5728 err.skill.builtin_readonly=\u5185\u7f6e\u6280\u80fd\u4e0d\u53ef\u5220\u9664 +err.skill.cross_workspace_binding=\u4e0d\u80fd\u5c06\u5176\u5b83\u5de5\u4f5c\u533a\u7684\u6280\u80fd\u7ed1\u5b9a\u5230\u5f53\u524d Agent err.mcp.not_found=MCP server \u4e0d\u5b58\u5728 err.mcp.builtin_readonly=\u5185\u7f6e MCP server \u4e0d\u53ef\u5220\u9664 err.mcp.name_required=MCP server \u540d\u79f0\u4e0d\u80fd\u4e3a\u7a7a diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index fa383fe5..413d4d3d 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -152,6 +152,8 @@ err.auth.wrong_password=Incorrect current password # agent err.agent.not_found=Agent not found err.agent.disabled=Agent is disabled +err.agent.name_required=Agent name is required +err.agent.duplicate_name=An Agent with this name already exists in the current workspace # workspace err.workspace.not_found=Workspace not found err.workspace.slug_exists=Workspace slug already exists @@ -174,6 +176,7 @@ err.skill.not_found=Skill not found err.skill.name_required=Skill name cannot be empty err.skill.name_exists=Skill name already exists err.skill.builtin_readonly=Built-in skill cannot be deleted +err.skill.cross_workspace_binding=Cannot bind a skill from a different workspace to this Agent # mcp err.mcp.not_found=MCP server not found err.mcp.builtin_readonly=Built-in MCP server cannot be deleted diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index 8342d23d..6fc3b178 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -3110,7 +3110,6 @@ export default { remember_structured: 'Save Memory', forget_structured: 'Clear Memory', fact_probe: 'Probe Facts', - fact_related: 'Query Related Facts', fact_list_contradictions: 'Check Contradictions', session_search: 'Search Sessions', read_workspace_memory_file: 'Read Memory File', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index e83bbb07..6dcdc668 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -3114,7 +3114,6 @@ export default { remember_structured: '保存记忆', forget_structured: '清除记忆', fact_probe: '探查事实', - fact_related: '查询关联事实', fact_list_contradictions: '检查矛盾事实', session_search: '搜索会话', read_workspace_memory_file: '读取记忆文件',