From a9c2d4579037471c271f8ec9a96c62e98a0f3330 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 23 May 2026 22:55:16 +0800 Subject: [PATCH] Harden goal approval and workspace flows --- .../vip/mate/agent/AgentGraphBuilder.java | 5 ++ .../graph/edge/GoalEvaluationDispatcher.java | 10 ++- .../agent/graph/node/GoalEvaluationNode.java | 38 +++++++-- .../graph/plan/state/PlanStateAccessor.java | 2 + .../graph/state/MateClawStateAccessor.java | 18 ++++ .../agent/graph/state/MateClawStateKeys.java | 30 +++++-- .../java/vip/mate/auth/model/UserEntity.java | 8 +- .../mate/channel/ChannelMessageRouter.java | 72 +++++++++++++--- .../tool_guard/ToolGuardCardHandler.java | 13 ++- .../tool_guard/ToolGuardCardHandler.java | 13 ++- .../controller/DatasourceController.java | 16 ++-- .../vip/mate/goal/config/GoalProperties.java | 9 ++ .../controller/ClaudeCodeOAuthController.java | 6 +- .../llm/controller/ModelConfigController.java | 47 ++++++----- .../mate/llm/controller/OAuthController.java | 18 ++-- .../controller/ProviderPoolController.java | 6 +- .../mate/tool/builtin/DelegateAgentTool.java | 12 +-- .../mate/tool/builtin/DelegationContext.java | 39 +++++++-- .../core/controller/WorkspaceController.java | 33 +++++--- .../core/service/WorkspaceService.java | 25 ++++-- .../edge/GoalEvaluationDispatcherTest.java | 28 ++++++- .../state/PlanStateAccessorUsageTest.java | 33 ++++++++ .../ChannelMessageRouterApprovalDenyTest.java | 67 +++++++++++++++ .../tool/builtin/DelegationContextTest.java | 37 ++++++++ .../WorkspaceControllerMembersAuthTest.java | 60 +++++++++++++ .../WorkspaceServiceRoleValidationTest.java | 84 +++++++++++++++++++ 26 files changed, 618 insertions(+), 111 deletions(-) create mode 100644 mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java create mode 100644 mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index c373e245..5079e21f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -519,6 +519,7 @@ public class AgentGraphBuilder { // Token Usage .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.COMPLETION_TOKENS, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.LLM_CALL_COUNT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_MODEL_NAME, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) // SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的 @@ -540,6 +541,8 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE) // Skill progressive disclosure — pinned skills loaded this // run. Registered in BOTH graphs so the read-merge-write in // ActionNode is not dropped on multi-node merges. @@ -798,6 +801,8 @@ public class AgentGraphBuilder { .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_COUNT, KeyStrategy.REPLACE) + .addStrategy(MateClawStateKeys.GOAL_ACCOUNTED_LLM_CALL_COUNT, KeyStrategy.REPLACE) // Skill progressive disclosure — pinned skills loaded this // run. Registered in BOTH graphs so the read-merge-write in // ActionNode is not dropped on multi-node merges. diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java index 7ccd2280..1964f4b9 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcher.java @@ -5,6 +5,7 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN; import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED; /** @@ -29,8 +30,15 @@ public class GoalEvaluationDispatcher implements EdgeAction { @Override public String apply(OverAllState state) { + // Re-enter the loop only when a followup was injected AND this was not a + // terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key + // strategy and is never cleared by the reasoning nodes, so after a + // run-to-completion loop it can linger true; goalEvaluatedThisRun (set + // true on every terminal branch — completed / exhausted / skip / + // continue-without-followup) is the authoritative end-of-run signal. boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false)); - if (followup) { + boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false)); + if (followup && !terminal) { log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget); return followupTarget; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 66553f44..076811d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -160,10 +160,13 @@ public class GoalEvaluationNode implements NodeAction { try { result = evaluationService.evaluate(goal, recent, terminal); - // Pre-eval agent_llm count snapshot — the bookkeeping helper - // folds it into the per-goal counter so future turns see - // growing usage. - int agentLlmDelta = accessor.llmCallCount(); + // Bill only the NEW agent LLM calls since the last accounted point. + // The run-to-completion loop evaluates multiple times per graph run + // while LLM_CALL_COUNT keeps growing, so passing the cumulative value + // raw would re-bill earlier calls on every pass and exhaust the + // goal's LLM budget prematurely. The followup branch advances the + // accounted marker; terminal branches don't (the run ends there). + int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount()); int evalLlmDelta = result.llmCallsConsumed(); goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta); @@ -219,6 +222,7 @@ public class GoalEvaluationNode implements NodeAction { .build(); } + int followupCountThisRun = accessor.goalFollowupCount(); Optional followup; try { followup = followupService.maybeBuildFollowup(refreshed, result); @@ -227,7 +231,18 @@ public class GoalEvaluationNode implements NodeAction { refreshed.getId(), t.toString()); followup = Optional.empty(); } - if (followup.isPresent()) { + // Per-run safety net: cap the autonomous self-continuation loop so a + // single user message can't drive an unbounded number of steps or + // approach the graph recursion limit. When the cap is hit we fall + // through to the terminal "continue, no followup" path — the goal stays + // active and the cross-message turn / LLM budget (or the user) carries + // it on. + boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun(); + if (followup.isPresent() && perRunCapReached) { + log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run", + followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId()); + } + if (followup.isPresent() && !perRunCapReached) { try { goalService.recordFollowupInjected(refreshed.getId(), followup.get()); } catch (Throwable t) { @@ -240,7 +255,18 @@ public class GoalEvaluationNode implements NodeAction { .goalEvaluationResult(result.toMap()) .goalFollowupInjected(true) .goalFollowupPrompt(followup.get()) - .goalEvaluatedThisRun(true) + .goalFollowupCount(followupCountThisRun + 1) + // Advance the LLM-billing marker to the current cumulative + // count so the NEXT evaluation in this run charges only its + // own delta (see agentLlmDelta above). + .goalAccountedLlmCallCount(accessor.llmCallCount()) + // Deliberately NOT setting goalEvaluatedThisRun(true): leaving + // it false lets the NEXT answer be re-evaluated, turning the + // old single-step behaviour into run-to-completion. The loop + // is bounded by the per-run cap above plus the turn / LLM + // budgets; the dispatcher treats any terminal pass + // (goalEvaluatedThisRun == true) as END even if this flag + // lingers true under the REPLACE key strategy. .needsToolCall(false) .events(List.of(goalEvent("goal_followup", Map.of( "goalId", String.valueOf(refreshed.getId()), diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java index fd85ef28..dccce082 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/plan/state/PlanStateAccessor.java @@ -246,8 +246,10 @@ public final class PlanStateAccessor { NodeStreamingChatHelper.StreamResult result) { int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0); int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_TOKENS, 0); + int existingLlmCalls = currentState.value(MateClawStateKeys.LLM_CALL_COUNT, 0); map.put(MateClawStateKeys.PROMPT_TOKENS, existingPrompt + result.promptTokens()); map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); + map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1); return this; } diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java index 0b7751da..96c18cef 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateAccessor.java @@ -293,6 +293,16 @@ public final class MateClawStateAccessor { return state.value(GOAL_FOLLOWUP_PROMPT, ""); } + /** Auto-followups already injected in this graph run (0 at run start). */ + public int goalFollowupCount() { + return state.value(GOAL_FOLLOWUP_COUNT, 0); + } + + /** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */ + public int goalAccountedLlmCallCount() { + return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0); + } + /** * Bridge across ReAct and Plan-Execute: ReAct writes the terminal text * to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode; @@ -534,6 +544,14 @@ public final class MateClawStateAccessor { return put(GOAL_EVALUATED_THIS_RUN, v); } + public OutputBuilder goalFollowupCount(int n) { + return put(GOAL_FOLLOWUP_COUNT, n); + } + + public OutputBuilder goalAccountedLlmCallCount(int n) { + return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n); + } + /** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't * immediately re-terminate via the existing final text. */ public OutputBuilder clearFinalAnswer() { diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java index a1c25506..2e269f57 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/state/MateClawStateKeys.java @@ -200,14 +200,34 @@ public final class MateClawStateKeys { public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt"; /** - * Re-entry guard: GoalEvaluationNode sets this true on its first run - * of a graph invocation; the FinalAnswerNode→GoalEvaluation conditional - * edge skips re-entering the node when it's already true. Combined with - * the dispatcher's followup clearing of FINAL_ANSWER, this bounds - * follow-ups to at most one per graph run. + * Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets + * this true only when it ENDS the run (completed / exhausted / skip / + * continue-without-followup). The FinalAnswerNode→GoalEvaluation edge skips + * re-entering once it's true. The followup branch deliberately leaves it + * false so the self-continuation loop can re-evaluate the next answer; that + * loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus + * the goal's turn / LLM-call budgets. */ public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run"; + /** + * Number of auto-followups already injected in THIS graph run (one user + * turn). Bounds the self-continuation loop per single message — independent + * of the goal's cross-turn turn_budget — so one message can't drive an + * unbounded number of autonomous steps or exhaust the graph recursion + * limit. Implicitly 0 at the start of each graph invocation. + */ + public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count"; + + /** + * Cumulative agent LLM-call count already billed to the goal in THIS graph + * run. The run-to-completion loop evaluates multiple times per run while + * {@link #LLM_CALL_COUNT} keeps growing; recording only + * (current − accounted) on each pass avoids re-billing earlier calls and + * exhausting the goal's LLM budget prematurely. Implicitly 0 at run start. + */ + public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count"; + /** Graph-node identifier for the GoalEvaluationNode. */ public static final String GOAL_EVALUATION_NODE = "goal_evaluation"; diff --git a/mateclaw-server/src/main/java/vip/mate/auth/model/UserEntity.java b/mateclaw-server/src/main/java/vip/mate/auth/model/UserEntity.java index f9401b2f..db49e296 100644 --- a/mateclaw-server/src/main/java/vip/mate/auth/model/UserEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/auth/model/UserEntity.java @@ -1,6 +1,7 @@ package vip.mate.auth.model; import com.baomidou.mybatisplus.annotation.*; +import com.fasterxml.jackson.annotation.JsonProperty; import lombok.Data; import java.time.LocalDateTime; @@ -20,7 +21,12 @@ public class UserEntity { /** 用户名 */ private String username; - /** 密码(BCrypt加密) */ + /** + * 密码(BCrypt加密)。WRITE_ONLY: accepted from request bodies (login / user + * creation) but never serialized into a response, so the bcrypt hash cannot + * leak via endpoints that return UserEntity (e.g. GET /auth/users). + */ + @JsonProperty(access = JsonProperty.Access.WRITE_ONLY) private String password; /** 昵称 */ diff --git a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java index 95295366..ef0d33f0 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/ChannelMessageRouter.java @@ -488,6 +488,47 @@ public class ChannelMessageRouter { return null; } + /** + * Identity gate shared by /approve and /deny (group-chat safety): only the + * original human requester may resolve a pending. Agent/cron ("system") and + * unattributed (null) approvals are fail-closed in IM — any group member + * could otherwise approve OR deny/cancel a guarded action — and must be + * handled from the admin console. Sends the rejection notice + logs and + * returns {@code false} when the caller is not authorized. + */ + private boolean approvalResolveAuthorized(PendingApproval pending, ChannelMessage message, + ChannelAdapter adapter, String replyTarget) { + String originalRequester = pending.getUserId(); + boolean systemOriginated = originalRequester == null || "system".equals(originalRequester); + if (systemOriginated || !originalRequester.equals(message.getSenderId())) { + adapter.sendMessage(replyTarget, systemOriginated + ? "⚠️ 该审批由系统/定时任务发起,请在管理端处理。" + : "⚠️ 只有原始请求者可以审批此操作。"); + log.warn("[{}] Approval resolve rejected: sender={} != requester={} (systemOriginated={})", + adapter.getChannelType(), message.getSenderId(), originalRequester, systemOriginated); + return false; + } + return true; + } + + /** + * When an /approve or /deny command carries an explicit short pendingId + * (e.g. "/deny a1b2c3"), verify it matches the conversation's current + * pending before resolving — otherwise a stale or copy-pasted id would + * silently act on the wrong pending. Sends the mismatch notice and returns + * {@code true} (caller must abort) when the ids don't line up. + */ + private boolean pendingIdMismatch(String userText, PendingApproval pending, + ChannelAdapter adapter, String replyTarget) { + String shortId = extractShortId(userText); + if (shortId != null && !pending.getPendingId().startsWith(shortId)) { + adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: " + + pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length()))); + return true; + } + return false; + } + // ==================== 消息处理(原 route 逻辑 + 审批拦截层) ==================== /** @@ -510,20 +551,12 @@ public class ChannelMessageRouter { String replyTarget = resolveReplyTarget(message); if (isApproveCommand(userText)) { - // pendingId 校验:如果命令包含 shortId,验证是否匹配当前 pending - String shortId = extractShortId(userText); - if (shortId != null && !pending.getPendingId().startsWith(shortId)) { - adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: " - + pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length()))); + // pendingId 校验:approve / deny 共用——命令带 shortId 时必须匹配当前 pending。 + if (pendingIdMismatch(userText, pending, adapter, replyTarget)) { return; } - // 身份校验:只有原始请求者可以审批(群聊安全) - String originalRequester = pending.getUserId(); - if (originalRequester != null && !"system".equals(originalRequester) - && !originalRequester.equals(message.getSenderId())) { - adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。"); - log.warn("[{}] Approval rejected: sender={} != requester={}", - adapter.getChannelType(), message.getSenderId(), originalRequester); + // 身份校验:approve / deny 共用同一道门禁(群聊安全 + system/null fail-closed)。 + if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) { return; } // Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically. @@ -542,9 +575,24 @@ public class ChannelMessageRouter { return; } else if (isDenyCommand(userText)) { + // pendingId 校验:与 approve 一致——命令带 shortId 时必须匹配当前 pending, + // 否则 /deny <其它ID> 会错误地拒绝当前 conversation 的 pending。 + if (pendingIdMismatch(userText, pending, adapter, replyTarget)) { + return; + } + // 身份校验:deny 与 approve 共用门禁。否则群里任意成员可拒绝/取消他人的 + // pending,system/null 发起的审批也会被任意人 deny(取消审批、清 placeholder、 + // 写入 denied 状态);这类审批改到管理端处理。 + if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) { + return; + } // Deny via IM: workflow.resolve owns the full state-machine transition. ResolveOutcome denyOutcome = approvalService.resolve( pending.getPendingId(), message.getSenderId(), "denied"); + if (denyOutcome.isAlreadyResolved()) { + adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。"); + return; + } conversationService.removeApprovalPlaceholders(conversationId); String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName(); persistAndBroadcastApprovalHint(conversationId, denyHint, diff --git a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java index 4d4e17a4..78476f4a 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/feishu/cards/tool_guard/ToolGuardCardHandler.java @@ -100,11 +100,16 @@ public class ToolGuardCardHandler implements FeishuCardHandler { } PendingApproval pending = opt.get(); - // ---- 3. Identity check + // ---- 3. Identity check (fail-closed) + // Agent/cron ("system") or unattributed (null) approvals have no human + // requester to match the clicker against. A guarded-tool card landing in + // a group chat would otherwise let ANY member click Approve and run the + // tool. Those approvals must be resolved from the admin console instead, + // so only an exact requester==clicker match is authorized here. String originalRequester = pending.getUserId(); - boolean authorized = originalRequester == null - || "system".equals(originalRequester) - || originalRequester.equals(clickerOpenId); + boolean authorized = originalRequester != null + && !"system".equals(originalRequester) + && originalRequester.equals(clickerOpenId); if (!authorized) { log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}", abbrev(clickerOpenId), abbrev(originalRequester), pendingId); diff --git a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java index ea1b934f..c73230b3 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/wecom/cards/tool_guard/ToolGuardCardHandler.java @@ -75,11 +75,16 @@ public class ToolGuardCardHandler implements WeComCardHandler { } PendingApproval pending = opt.get(); - // ---- 3. Identity check ---- + // ---- 3. Identity check (fail-closed) ---- + // Agent/cron ("system") or unattributed (null) approvals have no human + // requester to match the clicker against; a group card would let any + // member resolve a guarded action. Reject here (mirrors the feishu card + // handler + router) so we never renderResolved a click the router will + // then refuse to execute. These approvals go through the admin console. String originalRequester = pending.getUserId(); - boolean isAuthorized = originalRequester == null - || "system".equals(originalRequester) - || originalRequester.equals(clickerUserId); + boolean isAuthorized = originalRequester != null + && !"system".equals(originalRequester) + && originalRequester.equals(clickerUserId); if (!isAuthorized) { log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}", abbrev(clickerUserId), abbrev(originalRequester), pendingId); diff --git a/mateclaw-server/src/main/java/vip/mate/datasource/controller/DatasourceController.java b/mateclaw-server/src/main/java/vip/mate/datasource/controller/DatasourceController.java index b923226a..28d66025 100644 --- a/mateclaw-server/src/main/java/vip/mate/datasource/controller/DatasourceController.java +++ b/mateclaw-server/src/main/java/vip/mate/datasource/controller/DatasourceController.java @@ -10,7 +10,7 @@ import vip.mate.datasource.service.DatasourceService; import java.util.List; import java.util.Map; -import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; /** * 数据源管理接口 @@ -27,28 +27,28 @@ public class DatasourceController { @Operation(summary = "获取数据源列表") @GetMapping - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> list() { return R.ok(datasourceService.listAll()); } @Operation(summary = "获取数据源详情") @GetMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R get(@PathVariable Long id) { return R.ok(datasourceService.getByIdMasked(id)); } @Operation(summary = "创建数据源") @PostMapping - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R create(@RequestBody DatasourceEntity entity) { return R.ok(datasourceService.create(entity)); } @Operation(summary = "更新数据源") @PutMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R update(@PathVariable Long id, @RequestBody DatasourceEntity entity) { entity.setId(id); return R.ok(datasourceService.update(entity)); @@ -56,7 +56,7 @@ public class DatasourceController { @Operation(summary = "删除数据源") @DeleteMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R delete(@PathVariable Long id) { datasourceService.delete(id); return R.ok(); @@ -64,7 +64,7 @@ public class DatasourceController { @Operation(summary = "测试数据源连接") @PostMapping("/{id}/test") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> testConnection(@PathVariable Long id) { boolean ok = datasourceService.testConnection(id); return R.ok(Map.of("success", ok, "message", ok ? "连接成功" : "连接失败")); @@ -72,7 +72,7 @@ public class DatasourceController { @Operation(summary = "启用/禁用数据源") @PutMapping("/{id}/toggle") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { return R.ok(datasourceService.toggle(id, enabled)); } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java index 34ddb0c6..4e3d15b8 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/config/GoalProperties.java @@ -35,6 +35,15 @@ public class GoalProperties { /** Default cooldown between auto-followups in seconds. */ private int autoFollowupCooldownSeconds = 0; + /** + * Max auto-followups injected within a single graph run (one user turn). + * Caps the self-continuation loop so one message can't drive too many + * autonomous steps or approach the graph recursion limit. The goal's + * overall {@code turn_budget} still bounds total turns across messages; + * this is the tighter per-message safety net. + */ + private int maxFollowupsPerRun = 8; + /** * Provider/model id for the evaluator. Empty string means "use the * same model as the chat agent" — convenient for dev, expensive in diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ClaudeCodeOAuthController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ClaudeCodeOAuthController.java index 3ab5ede3..63345865 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ClaudeCodeOAuthController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ClaudeCodeOAuthController.java @@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController; import vip.mate.common.result.R; import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService.OAuthStatus; -import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; /** * RFC-062 PR-3: management-UI surface for the Claude Code OAuth provider. @@ -46,7 +46,7 @@ public class ClaudeCodeOAuthController { @Operation(summary = "Read current Claude Code OAuth credential status from local disk") @GetMapping("/status") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R status() { return R.ok(oauthService.getStatus()); } @@ -62,7 +62,7 @@ public class ClaudeCodeOAuthController { */ @Operation(summary = "Force re-detect credentials and refresh if near expiry") @PostMapping("/reload") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R reload() { try { oauthService.getValidToken(); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index a761f989..b1859809 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -21,6 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import java.util.HashMap; import java.util.List; import java.util.Map; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; @Slf4j @@ -40,28 +41,28 @@ public class ModelConfigController { @Operation(summary = "获取 Provider 列表(仅 enabled)") @GetMapping - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> list() { return R.ok(modelProviderService.listProviders()); } @Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用") @GetMapping("/catalog") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> catalog() { return R.ok(modelProviderService.listCatalog()); } @Operation(summary = "RFC-074: 启用 Provider") @PostMapping("/{providerId}/enable") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R enableProvider(@PathVariable String providerId) { return R.ok(modelProviderService.setEnabled(providerId, true)); } @Operation(summary = "RFC-074: 禁用 Provider(如其下模型为当前默认会自动切换)") @PostMapping("/{providerId}/disable") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R disableProvider(@PathVariable String providerId) { return R.ok(modelProviderService.setEnabled(providerId, false)); } @@ -97,7 +98,7 @@ public class ModelConfigController { @Operation(summary = "设置当前激活模型") @PutMapping("/active") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R setActiveModel(@RequestBody ModelSlotRequest request) { ModelConfigEntity model = modelConfigService.setDefaultModel(request.getProviderId(), request.getModel()); ActiveModelsInfo info = new ActiveModelsInfo(); @@ -107,7 +108,7 @@ public class ModelConfigController { @Operation(summary = "更新 Provider 配置") @PutMapping("/{providerId}/config") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R updateProviderConfig(@PathVariable String providerId, @RequestBody ProviderConfigRequest request) { ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request); @@ -118,14 +119,14 @@ public class ModelConfigController { @Operation(summary = "创建自定义 Provider") @PostMapping("/custom-providers") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R createCustomProvider(@RequestBody CreateCustomProviderRequest request) { return R.ok(modelProviderService.createCustomProvider(request)); } @Operation(summary = "删除自定义 Provider") @DeleteMapping("/custom-providers/{providerId}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R deleteCustomProvider(@PathVariable String providerId) { modelProviderService.deleteCustomProvider(providerId); return R.ok(); @@ -141,7 +142,7 @@ public class ModelConfigController { */ @Operation(summary = "删除自定义 Provider(查询参数变体,兼容含特殊字符的旧 ID)") @DeleteMapping("/custom-providers") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) { modelProviderService.deleteCustomProvider(providerId); return R.ok(); @@ -149,7 +150,7 @@ public class ModelConfigController { @Operation(summary = "向 Provider 添加模型") @PostMapping("/{providerId}/models") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R addProviderModel(@PathVariable String providerId, @RequestBody AddProviderModelRequest request) { return R.ok(modelProviderService.addModel(providerId, request)); @@ -157,7 +158,7 @@ public class ModelConfigController { @Operation(summary = "从 Provider 删除模型") @DeleteMapping("/{providerId}/models") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R removeProviderModel(@PathVariable String providerId, @RequestParam String modelId) { return R.ok(modelProviderService.removeModel(providerId, modelId)); @@ -165,21 +166,21 @@ public class ModelConfigController { @Operation(summary = "获取模型详情") @GetMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R get(@PathVariable Long id) { return R.ok(modelConfigService.getModel(id)); } @Operation(summary = "创建模型") @PostMapping - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R create(@RequestBody ModelConfigEntity entity) { return R.ok(modelConfigService.createModel(entity)); } @Operation(summary = "更新模型") @PutMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R update(@PathVariable Long id, @RequestBody ModelConfigEntity entity) { entity.setId(id); return R.ok(modelConfigService.updateModel(entity)); @@ -187,7 +188,7 @@ public class ModelConfigController { @Operation(summary = "删除模型") @DeleteMapping("/{id}") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R delete(@PathVariable Long id) { modelConfigService.deleteModel(id); return R.ok(); @@ -195,7 +196,7 @@ public class ModelConfigController { @Operation(summary = "设置默认模型") @PostMapping("/{id}/default") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R setDefault(@PathVariable Long id) { return R.ok(modelConfigService.setDefaultModel(id)); } @@ -204,14 +205,14 @@ public class ModelConfigController { @Operation(summary = "发现远端模型") @PostMapping("/{providerId}/discover") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R discoverModels(@PathVariable String providerId) { return R.ok(modelDiscoveryService.discoverModels(providerId)); } @Operation(summary = "批量添加发现的模型") @PostMapping("/{providerId}/discover/apply") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> applyDiscoveredModels(@PathVariable String providerId, @RequestBody ApplyDiscoveredModelsRequest request) { int added = modelDiscoveryService.batchAddModels(providerId, request.getModelIds()); @@ -220,14 +221,14 @@ public class ModelConfigController { @Operation(summary = "测试供应商连接") @PostMapping("/{providerId}/test-connection") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R testConnection(@PathVariable String providerId) { return R.ok(modelDiscoveryService.testConnection(providerId)); } @Operation(summary = "测试单个模型可用性") @PostMapping("/{providerId}/models/test") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R testModel(@PathVariable String providerId, @RequestParam String modelId) { return R.ok(modelDiscoveryService.testModel(providerId, modelId)); @@ -246,7 +247,7 @@ public class ModelConfigController { @Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key)") @PostMapping("/embedding/{modelId}/test") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> testEmbedding(@PathVariable Long modelId) { Map result = new HashMap<>(); try { @@ -279,7 +280,7 @@ public class ModelConfigController { @Operation(summary = "获取系统默认 Embedding 模型 ID") @GetMapping("/embedding/default") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> getDefaultEmbedding() { SystemSettingEntity entity = systemSettingMapper.selectOne( new LambdaQueryWrapper() @@ -293,7 +294,7 @@ public class ModelConfigController { @Operation(summary = "设置系统默认 Embedding 模型") @PostMapping("/embedding/default") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R setDefaultEmbedding(@RequestBody Map body) { Object v = body.get("modelId"); String value = v == null ? "" : v.toString(); diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java index 3a8a95ab..15a12050 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/OAuthController.java @@ -12,7 +12,7 @@ import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult; import vip.mate.llm.oauth.OpenAIOAuthService; import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult; import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult; -import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; @Tag(name = "OpenAI OAuth") @RestController @@ -25,7 +25,7 @@ public class OAuthController { @Operation(summary = "获取 OAuth 授权 URL(自动选 LOCAL / MANUAL_PASTE 模式)") @GetMapping("/authorize") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R authorize(HttpServletRequest request) { // 用 Host header 判断是否远程部署 — 远程则不启 localhost server,进 MANUAL_PASTE 流。 // 优先 X-Forwarded-Host(反向代理后的实际入口),fallback 到 Host。 @@ -44,7 +44,7 @@ public class OAuthController { */ @Operation(summary = "MANUAL_PASTE 模式:用户粘贴浏览器回调 URL 完成 OAuth") @PostMapping("/callback-paste") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R callbackPaste(@RequestBody PasteRequest request) { oauthService.completeFromPastedUrl(request.callbackUrl()); return R.ok(); @@ -55,21 +55,21 @@ public class OAuthController { @Operation(summary = "Device flow: start — request user_code") @PostMapping("/device/start") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R deviceStart() { return R.ok(deviceCodeService.start()); } @Operation(summary = "Device flow: poll for completion") @PostMapping("/device/poll") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R devicePoll(@RequestBody DeviceRequest request) { return R.ok(deviceCodeService.poll(request.deviceAuthId())); } @Operation(summary = "Device flow: cancel a pending session") @PostMapping("/device/cancel") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R deviceCancel(@RequestBody DeviceRequest request) { deviceCodeService.cancel(request.deviceAuthId()); return R.ok(); @@ -80,7 +80,7 @@ public class OAuthController { @Operation(summary = "手动刷新 Token") @PostMapping("/refresh") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R refresh() { oauthService.refreshToken(); return R.ok(); @@ -88,7 +88,7 @@ public class OAuthController { @Operation(summary = "清除 OAuth 凭证") @DeleteMapping("/revoke") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R revoke() { oauthService.revokeToken(); return R.ok(); @@ -96,7 +96,7 @@ public class OAuthController { @Operation(summary = "获取 OAuth 连接状态") @GetMapping("/status") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R status() { return R.ok(oauthService.getStatus()); } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java index f51e8f72..c56b053b 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ProviderPoolController.java @@ -21,7 +21,7 @@ import vip.mate.llm.service.ModelProviderService; import java.util.ArrayList; import java.util.List; import java.util.Map; -import vip.mate.workspace.core.annotation.RequireWorkspaceRole; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; /** * RFC-009 Phase 4 — read-only diagnostic endpoint for the provider pool. @@ -50,7 +50,7 @@ public class ProviderPoolController { @Operation(summary = "查询所有 provider 的池状态 + 冷却信息") @GetMapping - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R> snapshot() { Map poolView = providerPool.snapshot(); Map healthView = healthTracker.snapshot(); @@ -83,7 +83,7 @@ public class ProviderPoolController { @Operation(summary = "手动重新探测某个 provider,立即更新池状态") @PostMapping("/{providerId}/reprobe") - @RequireWorkspaceRole("admin") + @RequireGlobalAdmin public R reprobe(@PathVariable String providerId) { ProbeResult result = initProbe.probeOne(providerId); return R.ok(new ReprobeResultDTO( diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index cbfd9b4c..06921bf1 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -302,7 +302,7 @@ public class DelegateAgentTool { ChildResult result; try { result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, - parentOrigin, rootConversationId, subagentId); + parentOrigin, rootConversationId, subagentId, childDepth); } finally { // Cleanup relay + registry regardless of how the child returned // (success / exception / interruption) so we never leak entries. @@ -440,7 +440,7 @@ public class DelegateAgentTool { for (PreparedChild p : prepared) { CompletableFuture future = CompletableFuture.supplyAsync( () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, - parentOriginParallel, rootConvFinal, p.subagentId), + parentOriginParallel, rootConvFinal, p.subagentId, childDepth), DELEGATION_EXECUTOR); // Broadcast per-child completion as soon as each child finishes @@ -723,7 +723,7 @@ public class DelegateAgentTool { try { ChildResult childResult = runSingleChild(0, target, task, parentConversationId, childConversationId, parentOrigin, - rootConvAsync, subagentId); + rootConvAsync, subagentId, childDepth); return childResult.toToolResponse(target.getName()); } finally { subagentRegistry.get(subagentId).ifPresent(rec -> { @@ -933,7 +933,7 @@ public class DelegateAgentTool { private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, String parentConversationId, String childConversationId, ChatOrigin parentOrigin, - String rootConversationId, String subagentId) { + String rootConversationId, String subagentId, int childDepth) { boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); if (relayChildEvents) { streamTracker.register(childConversationId); @@ -941,8 +941,10 @@ public class DelegateAgentTool { } // Carry root conversation + this child's subagentId into the context so // a grandchild broadcasts to the root stream and tags this as its parent. + // Pass the real tree depth so the gate survives the executor-thread hop: + // async/parallel children run with an empty ThreadLocal stack. DelegationContext.enter(parentConversationId, deniedToolsForChild(), - rootConversationId, subagentId); + rootConversationId, subagentId, childDepth); try { long startTime = System.currentTimeMillis(); // RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java index c19deb36..85481626 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegationContext.java @@ -27,15 +27,29 @@ public final class DelegationContext { * the spawn tree. */ private record Frame(String parentConversationId, Set childDeniedTools, - String rootConversationId, String currentSubagentId) {} + String rootConversationId, String currentSubagentId, int depth) {} private static final ThreadLocal> STACK = ThreadLocal.withInitial(ArrayDeque::new); private DelegationContext() {} - /** Current delegation depth (0 = top-level call, not inside any delegation) */ + /** + * Current delegation depth (0 = top-level call, not inside any delegation). + *

Read from the TOP frame's recorded depth, NOT the thread-local stack + * size: async / parallel children run on fresh executor threads where the + * stack starts empty, so a size-based depth would reset to 1 at every async + * hop and let a child bypass {@code MAX_DELEGATION_DEPTH}. The real tree + * depth is carried in via {@link #enter(String, Set, String, String, int)}. + */ public static int currentDepth() { - return STACK.get().size(); + Frame top = STACK.get().peek(); + return top != null ? top.depth : 0; + } + + /** Depth for the next layer when the caller doesn't pass one explicitly. */ + private static int nextDepth() { + Frame top = STACK.get().peek(); + return (top != null ? top.depth : 0) + 1; } /** Parent conversation ID for event relay (from the current frame) */ @@ -64,22 +78,35 @@ public final class DelegationContext { /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */ public static void enter(String parentConversationId, Set deniedTools) { - enter(parentConversationId, deniedTools, null, null); + enter(parentConversationId, deniedTools, null, null, nextDepth()); } /** * Enter the next delegation layer carrying the full tree identity so deeper * children can broadcast to the root conversation and tag their parent. + * Depth is inferred from the current frame; use the explicit-depth overload + * from executor threads where the stack starts empty. */ public static void enter(String parentConversationId, Set deniedTools, String rootConversationId, String currentSubagentId) { + enter(parentConversationId, deniedTools, rootConversationId, currentSubagentId, nextDepth()); + } + + /** + * Enter the next delegation layer with an EXPLICIT tree depth. Async / + * parallel children run on fresh executor threads with an empty stack, so + * they must pass the real {@code childDepth} computed on the dispatching + * thread — otherwise depth-based recursion limits reset at every hop. + */ + public static void enter(String parentConversationId, Set deniedTools, + String rootConversationId, String currentSubagentId, int depth) { STACK.get().push(new Frame(parentConversationId, deniedTools, - rootConversationId, currentSubagentId)); + rootConversationId, currentSubagentId, depth)); } /** Enter the next delegation layer (backward-compatible overload) */ public static void enter() { - enter(null, null, null, null); + enter(null, null, null, null, nextDepth()); } /** Exit the current delegation layer, restoring the previous layer's context */ diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java index e26d4a3d..4ddb4e3a 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/controller/WorkspaceController.java @@ -9,6 +9,7 @@ import vip.mate.auth.model.UserEntity; import vip.mate.auth.service.AuthService; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; +import vip.mate.workspace.core.annotation.RequireGlobalAdmin; import vip.mate.workspace.core.model.WorkspaceAccessVO; import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.model.WorkspaceMemberEntity; @@ -58,6 +59,7 @@ public class WorkspaceController { @Operation(summary = "创建工作区") @PostMapping + @RequireGlobalAdmin public R create(@RequestBody WorkspaceEntity entity, Authentication auth) { Long userId = resolveUserId(auth); return R.ok(workspaceService.create(entity, userId)); @@ -85,7 +87,11 @@ public class WorkspaceController { @Operation(summary = "获取工作区成员列表") @GetMapping("/{id}/members") - public R> listMembers(@PathVariable Long id) { + public R> listMembers(@PathVariable Long id, Authentication auth) { + UserEntity currentUser = resolveUser(auth); + if (!isGlobalAdmin(currentUser)) { + workspaceService.requirePermission(id, currentUser.getId(), "viewer"); + } List members = workspaceService.listMembers(id); // 填充用户名/昵称 for (WorkspaceMemberEntity m : members) { @@ -124,12 +130,11 @@ public class WorkspaceController { newUser.setNickname(body.containsKey("nickname") ? body.get("nickname").toString() : username); target = authService.createUser(newUser); - } else if (password != null && !password.isBlank()) { - // User exists AND admin provided a password — reset it. - // This fixes the case where an admin removes a member, re-adds - // them with a new password, but the stale password blocks login. - authService.resetPassword(target.getId(), password); } + // Existing users are added as-is. A workspace admin must NOT be able + // to reset another account's password (including a global admin's) + // through the member-add path — that would be an account-takeover + // vector. Password changes go through the dedicated reset flow. targetUserId = target.getId(); } else { targetUserId = Long.valueOf(body.get("userId").toString()); @@ -139,22 +144,26 @@ public class WorkspaceController { } @Operation(summary = "更新成员角色") - @PutMapping("/{id}/members/{memberId}") + @PutMapping("/{id}/members/{targetUserId}") public R updateMemberRole(@PathVariable Long id, - @PathVariable Long memberId, + @PathVariable Long targetUserId, @RequestBody Map body, Authentication auth) { Long userId = resolveUserId(auth); workspaceService.requirePermission(id, userId, "admin"); - return R.ok(workspaceService.updateMemberRole(id, memberId, body.get("role"))); + // Path variable is the member's USER id (not the membership row id): + // WorkspaceService resolves membership by (workspaceId, userId). + return R.ok(workspaceService.updateMemberRole(id, targetUserId, body.get("role"))); } @Operation(summary = "移除工作区成员") - @DeleteMapping("/{id}/members/{memberId}") - public R removeMember(@PathVariable Long id, @PathVariable Long memberId, Authentication auth) { + @DeleteMapping("/{id}/members/{targetUserId}") + public R removeMember(@PathVariable Long id, @PathVariable Long targetUserId, Authentication auth) { Long userId = resolveUserId(auth); workspaceService.requirePermission(id, userId, "admin"); - workspaceService.removeMember(id, memberId); + // Path variable is the member's USER id (not the membership row id): + // WorkspaceService resolves membership by (workspaceId, userId). + workspaceService.removeMember(id, targetUserId); return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java index dce984b3..35d87d78 100644 --- a/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java +++ b/mateclaw-server/src/main/java/vip/mate/workspace/core/service/WorkspaceService.java @@ -269,12 +269,12 @@ public class WorkspaceService { // 检查是否已是成员 WorkspaceMemberEntity existing = getMembership(workspaceId, userId); if (existing != null) { - throw new MateClawException("err.workspace.member_exists", "用户已经是该工作区的成员"); + throw new MateClawException("err.workspace.member_exists", 409, "用户已经是该工作区的成员"); } WorkspaceMemberEntity member = new WorkspaceMemberEntity(); member.setWorkspaceId(workspaceId); member.setUserId(userId); - member.setRole(role != null ? role : "member"); + member.setRole(normalizeAssignableRole(role)); memberMapper.insert(member); evictMembershipCache(workspaceId, userId); log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole()); @@ -284,24 +284,35 @@ public class WorkspaceService { public WorkspaceMemberEntity updateMemberRole(Long workspaceId, Long userId, String role) { WorkspaceMemberEntity member = getMembership(workspaceId, userId); if (member == null) { - throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员"); + throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员"); } if ("owner".equals(member.getRole())) { - throw new MateClawException("err.workspace.cannot_modify_owner", "不能修改工作区拥有者的角色"); + throw new MateClawException("err.workspace.cannot_modify_owner", 400, "不能修改工作区拥有者的角色"); } - member.setRole(role); + member.setRole(normalizeAssignableRole(role)); memberMapper.updateById(member); evictMembershipCache(workspaceId, userId); return member; } + private String normalizeAssignableRole(String role) { + String normalized = role == null || role.isBlank() ? "member" : role.trim(); + return switch (normalized) { + case "admin", "member", "viewer" -> normalized; + case "owner" -> throw new MateClawException( + "err.workspace.invalid_member_role", 400, "不能通过成员管理授予 owner 角色"); + default -> throw new MateClawException( + "err.workspace.invalid_member_role", 400, "无效的成员角色: " + normalized); + }; + } + public void removeMember(Long workspaceId, Long userId) { WorkspaceMemberEntity member = getMembership(workspaceId, userId); if (member == null) { - throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员"); + throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员"); } if ("owner".equals(member.getRole())) { - throw new MateClawException("err.workspace.cannot_remove_owner", "不能移除工作区拥有者"); + throw new MateClawException("err.workspace.cannot_remove_owner", 400, "不能移除工作区拥有者"); } memberMapper.deleteById(member.getId()); evictMembershipCache(workspaceId, userId); diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java index 5d817710..39925131 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/edge/GoalEvaluationDispatcherTest.java @@ -19,10 +19,13 @@ import static org.mockito.Mockito.when; class GoalEvaluationDispatcherTest { private OverAllState stateWith(boolean followup) { + return stateWith(followup, false); + } + + private OverAllState stateWith(boolean followup, boolean terminal) { OverAllState s = mock(OverAllState.class); - // The dispatcher only reads GOAL_FOLLOWUP_INJECTED; everything else - // can stay default. lenient().when(s.value("goal_followup_injected", false)).thenReturn(followup); + lenient().when(s.value("goal_evaluated_this_run", false)).thenReturn(terminal); return s; } @@ -49,4 +52,25 @@ class GoalEvaluationDispatcherTest { GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__"); assertEquals("__END__", d.apply(stateWith(false))); } + + // ===== Run-to-completion loop guard ===== + + @Test + void followupOnNonTerminalPass_reentersLoop() throws Exception { + // The self-continuation loop: followup injected, not a terminal pass. + GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("reasoning", d.apply(stateWith(true, false))); + } + + @Test + void followupFlagLingeringOnTerminalPass_routesToEnd() throws Exception { + // GOAL_FOLLOWUP_INJECTED uses REPLACE and is never cleared, so after a + // run-to-completion loop it can still be true on the final (completed / + // exhausted) pass. goalEvaluatedThisRun == true must win and END the run, + // otherwise the graph loops forever. + GoalEvaluationDispatcher react = new GoalEvaluationDispatcher("reasoning", "__END__"); + assertEquals("__END__", react.apply(stateWith(true, true))); + GoalEvaluationDispatcher plan = new GoalEvaluationDispatcher("plan_generation", "__END__"); + assertEquals("__END__", plan.apply(stateWith(true, true))); + } } diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java new file mode 100644 index 00000000..f65c99fc --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/plan/state/PlanStateAccessorUsageTest.java @@ -0,0 +1,33 @@ +package vip.mate.agent.graph.plan.state; + +import com.alibaba.cloud.ai.graph.OverAllState; +import org.junit.jupiter.api.Test; +import vip.mate.agent.graph.NodeStreamingChatHelper; +import vip.mate.agent.graph.state.MateClawStateKeys; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PlanStateAccessorUsageTest { + + @Test + void mergeUsageAlsoIncrementsSharedLlmCallCount() { + Map state = new HashMap<>(); + state.put(MateClawStateKeys.PROMPT_TOKENS, 10); + state.put(MateClawStateKeys.COMPLETION_TOKENS, 20); + state.put(MateClawStateKeys.LLM_CALL_COUNT, 2); + NodeStreamingChatHelper.StreamResult result = + new NodeStreamingChatHelper.StreamResult("ok", "", null, List.of(), false, 3, 4); + + Map output = PlanStateAccessor.output() + .mergeUsage(new OverAllState(state), result) + .build(); + + assertEquals(13, output.get(MateClawStateKeys.PROMPT_TOKENS)); + assertEquals(24, output.get(MateClawStateKeys.COMPLETION_TOKENS)); + assertEquals(3, output.get(MateClawStateKeys.LLM_CALL_COUNT)); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java new file mode 100644 index 00000000..ccc7d50b --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/channel/ChannelMessageRouterApprovalDenyTest.java @@ -0,0 +1,67 @@ +package vip.mate.channel; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import vip.mate.agent.AgentService; +import vip.mate.approval.ApprovalWorkflowService; +import vip.mate.approval.PendingApproval; +import vip.mate.approval.ResolveOutcome; +import vip.mate.channel.model.ChannelEntity; +import vip.mate.channel.notification.ApprovalNotificationService; +import vip.mate.channel.service.ChannelService; +import vip.mate.channel.web.ChatStreamTracker; +import vip.mate.memory.event.ConversationCompletionPublisher; +import vip.mate.tts.TtsService; +import vip.mate.workspace.conversation.ConversationService; + +import java.lang.reflect.Method; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class ChannelMessageRouterApprovalDenyTest { + + @Test + void denyAlreadyResolvedDoesNotRewriteConversationOrBroadcastDeniedHint() throws Exception { + AgentService agentService = mock(AgentService.class); + ConversationService conversationService = mock(ConversationService.class); + ChannelService channelService = mock(ChannelService.class); + ChannelSessionStore channelSessionStore = mock(ChannelSessionStore.class); + ApprovalWorkflowService approvalService = mock(ApprovalWorkflowService.class); + ApprovalNotificationService approvalNotificationService = mock(ApprovalNotificationService.class); + ConversationCompletionPublisher completionPublisher = mock(ConversationCompletionPublisher.class); + TtsService ttsService = mock(TtsService.class); + ChatStreamTracker streamTracker = mock(ChatStreamTracker.class); + ChannelChatOriginFactory chatOriginFactory = mock(ChannelChatOriginFactory.class); + ChannelErrorClassifier errorClassifier = mock(ChannelErrorClassifier.class); + ChannelMessageRouter router = new ChannelMessageRouter(agentService, conversationService, + channelService, channelSessionStore, approvalService, approvalNotificationService, + completionPublisher, ttsService, new ObjectMapper(), streamTracker, + chatOriginFactory, errorClassifier); + + PendingApproval pending = new PendingApproval("abcdef123", "conv-1", "alice", + "dangerous_tool", "{}", "needs approval"); + when(approvalService.findPendingByConversation("conv-1")).thenReturn(pending); + when(approvalService.resolve("abcdef123", "alice", "denied")) + .thenReturn(ResolveOutcome.alreadyResolved("abcdef123")); + ChannelAdapter adapter = mock(ChannelAdapter.class); + when(adapter.getChannelType()).thenReturn("test"); + ChannelEntity channel = new ChannelEntity(); + channel.setAgentId(100L); + ChannelMessage message = ChannelMessage.builder() + .senderId("alice") + .replyToken("reply-1") + .content("/deny abcdef") + .build(); + + Method process = ChannelMessageRouter.class.getDeclaredMethod( + "processMessage", ChannelMessage.class, ChannelAdapter.class, ChannelEntity.class, String.class); + process.setAccessible(true); + process.invoke(router, message, adapter, channel, "conv-1"); + + verify(conversationService, never()).removeApprovalPlaceholders(anyString()); + verify(conversationService, never()).saveMessage(anyString(), anyString(), anyString(), any(), anyString()); + verify(adapter).sendMessage("reply-1", "⚠️ 审批记录已过期或已被处理。"); + verify(adapter, never()).sendMessage(eq("reply-1"), startsWith("⛔ 已拒绝执行工具")); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java index 0331a726..99b95789 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegationContextTest.java @@ -5,6 +5,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import static org.junit.jupiter.api.Assertions.*; @@ -149,4 +152,38 @@ class DelegationContextTest { assertEquals(1, DelegationContext.currentDepth()); assertEquals("main-thread-conv", DelegationContext.parentConversationId()); } + + // ===== Explicit depth (async/parallel recursion-cap bypass guard) ===== + + @Test + @DisplayName("Explicit-depth enter reports the depth verbatim, not stack size") + void explicitDepthReportedVerbatim() { + DelegationContext.enter("conv", Set.of(), "root", "sub", 3); + assertEquals(3, DelegationContext.currentDepth()); + DelegationContext.exit(); + assertEquals(0, DelegationContext.currentDepth()); + } + + @Test + @DisplayName("Explicit childDepth survives the executor-thread hop (async/parallel bypass guard)") + void explicitDepthSurvivesExecutorThreadHop() throws Exception { + // Async/parallel children run on a fresh executor thread with an EMPTY + // stack. Before the fix, currentDepth() used stack size and reset to 1 + // here, letting a child exceed MAX_DELEGATION_DEPTH at every hop. + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future f = executor.submit(() -> { + assertEquals(0, DelegationContext.currentDepth()); // fresh thread, empty stack + // Enter with the real tree depth computed on the dispatching thread. + DelegationContext.enter("childConv", Set.of(), "root", "sub", 3); + int observed = DelegationContext.currentDepth(); + DelegationContext.exit(); + return observed; + }); + assertEquals(3, f.get(), + "child on a fresh thread must observe the explicit childDepth, not the stack size"); + } finally { + executor.shutdownNow(); + } + } } diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java new file mode 100644 index 00000000..b928c4c2 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/controller/WorkspaceControllerMembersAuthTest.java @@ -0,0 +1,60 @@ +package vip.mate.workspace.core.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.Authentication; +import vip.mate.auth.model.UserEntity; +import vip.mate.auth.service.AuthService; +import vip.mate.workspace.core.service.WorkspaceService; + +import java.lang.reflect.Method; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.*; + +class WorkspaceControllerMembersAuthTest { + + @Test + void listMembersRequiresViewerPermissionForNonGlobalAdmin() throws Exception { + WorkspaceService workspaceService = mock(WorkspaceService.class); + AuthService authService = mock(AuthService.class); + WorkspaceController controller = new WorkspaceController(workspaceService, authService); + UserEntity user = new UserEntity(); + user.setId(42L); + user.setUsername("alice"); + user.setRole("user"); + when(authService.findByUsername("alice")).thenReturn(user); + when(workspaceService.listMembers(7L)).thenReturn(List.of()); + + invokeListMembers(controller, 7L, new TestingAuthenticationToken("alice", "pw")); + + verify(workspaceService).requirePermission(7L, 42L, "viewer"); + verify(workspaceService).listMembers(7L); + } + + @Test + void listMembersLetsGlobalAdminBypassWorkspaceMembership() throws Exception { + WorkspaceService workspaceService = mock(WorkspaceService.class); + AuthService authService = mock(AuthService.class); + WorkspaceController controller = new WorkspaceController(workspaceService, authService); + UserEntity user = new UserEntity(); + user.setId(1L); + user.setUsername("admin"); + user.setRole("admin"); + when(authService.findByUsername("admin")).thenReturn(user); + when(workspaceService.listMembers(7L)).thenReturn(List.of()); + + invokeListMembers(controller, 7L, new TestingAuthenticationToken("admin", "pw")); + + verify(workspaceService, never()).requirePermission(anyLong(), anyLong(), anyString()); + verify(workspaceService).listMembers(7L); + } + + private void invokeListMembers(WorkspaceController controller, Long workspaceId, + Authentication auth) throws Exception { + Method method = WorkspaceController.class.getMethod("listMembers", Long.class, Authentication.class); + assertNotNull(method); + method.invoke(controller, workspaceId, auth); + } +} diff --git a/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java new file mode 100644 index 00000000..401affec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/workspace/core/service/WorkspaceServiceRoleValidationTest.java @@ -0,0 +1,84 @@ +package vip.mate.workspace.core.service; + +import org.junit.jupiter.api.Test; +import vip.mate.exception.MateClawException; +import vip.mate.workspace.conversation.repository.ConversationMapper; +import vip.mate.workspace.core.model.WorkspaceEntity; +import vip.mate.workspace.core.model.WorkspaceMemberEntity; +import vip.mate.workspace.core.repository.WorkspaceMapper; +import vip.mate.workspace.core.repository.WorkspaceMemberMapper; +import vip.mate.wiki.service.WikiKnowledgeBaseService; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class WorkspaceServiceRoleValidationTest { + + private final WorkspaceMapper workspaceMapper = mock(WorkspaceMapper.class); + private final WorkspaceMemberMapper memberMapper = mock(WorkspaceMemberMapper.class); + private final ConversationMapper conversationMapper = mock(ConversationMapper.class); + private final WikiKnowledgeBaseService wikiKnowledgeBaseService = mock(WikiKnowledgeBaseService.class); + private final WorkspaceService service = new WorkspaceService( + workspaceMapper, memberMapper, conversationMapper, wikiKnowledgeBaseService, null); + + @Test + void addMemberRejectsOwnerRole() { + WorkspaceEntity workspace = new WorkspaceEntity(); + workspace.setId(1L); + when(workspaceMapper.selectById(1L)).thenReturn(workspace); + when(memberMapper.selectOne(any())).thenReturn(null); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.addMember(1L, 42L, "owner")); + + assertEquals(400, ex.getCode()); + assertEquals("err.workspace.invalid_member_role", ex.getMsgKey()); + verify(memberMapper, never()).insert(any(WorkspaceMemberEntity.class)); + } + + @Test + void updateMemberRoleRejectsOwnerEscalation() { + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(1L); + member.setUserId(42L); + member.setRole("admin"); + when(memberMapper.selectOne(any())).thenReturn(member); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.updateMemberRole(1L, 42L, "owner")); + + assertEquals(400, ex.getCode()); + assertEquals("err.workspace.invalid_member_role", ex.getMsgKey()); + verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class)); + } + + @Test + void addMemberDefaultsMissingRoleToMember() { + WorkspaceEntity workspace = new WorkspaceEntity(); + workspace.setId(1L); + when(workspaceMapper.selectById(1L)).thenReturn(workspace); + when(memberMapper.selectOne(any())).thenReturn(null); + + WorkspaceMemberEntity member = service.addMember(1L, 42L, null); + + assertEquals("member", member.getRole()); + verify(memberMapper).insert(any(WorkspaceMemberEntity.class)); + } + + @Test + void updateMemberRoleRejectsUnknownRole() { + WorkspaceMemberEntity member = new WorkspaceMemberEntity(); + member.setWorkspaceId(1L); + member.setUserId(42L); + member.setRole("member"); + when(memberMapper.selectOne(any())).thenReturn(member); + + MateClawException ex = assertThrows(MateClawException.class, + () -> service.updateMemberRole(1L, 42L, "superuser")); + + assertEquals(400, ex.getCode()); + verify(memberMapper, never()).updateById(any(WorkspaceMemberEntity.class)); + } +}