Harden goal approval and workspace flows

This commit is contained in:
matevip 2026-05-23 22:55:16 +08:00
parent 5cd6e841a4
commit a9c2d45790
26 changed files with 618 additions and 111 deletions

View File

@ -519,6 +519,7 @@ public class AgentGraphBuilder {
// Token Usage // Token Usage
.addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.PROMPT_TOKENS, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.COMPLETION_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_MODEL_NAME, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.RUNTIME_PROVIDER_ID, KeyStrategy.REPLACE)
// SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的 // SourceEvidenceLedger: ActionNode 把每轮 ToolResponse 抽取出的
@ -540,6 +541,8 @@ public class AgentGraphBuilder {
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, 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 // Skill progressive disclosure pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in // run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges. // 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_INJECTED, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE) .addStrategy(MateClawStateKeys.GOAL_FOLLOWUP_PROMPT, KeyStrategy.REPLACE)
.addStrategy(MateClawStateKeys.GOAL_EVALUATED_THIS_RUN, 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 // Skill progressive disclosure pinned skills loaded this
// run. Registered in BOTH graphs so the read-merge-write in // run. Registered in BOTH graphs so the read-merge-write in
// ActionNode is not dropped on multi-node merges. // ActionNode is not dropped on multi-node merges.

View File

@ -5,6 +5,7 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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; import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
/** /**
@ -29,8 +30,15 @@ public class GoalEvaluationDispatcher implements EdgeAction {
@Override @Override
public String apply(OverAllState state) { 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)); 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); log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
return followupTarget; return followupTarget;
} }

View File

@ -160,10 +160,13 @@ public class GoalEvaluationNode implements NodeAction {
try { try {
result = evaluationService.evaluate(goal, recent, terminal); result = evaluationService.evaluate(goal, recent, terminal);
// Pre-eval agent_llm count snapshot the bookkeeping helper // Bill only the NEW agent LLM calls since the last accounted point.
// folds it into the per-goal counter so future turns see // The run-to-completion loop evaluates multiple times per graph run
// growing usage. // while LLM_CALL_COUNT keeps growing, so passing the cumulative value
int agentLlmDelta = accessor.llmCallCount(); // 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(); int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta); goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
@ -219,6 +222,7 @@ public class GoalEvaluationNode implements NodeAction {
.build(); .build();
} }
int followupCountThisRun = accessor.goalFollowupCount();
Optional<String> followup; Optional<String> followup;
try { try {
followup = followupService.maybeBuildFollowup(refreshed, result); followup = followupService.maybeBuildFollowup(refreshed, result);
@ -227,7 +231,18 @@ public class GoalEvaluationNode implements NodeAction {
refreshed.getId(), t.toString()); refreshed.getId(), t.toString());
followup = Optional.empty(); 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 { try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get()); goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) { } catch (Throwable t) {
@ -240,7 +255,18 @@ public class GoalEvaluationNode implements NodeAction {
.goalEvaluationResult(result.toMap()) .goalEvaluationResult(result.toMap())
.goalFollowupInjected(true) .goalFollowupInjected(true)
.goalFollowupPrompt(followup.get()) .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) .needsToolCall(false)
.events(List.of(goalEvent("goal_followup", Map.of( .events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()), "goalId", String.valueOf(refreshed.getId()),

View File

@ -246,8 +246,10 @@ public final class PlanStateAccessor {
NodeStreamingChatHelper.StreamResult result) { NodeStreamingChatHelper.StreamResult result) {
int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0); int existingPrompt = currentState.value(MateClawStateKeys.PROMPT_TOKENS, 0);
int existingCompletion = currentState.value(MateClawStateKeys.COMPLETION_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.PROMPT_TOKENS, existingPrompt + result.promptTokens());
map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens()); map.put(MateClawStateKeys.COMPLETION_TOKENS, existingCompletion + result.completionTokens());
map.put(MateClawStateKeys.LLM_CALL_COUNT, existingLlmCalls + 1);
return this; return this;
} }

View File

@ -293,6 +293,16 @@ public final class MateClawStateAccessor {
return state.value(GOAL_FOLLOWUP_PROMPT, ""); 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 * Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode; * to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
@ -534,6 +544,14 @@ public final class MateClawStateAccessor {
return put(GOAL_EVALUATED_THIS_RUN, v); 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 /** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
* immediately re-terminate via the existing final text. */ * immediately re-terminate via the existing final text. */
public OutputBuilder clearFinalAnswer() { public OutputBuilder clearFinalAnswer() {

View File

@ -200,14 +200,34 @@ public final class MateClawStateKeys {
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt"; public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
/** /**
* Re-entry guard: GoalEvaluationNode sets this true on its first run * Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
* of a graph invocation; the FinalAnswerNodeGoalEvaluation conditional * this true only when it ENDS the run (completed / exhausted / skip /
* edge skips re-entering the node when it's already true. Combined with * continue-without-followup). The FinalAnswerNodeGoalEvaluation edge skips
* the dispatcher's followup clearing of FINAL_ANSWER, this bounds * re-entering once it's true. The followup branch deliberately leaves it
* follow-ups to at most one per graph run. * 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"; 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. */ /** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation"; public static final String GOAL_EVALUATION_NODE = "goal_evaluation";

View File

@ -1,6 +1,7 @@
package vip.mate.auth.model; package vip.mate.auth.model;
import com.baomidou.mybatisplus.annotation.*; import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -20,7 +21,12 @@ public class UserEntity {
/** 用户名 */ /** 用户名 */
private String username; 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; private String password;
/** 昵称 */ /** 昵称 */

View File

@ -488,6 +488,47 @@ public class ChannelMessageRouter {
return null; 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 逻辑 + 审批拦截层 ==================== // ==================== 消息处理 route 逻辑 + 审批拦截层 ====================
/** /**
@ -510,20 +551,12 @@ public class ChannelMessageRouter {
String replyTarget = resolveReplyTarget(message); String replyTarget = resolveReplyTarget(message);
if (isApproveCommand(userText)) { if (isApproveCommand(userText)) {
// pendingId 校验如果命令包含 shortId验证是否匹配当前 pending // pendingId 校验approve / deny 共用命令带 shortId 时必须匹配当前 pending
String shortId = extractShortId(userText); if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return; return;
} }
// 身份校验只有原始请求者可以审批群聊安全 // 身份校验approve / deny 共用同一道门禁群聊安全 + system/null fail-closed
String originalRequester = pending.getUserId(); if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
if (originalRequester != null && !"system".equals(originalRequester)
&& !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval rejected: sender={} != requester={}",
adapter.getChannelType(), message.getSenderId(), originalRequester);
return; return;
} }
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically. // Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
@ -542,9 +575,24 @@ public class ChannelMessageRouter {
return; return;
} else if (isDenyCommand(userText)) { } else if (isDenyCommand(userText)) {
// pendingId 校验 approve 一致命令带 shortId 时必须匹配当前 pending
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation pending
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验deny approve 共用门禁否则群里任意成员可拒绝/取消他人的
// pendingsystem/null 发起的审批也会被任意人 deny取消审批 placeholder
// 写入 denied 状态这类审批改到管理端处理
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
return;
}
// Deny via IM: workflow.resolve owns the full state-machine transition. // Deny via IM: workflow.resolve owns the full state-machine transition.
ResolveOutcome denyOutcome = approvalService.resolve( ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied"); pending.getPendingId(), message.getSenderId(), "denied");
if (denyOutcome.isAlreadyResolved()) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
conversationService.removeApprovalPlaceholders(conversationId); conversationService.removeApprovalPlaceholders(conversationId);
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName(); String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
persistAndBroadcastApprovalHint(conversationId, denyHint, persistAndBroadcastApprovalHint(conversationId, denyHint,

View File

@ -100,11 +100,16 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
} }
PendingApproval pending = opt.get(); 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(); String originalRequester = pending.getUserId();
boolean authorized = originalRequester == null boolean authorized = originalRequester != null
|| "system".equals(originalRequester) && !"system".equals(originalRequester)
|| originalRequester.equals(clickerOpenId); && originalRequester.equals(clickerOpenId);
if (!authorized) { if (!authorized) {
log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}", log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerOpenId), abbrev(originalRequester), pendingId); abbrev(clickerOpenId), abbrev(originalRequester), pendingId);

View File

@ -75,11 +75,16 @@ public class ToolGuardCardHandler implements WeComCardHandler {
} }
PendingApproval pending = opt.get(); 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(); String originalRequester = pending.getUserId();
boolean isAuthorized = originalRequester == null boolean isAuthorized = originalRequester != null
|| "system".equals(originalRequester) && !"system".equals(originalRequester)
|| originalRequester.equals(clickerUserId); && originalRequester.equals(clickerUserId);
if (!isAuthorized) { if (!isAuthorized) {
log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}", log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerUserId), abbrev(originalRequester), pendingId); abbrev(clickerUserId), abbrev(originalRequester), pendingId);

View File

@ -10,7 +10,7 @@ import vip.mate.datasource.service.DatasourceService;
import java.util.List; import java.util.List;
import java.util.Map; 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 = "获取数据源列表") @Operation(summary = "获取数据源列表")
@GetMapping @GetMapping
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<List<DatasourceEntity>> list() { public R<List<DatasourceEntity>> list() {
return R.ok(datasourceService.listAll()); return R.ok(datasourceService.listAll());
} }
@Operation(summary = "获取数据源详情") @Operation(summary = "获取数据源详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DatasourceEntity> get(@PathVariable Long id) { public R<DatasourceEntity> get(@PathVariable Long id) {
return R.ok(datasourceService.getByIdMasked(id)); return R.ok(datasourceService.getByIdMasked(id));
} }
@Operation(summary = "创建数据源") @Operation(summary = "创建数据源")
@PostMapping @PostMapping
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DatasourceEntity> create(@RequestBody DatasourceEntity entity) { public R<DatasourceEntity> create(@RequestBody DatasourceEntity entity) {
return R.ok(datasourceService.create(entity)); return R.ok(datasourceService.create(entity));
} }
@Operation(summary = "更新数据源") @Operation(summary = "更新数据源")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DatasourceEntity> update(@PathVariable Long id, @RequestBody DatasourceEntity entity) { public R<DatasourceEntity> update(@PathVariable Long id, @RequestBody DatasourceEntity entity) {
entity.setId(id); entity.setId(id);
return R.ok(datasourceService.update(entity)); return R.ok(datasourceService.update(entity));
@ -56,7 +56,7 @@ public class DatasourceController {
@Operation(summary = "删除数据源") @Operation(summary = "删除数据源")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> delete(@PathVariable Long id) { public R<Void> delete(@PathVariable Long id) {
datasourceService.delete(id); datasourceService.delete(id);
return R.ok(); return R.ok();
@ -64,7 +64,7 @@ public class DatasourceController {
@Operation(summary = "测试数据源连接") @Operation(summary = "测试数据源连接")
@PostMapping("/{id}/test") @PostMapping("/{id}/test")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Map<String, Object>> testConnection(@PathVariable Long id) { public R<Map<String, Object>> testConnection(@PathVariable Long id) {
boolean ok = datasourceService.testConnection(id); boolean ok = datasourceService.testConnection(id);
return R.ok(Map.of("success", ok, "message", ok ? "连接成功" : "连接失败")); return R.ok(Map.of("success", ok, "message", ok ? "连接成功" : "连接失败"));
@ -72,7 +72,7 @@ public class DatasourceController {
@Operation(summary = "启用/禁用数据源") @Operation(summary = "启用/禁用数据源")
@PutMapping("/{id}/toggle") @PutMapping("/{id}/toggle")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DatasourceEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) { public R<DatasourceEntity> toggle(@PathVariable Long id, @RequestParam boolean enabled) {
return R.ok(datasourceService.toggle(id, enabled)); return R.ok(datasourceService.toggle(id, enabled));
} }

View File

@ -35,6 +35,15 @@ public class GoalProperties {
/** Default cooldown between auto-followups in seconds. */ /** Default cooldown between auto-followups in seconds. */
private int autoFollowupCooldownSeconds = 0; 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 * Provider/model id for the evaluator. Empty string means "use the
* same model as the chat agent" — convenient for dev, expensive in * same model as the chat agent" — convenient for dev, expensive in

View File

@ -11,7 +11,7 @@ import org.springframework.web.bind.annotation.RestController;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService; import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService;
import vip.mate.llm.anthropic.oauth.ClaudeCodeOAuthService.OAuthStatus; 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. * 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") @Operation(summary = "Read current Claude Code OAuth credential status from local disk")
@GetMapping("/status") @GetMapping("/status")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<OAuthStatus> status() { public R<OAuthStatus> status() {
return R.ok(oauthService.getStatus()); return R.ok(oauthService.getStatus());
} }
@ -62,7 +62,7 @@ public class ClaudeCodeOAuthController {
*/ */
@Operation(summary = "Force re-detect credentials and refresh if near expiry") @Operation(summary = "Force re-detect credentials and refresh if near expiry")
@PostMapping("/reload") @PostMapping("/reload")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<OAuthStatus> reload() { public R<OAuthStatus> reload() {
try { try {
oauthService.getValidToken(); oauthService.getValidToken();

View File

@ -21,6 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@Slf4j @Slf4j
@ -40,28 +41,28 @@ public class ModelConfigController {
@Operation(summary = "获取 Provider 列表(仅 enabled") @Operation(summary = "获取 Provider 列表(仅 enabled")
@GetMapping @GetMapping
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<List<ProviderInfoDTO>> list() { public R<List<ProviderInfoDTO>> list() {
return R.ok(modelProviderService.listProviders()); return R.ok(modelProviderService.listProviders());
} }
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用") @Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
@GetMapping("/catalog") @GetMapping("/catalog")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<List<ProviderInfoDTO>> catalog() { public R<List<ProviderInfoDTO>> catalog() {
return R.ok(modelProviderService.listCatalog()); return R.ok(modelProviderService.listCatalog());
} }
@Operation(summary = "RFC-074: 启用 Provider") @Operation(summary = "RFC-074: 启用 Provider")
@PostMapping("/{providerId}/enable") @PostMapping("/{providerId}/enable")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<EnableResult> enableProvider(@PathVariable String providerId) { public R<EnableResult> enableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, true)); return R.ok(modelProviderService.setEnabled(providerId, true));
} }
@Operation(summary = "RFC-074: 禁用 Provider如其下模型为当前默认会自动切换") @Operation(summary = "RFC-074: 禁用 Provider如其下模型为当前默认会自动切换")
@PostMapping("/{providerId}/disable") @PostMapping("/{providerId}/disable")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<EnableResult> disableProvider(@PathVariable String providerId) { public R<EnableResult> disableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, false)); return R.ok(modelProviderService.setEnabled(providerId, false));
} }
@ -97,7 +98,7 @@ public class ModelConfigController {
@Operation(summary = "设置当前激活模型") @Operation(summary = "设置当前激活模型")
@PutMapping("/active") @PutMapping("/active")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ActiveModelsInfo> setActiveModel(@RequestBody ModelSlotRequest request) { public R<ActiveModelsInfo> setActiveModel(@RequestBody ModelSlotRequest request) {
ModelConfigEntity model = modelConfigService.setDefaultModel(request.getProviderId(), request.getModel()); ModelConfigEntity model = modelConfigService.setDefaultModel(request.getProviderId(), request.getModel());
ActiveModelsInfo info = new ActiveModelsInfo(); ActiveModelsInfo info = new ActiveModelsInfo();
@ -107,7 +108,7 @@ public class ModelConfigController {
@Operation(summary = "更新 Provider 配置") @Operation(summary = "更新 Provider 配置")
@PutMapping("/{providerId}/config") @PutMapping("/{providerId}/config")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ProviderInfoDTO> updateProviderConfig(@PathVariable String providerId, public R<ProviderInfoDTO> updateProviderConfig(@PathVariable String providerId,
@RequestBody ProviderConfigRequest request) { @RequestBody ProviderConfigRequest request) {
ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request); ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request);
@ -118,14 +119,14 @@ public class ModelConfigController {
@Operation(summary = "创建自定义 Provider") @Operation(summary = "创建自定义 Provider")
@PostMapping("/custom-providers") @PostMapping("/custom-providers")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ProviderInfoDTO> createCustomProvider(@RequestBody CreateCustomProviderRequest request) { public R<ProviderInfoDTO> createCustomProvider(@RequestBody CreateCustomProviderRequest request) {
return R.ok(modelProviderService.createCustomProvider(request)); return R.ok(modelProviderService.createCustomProvider(request));
} }
@Operation(summary = "删除自定义 Provider") @Operation(summary = "删除自定义 Provider")
@DeleteMapping("/custom-providers/{providerId}") @DeleteMapping("/custom-providers/{providerId}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> deleteCustomProvider(@PathVariable String providerId) { public R<Void> deleteCustomProvider(@PathVariable String providerId) {
modelProviderService.deleteCustomProvider(providerId); modelProviderService.deleteCustomProvider(providerId);
return R.ok(); return R.ok();
@ -141,7 +142,7 @@ public class ModelConfigController {
*/ */
@Operation(summary = "删除自定义 Provider查询参数变体兼容含特殊字符的旧 ID") @Operation(summary = "删除自定义 Provider查询参数变体兼容含特殊字符的旧 ID")
@DeleteMapping("/custom-providers") @DeleteMapping("/custom-providers")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) { public R<Void> deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) {
modelProviderService.deleteCustomProvider(providerId); modelProviderService.deleteCustomProvider(providerId);
return R.ok(); return R.ok();
@ -149,7 +150,7 @@ public class ModelConfigController {
@Operation(summary = "向 Provider 添加模型") @Operation(summary = "向 Provider 添加模型")
@PostMapping("/{providerId}/models") @PostMapping("/{providerId}/models")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId, public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId,
@RequestBody AddProviderModelRequest request) { @RequestBody AddProviderModelRequest request) {
return R.ok(modelProviderService.addModel(providerId, request)); return R.ok(modelProviderService.addModel(providerId, request));
@ -157,7 +158,7 @@ public class ModelConfigController {
@Operation(summary = "从 Provider 删除模型") @Operation(summary = "从 Provider 删除模型")
@DeleteMapping("/{providerId}/models") @DeleteMapping("/{providerId}/models")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ProviderInfoDTO> removeProviderModel(@PathVariable String providerId, public R<ProviderInfoDTO> removeProviderModel(@PathVariable String providerId,
@RequestParam String modelId) { @RequestParam String modelId) {
return R.ok(modelProviderService.removeModel(providerId, modelId)); return R.ok(modelProviderService.removeModel(providerId, modelId));
@ -165,21 +166,21 @@ public class ModelConfigController {
@Operation(summary = "获取模型详情") @Operation(summary = "获取模型详情")
@GetMapping("/{id}") @GetMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ModelConfigEntity> get(@PathVariable Long id) { public R<ModelConfigEntity> get(@PathVariable Long id) {
return R.ok(modelConfigService.getModel(id)); return R.ok(modelConfigService.getModel(id));
} }
@Operation(summary = "创建模型") @Operation(summary = "创建模型")
@PostMapping @PostMapping
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ModelConfigEntity> create(@RequestBody ModelConfigEntity entity) { public R<ModelConfigEntity> create(@RequestBody ModelConfigEntity entity) {
return R.ok(modelConfigService.createModel(entity)); return R.ok(modelConfigService.createModel(entity));
} }
@Operation(summary = "更新模型") @Operation(summary = "更新模型")
@PutMapping("/{id}") @PutMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ModelConfigEntity> update(@PathVariable Long id, @RequestBody ModelConfigEntity entity) { public R<ModelConfigEntity> update(@PathVariable Long id, @RequestBody ModelConfigEntity entity) {
entity.setId(id); entity.setId(id);
return R.ok(modelConfigService.updateModel(entity)); return R.ok(modelConfigService.updateModel(entity));
@ -187,7 +188,7 @@ public class ModelConfigController {
@Operation(summary = "删除模型") @Operation(summary = "删除模型")
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> delete(@PathVariable Long id) { public R<Void> delete(@PathVariable Long id) {
modelConfigService.deleteModel(id); modelConfigService.deleteModel(id);
return R.ok(); return R.ok();
@ -195,7 +196,7 @@ public class ModelConfigController {
@Operation(summary = "设置默认模型") @Operation(summary = "设置默认模型")
@PostMapping("/{id}/default") @PostMapping("/{id}/default")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ModelConfigEntity> setDefault(@PathVariable Long id) { public R<ModelConfigEntity> setDefault(@PathVariable Long id) {
return R.ok(modelConfigService.setDefaultModel(id)); return R.ok(modelConfigService.setDefaultModel(id));
} }
@ -204,14 +205,14 @@ public class ModelConfigController {
@Operation(summary = "发现远端模型") @Operation(summary = "发现远端模型")
@PostMapping("/{providerId}/discover") @PostMapping("/{providerId}/discover")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DiscoverResult> discoverModels(@PathVariable String providerId) { public R<DiscoverResult> discoverModels(@PathVariable String providerId) {
return R.ok(modelDiscoveryService.discoverModels(providerId)); return R.ok(modelDiscoveryService.discoverModels(providerId));
} }
@Operation(summary = "批量添加发现的模型") @Operation(summary = "批量添加发现的模型")
@PostMapping("/{providerId}/discover/apply") @PostMapping("/{providerId}/discover/apply")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Map<String, Integer>> applyDiscoveredModels(@PathVariable String providerId, public R<Map<String, Integer>> applyDiscoveredModels(@PathVariable String providerId,
@RequestBody ApplyDiscoveredModelsRequest request) { @RequestBody ApplyDiscoveredModelsRequest request) {
int added = modelDiscoveryService.batchAddModels(providerId, request.getModelIds()); int added = modelDiscoveryService.batchAddModels(providerId, request.getModelIds());
@ -220,14 +221,14 @@ public class ModelConfigController {
@Operation(summary = "测试供应商连接") @Operation(summary = "测试供应商连接")
@PostMapping("/{providerId}/test-connection") @PostMapping("/{providerId}/test-connection")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<TestResult> testConnection(@PathVariable String providerId) { public R<TestResult> testConnection(@PathVariable String providerId) {
return R.ok(modelDiscoveryService.testConnection(providerId)); return R.ok(modelDiscoveryService.testConnection(providerId));
} }
@Operation(summary = "测试单个模型可用性") @Operation(summary = "测试单个模型可用性")
@PostMapping("/{providerId}/models/test") @PostMapping("/{providerId}/models/test")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<TestResult> testModel(@PathVariable String providerId, public R<TestResult> testModel(@PathVariable String providerId,
@RequestParam String modelId) { @RequestParam String modelId) {
return R.ok(modelDiscoveryService.testModel(providerId, modelId)); return R.ok(modelDiscoveryService.testModel(providerId, modelId));
@ -246,7 +247,7 @@ public class ModelConfigController {
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key") @Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key")
@PostMapping("/embedding/{modelId}/test") @PostMapping("/embedding/{modelId}/test")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) { public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) {
Map<String, Object> result = new HashMap<>(); Map<String, Object> result = new HashMap<>();
try { try {
@ -279,7 +280,7 @@ public class ModelConfigController {
@Operation(summary = "获取系统默认 Embedding 模型 ID") @Operation(summary = "获取系统默认 Embedding 模型 ID")
@GetMapping("/embedding/default") @GetMapping("/embedding/default")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Map<String, Object>> getDefaultEmbedding() { public R<Map<String, Object>> getDefaultEmbedding() {
SystemSettingEntity entity = systemSettingMapper.selectOne( SystemSettingEntity entity = systemSettingMapper.selectOne(
new LambdaQueryWrapper<SystemSettingEntity>() new LambdaQueryWrapper<SystemSettingEntity>()
@ -293,7 +294,7 @@ public class ModelConfigController {
@Operation(summary = "设置系统默认 Embedding 模型") @Operation(summary = "设置系统默认 Embedding 模型")
@PostMapping("/embedding/default") @PostMapping("/embedding/default")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) { public R<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) {
Object v = body.get("modelId"); Object v = body.get("modelId");
String value = v == null ? "" : v.toString(); String value = v == null ? "" : v.toString();

View File

@ -12,7 +12,7 @@ import vip.mate.llm.oauth.OpenAIDeviceCodeService.DeviceCodeStartResult;
import vip.mate.llm.oauth.OpenAIOAuthService; import vip.mate.llm.oauth.OpenAIOAuthService;
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult; import vip.mate.llm.oauth.OpenAIOAuthService.OAuthAuthorizeResult;
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult; 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") @Tag(name = "OpenAI OAuth")
@RestController @RestController
@ -25,7 +25,7 @@ public class OAuthController {
@Operation(summary = "获取 OAuth 授权 URL自动选 LOCAL / MANUAL_PASTE 模式)") @Operation(summary = "获取 OAuth 授权 URL自动选 LOCAL / MANUAL_PASTE 模式)")
@GetMapping("/authorize") @GetMapping("/authorize")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<OAuthAuthorizeResult> authorize(HttpServletRequest request) { public R<OAuthAuthorizeResult> authorize(HttpServletRequest request) {
// Host header 判断是否远程部署 远程则不启 localhost server MANUAL_PASTE // Host header 判断是否远程部署 远程则不启 localhost server MANUAL_PASTE
// 优先 X-Forwarded-Host反向代理后的实际入口fallback Host // 优先 X-Forwarded-Host反向代理后的实际入口fallback Host
@ -44,7 +44,7 @@ public class OAuthController {
*/ */
@Operation(summary = "MANUAL_PASTE 模式:用户粘贴浏览器回调 URL 完成 OAuth") @Operation(summary = "MANUAL_PASTE 模式:用户粘贴浏览器回调 URL 完成 OAuth")
@PostMapping("/callback-paste") @PostMapping("/callback-paste")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> callbackPaste(@RequestBody PasteRequest request) { public R<Void> callbackPaste(@RequestBody PasteRequest request) {
oauthService.completeFromPastedUrl(request.callbackUrl()); oauthService.completeFromPastedUrl(request.callbackUrl());
return R.ok(); return R.ok();
@ -55,21 +55,21 @@ public class OAuthController {
@Operation(summary = "Device flow: start — request user_code") @Operation(summary = "Device flow: start — request user_code")
@PostMapping("/device/start") @PostMapping("/device/start")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DeviceCodeStartResult> deviceStart() { public R<DeviceCodeStartResult> deviceStart() {
return R.ok(deviceCodeService.start()); return R.ok(deviceCodeService.start());
} }
@Operation(summary = "Device flow: poll for completion") @Operation(summary = "Device flow: poll for completion")
@PostMapping("/device/poll") @PostMapping("/device/poll")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) { public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) {
return R.ok(deviceCodeService.poll(request.deviceAuthId())); return R.ok(deviceCodeService.poll(request.deviceAuthId()));
} }
@Operation(summary = "Device flow: cancel a pending session") @Operation(summary = "Device flow: cancel a pending session")
@PostMapping("/device/cancel") @PostMapping("/device/cancel")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> deviceCancel(@RequestBody DeviceRequest request) { public R<Void> deviceCancel(@RequestBody DeviceRequest request) {
deviceCodeService.cancel(request.deviceAuthId()); deviceCodeService.cancel(request.deviceAuthId());
return R.ok(); return R.ok();
@ -80,7 +80,7 @@ public class OAuthController {
@Operation(summary = "手动刷新 Token") @Operation(summary = "手动刷新 Token")
@PostMapping("/refresh") @PostMapping("/refresh")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> refresh() { public R<Void> refresh() {
oauthService.refreshToken(); oauthService.refreshToken();
return R.ok(); return R.ok();
@ -88,7 +88,7 @@ public class OAuthController {
@Operation(summary = "清除 OAuth 凭证") @Operation(summary = "清除 OAuth 凭证")
@DeleteMapping("/revoke") @DeleteMapping("/revoke")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<Void> revoke() { public R<Void> revoke() {
oauthService.revokeToken(); oauthService.revokeToken();
return R.ok(); return R.ok();
@ -96,7 +96,7 @@ public class OAuthController {
@Operation(summary = "获取 OAuth 连接状态") @Operation(summary = "获取 OAuth 连接状态")
@GetMapping("/status") @GetMapping("/status")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<OAuthStatusResult> status() { public R<OAuthStatusResult> status() {
return R.ok(oauthService.getStatus()); return R.ok(oauthService.getStatus());
} }

View File

@ -21,7 +21,7 @@ import vip.mate.llm.service.ModelProviderService;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map; 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. * RFC-009 Phase 4 read-only diagnostic endpoint for the provider pool.
@ -50,7 +50,7 @@ public class ProviderPoolController {
@Operation(summary = "查询所有 provider 的池状态 + 冷却信息") @Operation(summary = "查询所有 provider 的池状态 + 冷却信息")
@GetMapping @GetMapping
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<List<ProviderPoolEntryDTO>> snapshot() { public R<List<ProviderPoolEntryDTO>> snapshot() {
Map<String, RemovalReason> poolView = providerPool.snapshot(); Map<String, RemovalReason> poolView = providerPool.snapshot();
Map<String, ProviderHealthSnapshot> healthView = healthTracker.snapshot(); Map<String, ProviderHealthSnapshot> healthView = healthTracker.snapshot();
@ -83,7 +83,7 @@ public class ProviderPoolController {
@Operation(summary = "手动重新探测某个 provider立即更新池状态") @Operation(summary = "手动重新探测某个 provider立即更新池状态")
@PostMapping("/{providerId}/reprobe") @PostMapping("/{providerId}/reprobe")
@RequireWorkspaceRole("admin") @RequireGlobalAdmin
public R<ReprobeResultDTO> reprobe(@PathVariable String providerId) { public R<ReprobeResultDTO> reprobe(@PathVariable String providerId) {
ProbeResult result = initProbe.probeOne(providerId); ProbeResult result = initProbe.probeOne(providerId);
return R.ok(new ReprobeResultDTO( return R.ok(new ReprobeResultDTO(

View File

@ -302,7 +302,7 @@ public class DelegateAgentTool {
ChildResult result; ChildResult result;
try { try {
result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId, result = runSingleChild(0, target, taskWithContext, parentConversationId, childConversationId,
parentOrigin, rootConversationId, subagentId); parentOrigin, rootConversationId, subagentId, childDepth);
} finally { } finally {
// Cleanup relay + registry regardless of how the child returned // Cleanup relay + registry regardless of how the child returned
// (success / exception / interruption) so we never leak entries. // (success / exception / interruption) so we never leak entries.
@ -440,7 +440,7 @@ public class DelegateAgentTool {
for (PreparedChild p : prepared) { for (PreparedChild p : prepared) {
CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync( CompletableFuture<ChildResult> future = CompletableFuture.supplyAsync(
() -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId, () -> runSingleChild(p.index, p.agent, p.task, parentConversationId, p.childConvId,
parentOriginParallel, rootConvFinal, p.subagentId), parentOriginParallel, rootConvFinal, p.subagentId, childDepth),
DELEGATION_EXECUTOR); DELEGATION_EXECUTOR);
// Broadcast per-child completion as soon as each child finishes // Broadcast per-child completion as soon as each child finishes
@ -723,7 +723,7 @@ public class DelegateAgentTool {
try { try {
ChildResult childResult = runSingleChild(0, target, task, ChildResult childResult = runSingleChild(0, target, task,
parentConversationId, childConversationId, parentOrigin, parentConversationId, childConversationId, parentOrigin,
rootConvAsync, subagentId); rootConvAsync, subagentId, childDepth);
return childResult.toToolResponse(target.getName()); return childResult.toToolResponse(target.getName());
} finally { } finally {
subagentRegistry.get(subagentId).ifPresent(rec -> { subagentRegistry.get(subagentId).ifPresent(rec -> {
@ -933,7 +933,7 @@ public class DelegateAgentTool {
private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task, private ChildResult runSingleChild(int taskIndex, AgentEntity target, String task,
String parentConversationId, String childConversationId, String parentConversationId, String childConversationId,
ChatOrigin parentOrigin, ChatOrigin parentOrigin,
String rootConversationId, String subagentId) { String rootConversationId, String subagentId, int childDepth) {
boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId); boolean relayChildEvents = parentConversationId != null && streamTracker.isRunning(parentConversationId);
if (relayChildEvents) { if (relayChildEvents) {
streamTracker.register(childConversationId); streamTracker.register(childConversationId);
@ -941,8 +941,10 @@ public class DelegateAgentTool {
} }
// Carry root conversation + this child's subagentId into the context so // Carry root conversation + this child's subagentId into the context so
// a grandchild broadcasts to the root stream and tags this as its parent. // 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(), DelegationContext.enter(parentConversationId, deniedToolsForChild(),
rootConversationId, subagentId); rootConversationId, subagentId, childDepth);
try { try {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
// RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId // RFC-063r §2.5 改动点 5: inherit parent origin, swap agentId

View File

@ -27,15 +27,29 @@ public final class DelegationContext {
* the spawn tree. * the spawn tree.
*/ */
private record Frame(String parentConversationId, Set<String> childDeniedTools, private record Frame(String parentConversationId, Set<String> childDeniedTools,
String rootConversationId, String currentSubagentId) {} String rootConversationId, String currentSubagentId, int depth) {}
private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new); private static final ThreadLocal<Deque<Frame>> STACK = ThreadLocal.withInitial(ArrayDeque::new);
private DelegationContext() {} private DelegationContext() {}
/** Current delegation depth (0 = top-level call, not inside any delegation) */ /**
* Current delegation depth (0 = top-level call, not inside any delegation).
* <p>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() { 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) */ /** 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) */ /** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
public static void enter(String parentConversationId, Set<String> deniedTools) { public static void enter(String parentConversationId, Set<String> deniedTools) {
enter(parentConversationId, deniedTools, null, null); enter(parentConversationId, deniedTools, null, null, nextDepth());
} }
/** /**
* Enter the next delegation layer carrying the full tree identity so deeper * Enter the next delegation layer carrying the full tree identity so deeper
* children can broadcast to the root conversation and tag their parent. * 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<String> deniedTools, public static void enter(String parentConversationId, Set<String> deniedTools,
String rootConversationId, String currentSubagentId) { 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<String> deniedTools,
String rootConversationId, String currentSubagentId, int depth) {
STACK.get().push(new Frame(parentConversationId, deniedTools, STACK.get().push(new Frame(parentConversationId, deniedTools,
rootConversationId, currentSubagentId)); rootConversationId, currentSubagentId, depth));
} }
/** Enter the next delegation layer (backward-compatible overload) */ /** Enter the next delegation layer (backward-compatible overload) */
public static void enter() { 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 */ /** Exit the current delegation layer, restoring the previous layer's context */

View File

@ -9,6 +9,7 @@ import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService; import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R; import vip.mate.common.result.R;
import vip.mate.exception.MateClawException; 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.WorkspaceAccessVO;
import vip.mate.workspace.core.model.WorkspaceEntity; import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.model.WorkspaceMemberEntity; import vip.mate.workspace.core.model.WorkspaceMemberEntity;
@ -58,6 +59,7 @@ public class WorkspaceController {
@Operation(summary = "创建工作区") @Operation(summary = "创建工作区")
@PostMapping @PostMapping
@RequireGlobalAdmin
public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) { public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) {
Long userId = resolveUserId(auth); Long userId = resolveUserId(auth);
return R.ok(workspaceService.create(entity, userId)); return R.ok(workspaceService.create(entity, userId));
@ -85,7 +87,11 @@ public class WorkspaceController {
@Operation(summary = "获取工作区成员列表") @Operation(summary = "获取工作区成员列表")
@GetMapping("/{id}/members") @GetMapping("/{id}/members")
public R<List<WorkspaceMemberEntity>> listMembers(@PathVariable Long id) { public R<List<WorkspaceMemberEntity>> listMembers(@PathVariable Long id, Authentication auth) {
UserEntity currentUser = resolveUser(auth);
if (!isGlobalAdmin(currentUser)) {
workspaceService.requirePermission(id, currentUser.getId(), "viewer");
}
List<WorkspaceMemberEntity> members = workspaceService.listMembers(id); List<WorkspaceMemberEntity> members = workspaceService.listMembers(id);
// 填充用户名/昵称 // 填充用户名/昵称
for (WorkspaceMemberEntity m : members) { for (WorkspaceMemberEntity m : members) {
@ -124,12 +130,11 @@ public class WorkspaceController {
newUser.setNickname(body.containsKey("nickname") newUser.setNickname(body.containsKey("nickname")
? body.get("nickname").toString() : username); ? body.get("nickname").toString() : username);
target = authService.createUser(newUser); 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(); targetUserId = target.getId();
} else { } else {
targetUserId = Long.valueOf(body.get("userId").toString()); targetUserId = Long.valueOf(body.get("userId").toString());
@ -139,22 +144,26 @@ public class WorkspaceController {
} }
@Operation(summary = "更新成员角色") @Operation(summary = "更新成员角色")
@PutMapping("/{id}/members/{memberId}") @PutMapping("/{id}/members/{targetUserId}")
public R<WorkspaceMemberEntity> updateMemberRole(@PathVariable Long id, public R<WorkspaceMemberEntity> updateMemberRole(@PathVariable Long id,
@PathVariable Long memberId, @PathVariable Long targetUserId,
@RequestBody Map<String, String> body, @RequestBody Map<String, String> body,
Authentication auth) { Authentication auth) {
Long userId = resolveUserId(auth); Long userId = resolveUserId(auth);
workspaceService.requirePermission(id, userId, "admin"); 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 = "移除工作区成员") @Operation(summary = "移除工作区成员")
@DeleteMapping("/{id}/members/{memberId}") @DeleteMapping("/{id}/members/{targetUserId}")
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long memberId, Authentication auth) { public R<Void> removeMember(@PathVariable Long id, @PathVariable Long targetUserId, Authentication auth) {
Long userId = resolveUserId(auth); Long userId = resolveUserId(auth);
workspaceService.requirePermission(id, userId, "admin"); 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(); return R.ok();
} }

View File

@ -269,12 +269,12 @@ public class WorkspaceService {
// 检查是否已是成员 // 检查是否已是成员
WorkspaceMemberEntity existing = getMembership(workspaceId, userId); WorkspaceMemberEntity existing = getMembership(workspaceId, userId);
if (existing != null) { if (existing != null) {
throw new MateClawException("err.workspace.member_exists", "用户已经是该工作区的成员"); throw new MateClawException("err.workspace.member_exists", 409, "用户已经是该工作区的成员");
} }
WorkspaceMemberEntity member = new WorkspaceMemberEntity(); WorkspaceMemberEntity member = new WorkspaceMemberEntity();
member.setWorkspaceId(workspaceId); member.setWorkspaceId(workspaceId);
member.setUserId(userId); member.setUserId(userId);
member.setRole(role != null ? role : "member"); member.setRole(normalizeAssignableRole(role));
memberMapper.insert(member); memberMapper.insert(member);
evictMembershipCache(workspaceId, userId); evictMembershipCache(workspaceId, userId);
log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole()); 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) { public WorkspaceMemberEntity updateMemberRole(Long workspaceId, Long userId, String role) {
WorkspaceMemberEntity member = getMembership(workspaceId, userId); WorkspaceMemberEntity member = getMembership(workspaceId, userId);
if (member == null) { if (member == null) {
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员"); throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
} }
if ("owner".equals(member.getRole())) { 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); memberMapper.updateById(member);
evictMembershipCache(workspaceId, userId); evictMembershipCache(workspaceId, userId);
return member; 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) { public void removeMember(Long workspaceId, Long userId) {
WorkspaceMemberEntity member = getMembership(workspaceId, userId); WorkspaceMemberEntity member = getMembership(workspaceId, userId);
if (member == null) { if (member == null) {
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员"); throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
} }
if ("owner".equals(member.getRole())) { 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()); memberMapper.deleteById(member.getId());
evictMembershipCache(workspaceId, userId); evictMembershipCache(workspaceId, userId);

View File

@ -19,10 +19,13 @@ import static org.mockito.Mockito.when;
class GoalEvaluationDispatcherTest { class GoalEvaluationDispatcherTest {
private OverAllState stateWith(boolean followup) { private OverAllState stateWith(boolean followup) {
return stateWith(followup, false);
}
private OverAllState stateWith(boolean followup, boolean terminal) {
OverAllState s = mock(OverAllState.class); 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_followup_injected", false)).thenReturn(followup);
lenient().when(s.value("goal_evaluated_this_run", false)).thenReturn(terminal);
return s; return s;
} }
@ -49,4 +52,25 @@ class GoalEvaluationDispatcherTest {
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__"); GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__");
assertEquals("__END__", d.apply(stateWith(false))); 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)));
}
} }

View File

@ -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<String, Object> 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<String, Object> 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));
}
}

View File

@ -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("⛔ 已拒绝执行工具"));
}
}

View File

@ -5,6 +5,9 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import java.util.Set; 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.*; import static org.junit.jupiter.api.Assertions.*;
@ -149,4 +152,38 @@ class DelegationContextTest {
assertEquals(1, DelegationContext.currentDepth()); assertEquals(1, DelegationContext.currentDepth());
assertEquals("main-thread-conv", DelegationContext.parentConversationId()); 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<Integer> 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();
}
}
} }

View File

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

View File

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