mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
Harden goal approval and workspace flows
This commit is contained in:
parent
5cd6e841a4
commit
a9c2d45790
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()),
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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 FinalAnswerNode→GoalEvaluation 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 FinalAnswerNode→GoalEvaluation 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";
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
/** 昵称 */
|
/** 昵称 */
|
||||||
|
|||||||
@ -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 共用门禁。否则群里任意成员可拒绝/取消他人的
|
||||||
|
// pending,system/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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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("⛔ 已拒绝执行工具"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user