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

View File

@ -5,6 +5,7 @@ import com.alibaba.cloud.ai.graph.action.EdgeAction;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_EVALUATED_THIS_RUN;
import static vip.mate.agent.graph.state.MateClawStateKeys.GOAL_FOLLOWUP_INJECTED;
/**
@ -29,8 +30,15 @@ public class GoalEvaluationDispatcher implements EdgeAction {
@Override
public String apply(OverAllState state) {
// Re-enter the loop only when a followup was injected AND this was not a
// terminal evaluation pass. GOAL_FOLLOWUP_INJECTED uses the REPLACE key
// strategy and is never cleared by the reasoning nodes, so after a
// run-to-completion loop it can linger true; goalEvaluatedThisRun (set
// true on every terminal branch completed / exhausted / skip /
// continue-without-followup) is the authoritative end-of-run signal.
boolean followup = Boolean.TRUE.equals(state.value(GOAL_FOLLOWUP_INJECTED, false));
if (followup) {
boolean terminal = Boolean.TRUE.equals(state.value(GOAL_EVALUATED_THIS_RUN, false));
if (followup && !terminal) {
log.debug("[GoalEvaluationDispatcher] followup injected -> routing to {}", followupTarget);
return followupTarget;
}

View File

@ -160,10 +160,13 @@ public class GoalEvaluationNode implements NodeAction {
try {
result = evaluationService.evaluate(goal, recent, terminal);
// Pre-eval agent_llm count snapshot the bookkeeping helper
// folds it into the per-goal counter so future turns see
// growing usage.
int agentLlmDelta = accessor.llmCallCount();
// Bill only the NEW agent LLM calls since the last accounted point.
// The run-to-completion loop evaluates multiple times per graph run
// while LLM_CALL_COUNT keeps growing, so passing the cumulative value
// raw would re-bill earlier calls on every pass and exhaust the
// goal's LLM budget prematurely. The followup branch advances the
// accounted marker; terminal branches don't (the run ends there).
int agentLlmDelta = Math.max(0, accessor.llmCallCount() - accessor.goalAccountedLlmCallCount());
int evalLlmDelta = result.llmCallsConsumed();
goalService.recordEvaluation(goal.getId(), result, agentLlmDelta, evalLlmDelta);
@ -219,6 +222,7 @@ public class GoalEvaluationNode implements NodeAction {
.build();
}
int followupCountThisRun = accessor.goalFollowupCount();
Optional<String> followup;
try {
followup = followupService.maybeBuildFollowup(refreshed, result);
@ -227,7 +231,18 @@ public class GoalEvaluationNode implements NodeAction {
refreshed.getId(), t.toString());
followup = Optional.empty();
}
if (followup.isPresent()) {
// Per-run safety net: cap the autonomous self-continuation loop so a
// single user message can't drive an unbounded number of steps or
// approach the graph recursion limit. When the cap is hit we fall
// through to the terminal "continue, no followup" path the goal stays
// active and the cross-message turn / LLM budget (or the user) carries
// it on.
boolean perRunCapReached = followupCountThisRun >= properties.getMaxFollowupsPerRun();
if (followup.isPresent() && perRunCapReached) {
log.info("[GoalEvaluationNode] per-run followup cap reached ({}/{}) for goal={}; ending this run",
followupCountThisRun, properties.getMaxFollowupsPerRun(), refreshed.getId());
}
if (followup.isPresent() && !perRunCapReached) {
try {
goalService.recordFollowupInjected(refreshed.getId(), followup.get());
} catch (Throwable t) {
@ -240,7 +255,18 @@ public class GoalEvaluationNode implements NodeAction {
.goalEvaluationResult(result.toMap())
.goalFollowupInjected(true)
.goalFollowupPrompt(followup.get())
.goalEvaluatedThisRun(true)
.goalFollowupCount(followupCountThisRun + 1)
// Advance the LLM-billing marker to the current cumulative
// count so the NEXT evaluation in this run charges only its
// own delta (see agentLlmDelta above).
.goalAccountedLlmCallCount(accessor.llmCallCount())
// Deliberately NOT setting goalEvaluatedThisRun(true): leaving
// it false lets the NEXT answer be re-evaluated, turning the
// old single-step behaviour into run-to-completion. The loop
// is bounded by the per-run cap above plus the turn / LLM
// budgets; the dispatcher treats any terminal pass
// (goalEvaluatedThisRun == true) as END even if this flag
// lingers true under the REPLACE key strategy.
.needsToolCall(false)
.events(List.of(goalEvent("goal_followup", Map.of(
"goalId", String.valueOf(refreshed.getId()),

View File

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

View File

@ -293,6 +293,16 @@ public final class MateClawStateAccessor {
return state.value(GOAL_FOLLOWUP_PROMPT, "");
}
/** Auto-followups already injected in this graph run (0 at run start). */
public int goalFollowupCount() {
return state.value(GOAL_FOLLOWUP_COUNT, 0);
}
/** Cumulative agent LLM calls already billed to the goal this run (0 at run start). */
public int goalAccountedLlmCallCount() {
return state.value(GOAL_ACCOUNTED_LLM_CALL_COUNT, 0);
}
/**
* Bridge across ReAct and Plan-Execute: ReAct writes the terminal text
* to {@link MateClawStateKeys#FINAL_ANSWER} via FinalAnswerNode;
@ -534,6 +544,14 @@ public final class MateClawStateAccessor {
return put(GOAL_EVALUATED_THIS_RUN, v);
}
public OutputBuilder goalFollowupCount(int n) {
return put(GOAL_FOLLOWUP_COUNT, n);
}
public OutputBuilder goalAccountedLlmCallCount(int n) {
return put(GOAL_ACCOUNTED_LLM_CALL_COUNT, n);
}
/** Wipe FINAL_ANSWER on follow-up so the next graph pass doesn't
* immediately re-terminate via the existing final text. */
public OutputBuilder clearFinalAnswer() {

View File

@ -200,14 +200,34 @@ public final class MateClawStateKeys {
public static final String GOAL_FOLLOWUP_PROMPT = "goal_followup_prompt";
/**
* Re-entry guard: GoalEvaluationNode sets this true on its first run
* of a graph invocation; the FinalAnswerNodeGoalEvaluation conditional
* edge skips re-entering the node when it's already true. Combined with
* the dispatcher's followup clearing of FINAL_ANSWER, this bounds
* follow-ups to at most one per graph run.
* Re-entry guard for TERMINAL evaluation passes: GoalEvaluationNode sets
* this true only when it ENDS the run (completed / exhausted / skip /
* continue-without-followup). The FinalAnswerNodeGoalEvaluation edge skips
* re-entering once it's true. The followup branch deliberately leaves it
* false so the self-continuation loop can re-evaluate the next answer; that
* loop is bounded instead by {@link #GOAL_FOLLOWUP_COUNT} (per-run cap) plus
* the goal's turn / LLM-call budgets.
*/
public static final String GOAL_EVALUATED_THIS_RUN = "goal_evaluated_this_run";
/**
* Number of auto-followups already injected in THIS graph run (one user
* turn). Bounds the self-continuation loop per single message independent
* of the goal's cross-turn turn_budget so one message can't drive an
* unbounded number of autonomous steps or exhaust the graph recursion
* limit. Implicitly 0 at the start of each graph invocation.
*/
public static final String GOAL_FOLLOWUP_COUNT = "goal_followup_count";
/**
* Cumulative agent LLM-call count already billed to the goal in THIS graph
* run. The run-to-completion loop evaluates multiple times per run while
* {@link #LLM_CALL_COUNT} keeps growing; recording only
* (current accounted) on each pass avoids re-billing earlier calls and
* exhausting the goal's LLM budget prematurely. Implicitly 0 at run start.
*/
public static final String GOAL_ACCOUNTED_LLM_CALL_COUNT = "goal_accounted_llm_call_count";
/** Graph-node identifier for the GoalEvaluationNode. */
public static final String GOAL_EVALUATION_NODE = "goal_evaluation";

View File

@ -1,6 +1,7 @@
package vip.mate.auth.model;
import com.baomidou.mybatisplus.annotation.*;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
import java.time.LocalDateTime;
@ -20,7 +21,12 @@ public class UserEntity {
/** 用户名 */
private String username;
/** 密码BCrypt加密 */
/**
* 密码BCrypt加密WRITE_ONLY: accepted from request bodies (login / user
* creation) but never serialized into a response, so the bcrypt hash cannot
* leak via endpoints that return UserEntity (e.g. GET /auth/users).
*/
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY)
private String password;
/** 昵称 */

View File

@ -488,6 +488,47 @@ public class ChannelMessageRouter {
return null;
}
/**
* Identity gate shared by /approve and /deny (group-chat safety): only the
* original human requester may resolve a pending. Agent/cron ("system") and
* unattributed (null) approvals are fail-closed in IM any group member
* could otherwise approve OR deny/cancel a guarded action and must be
* handled from the admin console. Sends the rejection notice + logs and
* returns {@code false} when the caller is not authorized.
*/
private boolean approvalResolveAuthorized(PendingApproval pending, ChannelMessage message,
ChannelAdapter adapter, String replyTarget) {
String originalRequester = pending.getUserId();
boolean systemOriginated = originalRequester == null || "system".equals(originalRequester);
if (systemOriginated || !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, systemOriginated
? "⚠️ 该审批由系统/定时任务发起,请在管理端处理。"
: "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval resolve rejected: sender={} != requester={} (systemOriginated={})",
adapter.getChannelType(), message.getSenderId(), originalRequester, systemOriginated);
return false;
}
return true;
}
/**
* When an /approve or /deny command carries an explicit short pendingId
* (e.g. "/deny a1b2c3"), verify it matches the conversation's current
* pending before resolving otherwise a stale or copy-pasted id would
* silently act on the wrong pending. Sends the mismatch notice and returns
* {@code true} (caller must abort) when the ids don't line up.
*/
private boolean pendingIdMismatch(String userText, PendingApproval pending,
ChannelAdapter adapter, String replyTarget) {
String shortId = extractShortId(userText);
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
return true;
}
return false;
}
// ==================== 消息处理 route 逻辑 + 审批拦截层 ====================
/**
@ -510,20 +551,12 @@ public class ChannelMessageRouter {
String replyTarget = resolveReplyTarget(message);
if (isApproveCommand(userText)) {
// pendingId 校验如果命令包含 shortId验证是否匹配当前 pending
String shortId = extractShortId(userText);
if (shortId != null && !pending.getPendingId().startsWith(shortId)) {
adapter.sendMessage(replyTarget, "⚠️ 审批ID不匹配。当前待审批: "
+ pending.getPendingId().substring(0, Math.min(6, pending.getPendingId().length())));
// pendingId 校验approve / deny 共用命令带 shortId 时必须匹配当前 pending
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验只有原始请求者可以审批群聊安全
String originalRequester = pending.getUserId();
if (originalRequester != null && !"system".equals(originalRequester)
&& !originalRequester.equals(message.getSenderId())) {
adapter.sendMessage(replyTarget, "⚠️ 只有原始请求者可以审批此操作。");
log.warn("[{}] Approval rejected: sender={} != requester={}",
adapter.getChannelType(), message.getSenderId(), originalRequester);
// 身份校验approve / deny 共用同一道门禁群聊安全 + system/null fail-closed
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
return;
}
// Approve via IM: workflow.resolveAndConsume runs DB + metadata + memory atomically.
@ -542,9 +575,24 @@ public class ChannelMessageRouter {
return;
} else if (isDenyCommand(userText)) {
// pendingId 校验 approve 一致命令带 shortId 时必须匹配当前 pending
// 否则 /deny <其它ID> 会错误地拒绝当前 conversation pending
if (pendingIdMismatch(userText, pending, adapter, replyTarget)) {
return;
}
// 身份校验deny approve 共用门禁否则群里任意成员可拒绝/取消他人的
// pendingsystem/null 发起的审批也会被任意人 deny取消审批 placeholder
// 写入 denied 状态这类审批改到管理端处理
if (!approvalResolveAuthorized(pending, message, adapter, replyTarget)) {
return;
}
// Deny via IM: workflow.resolve owns the full state-machine transition.
ResolveOutcome denyOutcome = approvalService.resolve(
pending.getPendingId(), message.getSenderId(), "denied");
if (denyOutcome.isAlreadyResolved()) {
adapter.sendMessage(replyTarget, "⚠️ 审批记录已过期或已被处理。");
return;
}
conversationService.removeApprovalPlaceholders(conversationId);
String denyHint = "⛔ 已拒绝执行工具: " + pending.getToolName();
persistAndBroadcastApprovalHint(conversationId, denyHint,

View File

@ -100,11 +100,16 @@ public class ToolGuardCardHandler implements FeishuCardHandler {
}
PendingApproval pending = opt.get();
// ---- 3. Identity check
// ---- 3. Identity check (fail-closed)
// Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against. A guarded-tool card landing in
// a group chat would otherwise let ANY member click Approve and run the
// tool. Those approvals must be resolved from the admin console instead,
// so only an exact requester==clicker match is authorized here.
String originalRequester = pending.getUserId();
boolean authorized = originalRequester == null
|| "system".equals(originalRequester)
|| originalRequester.equals(clickerOpenId);
boolean authorized = originalRequester != null
&& !"system".equals(originalRequester)
&& originalRequester.equals(clickerOpenId);
if (!authorized) {
log.warn("[feishu-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerOpenId), abbrev(originalRequester), pendingId);

View File

@ -75,11 +75,16 @@ public class ToolGuardCardHandler implements WeComCardHandler {
}
PendingApproval pending = opt.get();
// ---- 3. Identity check ----
// ---- 3. Identity check (fail-closed) ----
// Agent/cron ("system") or unattributed (null) approvals have no human
// requester to match the clicker against; a group card would let any
// member resolve a guarded action. Reject here (mirrors the feishu card
// handler + router) so we never renderResolved a click the router will
// then refuse to execute. These approvals go through the admin console.
String originalRequester = pending.getUserId();
boolean isAuthorized = originalRequester == null
|| "system".equals(originalRequester)
|| originalRequester.equals(clickerUserId);
boolean isAuthorized = originalRequester != null
&& !"system".equals(originalRequester)
&& originalRequester.equals(clickerUserId);
if (!isAuthorized) {
log.warn("[wecom-toolguard] Unauthorised click: clicker={} != requester={}, pending={}",
abbrev(clickerUserId), abbrev(originalRequester), pendingId);

View File

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

View File

@ -35,6 +35,15 @@ public class GoalProperties {
/** Default cooldown between auto-followups in seconds. */
private int autoFollowupCooldownSeconds = 0;
/**
* Max auto-followups injected within a single graph run (one user turn).
* Caps the self-continuation loop so one message can't drive too many
* autonomous steps or approach the graph recursion limit. The goal's
* overall {@code turn_budget} still bounds total turns across messages;
* this is the tighter per-message safety net.
*/
private int maxFollowupsPerRun = 8;
/**
* Provider/model id for the evaluator. Empty string means "use the
* same model as the chat agent" — convenient for dev, expensive in

View File

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

View File

@ -21,6 +21,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
@Slf4j
@ -40,28 +41,28 @@ public class ModelConfigController {
@Operation(summary = "获取 Provider 列表(仅 enabled")
@GetMapping
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<List<ProviderInfoDTO>> list() {
return R.ok(modelProviderService.listProviders());
}
@Operation(summary = "RFC-074: 获取 Provider 全量目录(含未启用),供 Add Provider 抽屉使用")
@GetMapping("/catalog")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<List<ProviderInfoDTO>> catalog() {
return R.ok(modelProviderService.listCatalog());
}
@Operation(summary = "RFC-074: 启用 Provider")
@PostMapping("/{providerId}/enable")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<EnableResult> enableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, true));
}
@Operation(summary = "RFC-074: 禁用 Provider如其下模型为当前默认会自动切换")
@PostMapping("/{providerId}/disable")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<EnableResult> disableProvider(@PathVariable String providerId) {
return R.ok(modelProviderService.setEnabled(providerId, false));
}
@ -97,7 +98,7 @@ public class ModelConfigController {
@Operation(summary = "设置当前激活模型")
@PutMapping("/active")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ActiveModelsInfo> setActiveModel(@RequestBody ModelSlotRequest request) {
ModelConfigEntity model = modelConfigService.setDefaultModel(request.getProviderId(), request.getModel());
ActiveModelsInfo info = new ActiveModelsInfo();
@ -107,7 +108,7 @@ public class ModelConfigController {
@Operation(summary = "更新 Provider 配置")
@PutMapping("/{providerId}/config")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ProviderInfoDTO> updateProviderConfig(@PathVariable String providerId,
@RequestBody ProviderConfigRequest request) {
ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request);
@ -118,14 +119,14 @@ public class ModelConfigController {
@Operation(summary = "创建自定义 Provider")
@PostMapping("/custom-providers")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ProviderInfoDTO> createCustomProvider(@RequestBody CreateCustomProviderRequest request) {
return R.ok(modelProviderService.createCustomProvider(request));
}
@Operation(summary = "删除自定义 Provider")
@DeleteMapping("/custom-providers/{providerId}")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> deleteCustomProvider(@PathVariable String providerId) {
modelProviderService.deleteCustomProvider(providerId);
return R.ok();
@ -141,7 +142,7 @@ public class ModelConfigController {
*/
@Operation(summary = "删除自定义 Provider查询参数变体兼容含特殊字符的旧 ID")
@DeleteMapping("/custom-providers")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> deleteCustomProviderByQuery(@RequestParam("providerId") String providerId) {
modelProviderService.deleteCustomProvider(providerId);
return R.ok();
@ -149,7 +150,7 @@ public class ModelConfigController {
@Operation(summary = "向 Provider 添加模型")
@PostMapping("/{providerId}/models")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ProviderInfoDTO> addProviderModel(@PathVariable String providerId,
@RequestBody AddProviderModelRequest request) {
return R.ok(modelProviderService.addModel(providerId, request));
@ -157,7 +158,7 @@ public class ModelConfigController {
@Operation(summary = "从 Provider 删除模型")
@DeleteMapping("/{providerId}/models")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ProviderInfoDTO> removeProviderModel(@PathVariable String providerId,
@RequestParam String modelId) {
return R.ok(modelProviderService.removeModel(providerId, modelId));
@ -165,21 +166,21 @@ public class ModelConfigController {
@Operation(summary = "获取模型详情")
@GetMapping("/{id}")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ModelConfigEntity> get(@PathVariable Long id) {
return R.ok(modelConfigService.getModel(id));
}
@Operation(summary = "创建模型")
@PostMapping
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ModelConfigEntity> create(@RequestBody ModelConfigEntity entity) {
return R.ok(modelConfigService.createModel(entity));
}
@Operation(summary = "更新模型")
@PutMapping("/{id}")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ModelConfigEntity> update(@PathVariable Long id, @RequestBody ModelConfigEntity entity) {
entity.setId(id);
return R.ok(modelConfigService.updateModel(entity));
@ -187,7 +188,7 @@ public class ModelConfigController {
@Operation(summary = "删除模型")
@DeleteMapping("/{id}")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> delete(@PathVariable Long id) {
modelConfigService.deleteModel(id);
return R.ok();
@ -195,7 +196,7 @@ public class ModelConfigController {
@Operation(summary = "设置默认模型")
@PostMapping("/{id}/default")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<ModelConfigEntity> setDefault(@PathVariable Long id) {
return R.ok(modelConfigService.setDefaultModel(id));
}
@ -204,14 +205,14 @@ public class ModelConfigController {
@Operation(summary = "发现远端模型")
@PostMapping("/{providerId}/discover")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<DiscoverResult> discoverModels(@PathVariable String providerId) {
return R.ok(modelDiscoveryService.discoverModels(providerId));
}
@Operation(summary = "批量添加发现的模型")
@PostMapping("/{providerId}/discover/apply")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Map<String, Integer>> applyDiscoveredModels(@PathVariable String providerId,
@RequestBody ApplyDiscoveredModelsRequest request) {
int added = modelDiscoveryService.batchAddModels(providerId, request.getModelIds());
@ -220,14 +221,14 @@ public class ModelConfigController {
@Operation(summary = "测试供应商连接")
@PostMapping("/{providerId}/test-connection")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<TestResult> testConnection(@PathVariable String providerId) {
return R.ok(modelDiscoveryService.testConnection(providerId));
}
@Operation(summary = "测试单个模型可用性")
@PostMapping("/{providerId}/models/test")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<TestResult> testModel(@PathVariable String providerId,
@RequestParam String modelId) {
return R.ok(modelDiscoveryService.testModel(providerId, modelId));
@ -246,7 +247,7 @@ public class ModelConfigController {
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key")
@PostMapping("/embedding/{modelId}/test")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) {
Map<String, Object> result = new HashMap<>();
try {
@ -279,7 +280,7 @@ public class ModelConfigController {
@Operation(summary = "获取系统默认 Embedding 模型 ID")
@GetMapping("/embedding/default")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Map<String, Object>> getDefaultEmbedding() {
SystemSettingEntity entity = systemSettingMapper.selectOne(
new LambdaQueryWrapper<SystemSettingEntity>()
@ -293,7 +294,7 @@ public class ModelConfigController {
@Operation(summary = "设置系统默认 Embedding 模型")
@PostMapping("/embedding/default")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) {
Object v = body.get("modelId");
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.OAuthAuthorizeResult;
import vip.mate.llm.oauth.OpenAIOAuthService.OAuthStatusResult;
import vip.mate.workspace.core.annotation.RequireWorkspaceRole;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
@Tag(name = "OpenAI OAuth")
@RestController
@ -25,7 +25,7 @@ public class OAuthController {
@Operation(summary = "获取 OAuth 授权 URL自动选 LOCAL / MANUAL_PASTE 模式)")
@GetMapping("/authorize")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<OAuthAuthorizeResult> authorize(HttpServletRequest request) {
// Host header 判断是否远程部署 远程则不启 localhost server MANUAL_PASTE
// 优先 X-Forwarded-Host反向代理后的实际入口fallback Host
@ -44,7 +44,7 @@ public class OAuthController {
*/
@Operation(summary = "MANUAL_PASTE 模式:用户粘贴浏览器回调 URL 完成 OAuth")
@PostMapping("/callback-paste")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> callbackPaste(@RequestBody PasteRequest request) {
oauthService.completeFromPastedUrl(request.callbackUrl());
return R.ok();
@ -55,21 +55,21 @@ public class OAuthController {
@Operation(summary = "Device flow: start — request user_code")
@PostMapping("/device/start")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<DeviceCodeStartResult> deviceStart() {
return R.ok(deviceCodeService.start());
}
@Operation(summary = "Device flow: poll for completion")
@PostMapping("/device/poll")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<DeviceCodePollResult> devicePoll(@RequestBody DeviceRequest request) {
return R.ok(deviceCodeService.poll(request.deviceAuthId()));
}
@Operation(summary = "Device flow: cancel a pending session")
@PostMapping("/device/cancel")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> deviceCancel(@RequestBody DeviceRequest request) {
deviceCodeService.cancel(request.deviceAuthId());
return R.ok();
@ -80,7 +80,7 @@ public class OAuthController {
@Operation(summary = "手动刷新 Token")
@PostMapping("/refresh")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> refresh() {
oauthService.refreshToken();
return R.ok();
@ -88,7 +88,7 @@ public class OAuthController {
@Operation(summary = "清除 OAuth 凭证")
@DeleteMapping("/revoke")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<Void> revoke() {
oauthService.revokeToken();
return R.ok();
@ -96,7 +96,7 @@ public class OAuthController {
@Operation(summary = "获取 OAuth 连接状态")
@GetMapping("/status")
@RequireWorkspaceRole("admin")
@RequireGlobalAdmin
public R<OAuthStatusResult> status() {
return R.ok(oauthService.getStatus());
}

View File

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

View File

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

View File

@ -27,15 +27,29 @@ public final class DelegationContext {
* the spawn tree.
*/
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 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() {
return STACK.get().size();
Frame top = STACK.get().peek();
return top != null ? top.depth : 0;
}
/** Depth for the next layer when the caller doesn't pass one explicitly. */
private static int nextDepth() {
Frame top = STACK.get().peek();
return (top != null ? top.depth : 0) + 1;
}
/** Parent conversation ID for event relay (from the current frame) */
@ -64,22 +78,35 @@ public final class DelegationContext {
/** Enter the next delegation layer (with parent conversation ID and child tool restrictions) */
public static void enter(String parentConversationId, Set<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
* 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,
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,
rootConversationId, currentSubagentId));
rootConversationId, currentSubagentId, depth));
}
/** Enter the next delegation layer (backward-compatible overload) */
public static void enter() {
enter(null, null, null, null);
enter(null, null, null, null, nextDepth());
}
/** Exit the current delegation layer, restoring the previous layer's context */

View File

@ -9,6 +9,7 @@ import vip.mate.auth.model.UserEntity;
import vip.mate.auth.service.AuthService;
import vip.mate.common.result.R;
import vip.mate.exception.MateClawException;
import vip.mate.workspace.core.annotation.RequireGlobalAdmin;
import vip.mate.workspace.core.model.WorkspaceAccessVO;
import vip.mate.workspace.core.model.WorkspaceEntity;
import vip.mate.workspace.core.model.WorkspaceMemberEntity;
@ -58,6 +59,7 @@ public class WorkspaceController {
@Operation(summary = "创建工作区")
@PostMapping
@RequireGlobalAdmin
public R<WorkspaceEntity> create(@RequestBody WorkspaceEntity entity, Authentication auth) {
Long userId = resolveUserId(auth);
return R.ok(workspaceService.create(entity, userId));
@ -85,7 +87,11 @@ public class WorkspaceController {
@Operation(summary = "获取工作区成员列表")
@GetMapping("/{id}/members")
public R<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);
// 填充用户名/昵称
for (WorkspaceMemberEntity m : members) {
@ -124,12 +130,11 @@ public class WorkspaceController {
newUser.setNickname(body.containsKey("nickname")
? body.get("nickname").toString() : username);
target = authService.createUser(newUser);
} else if (password != null && !password.isBlank()) {
// User exists AND admin provided a password reset it.
// This fixes the case where an admin removes a member, re-adds
// them with a new password, but the stale password blocks login.
authService.resetPassword(target.getId(), password);
}
// Existing users are added as-is. A workspace admin must NOT be able
// to reset another account's password (including a global admin's)
// through the member-add path that would be an account-takeover
// vector. Password changes go through the dedicated reset flow.
targetUserId = target.getId();
} else {
targetUserId = Long.valueOf(body.get("userId").toString());
@ -139,22 +144,26 @@ public class WorkspaceController {
}
@Operation(summary = "更新成员角色")
@PutMapping("/{id}/members/{memberId}")
@PutMapping("/{id}/members/{targetUserId}")
public R<WorkspaceMemberEntity> updateMemberRole(@PathVariable Long id,
@PathVariable Long memberId,
@PathVariable Long targetUserId,
@RequestBody Map<String, String> body,
Authentication auth) {
Long userId = resolveUserId(auth);
workspaceService.requirePermission(id, userId, "admin");
return R.ok(workspaceService.updateMemberRole(id, memberId, body.get("role")));
// Path variable is the member's USER id (not the membership row id):
// WorkspaceService resolves membership by (workspaceId, userId).
return R.ok(workspaceService.updateMemberRole(id, targetUserId, body.get("role")));
}
@Operation(summary = "移除工作区成员")
@DeleteMapping("/{id}/members/{memberId}")
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long memberId, Authentication auth) {
@DeleteMapping("/{id}/members/{targetUserId}")
public R<Void> removeMember(@PathVariable Long id, @PathVariable Long targetUserId, Authentication auth) {
Long userId = resolveUserId(auth);
workspaceService.requirePermission(id, userId, "admin");
workspaceService.removeMember(id, memberId);
// Path variable is the member's USER id (not the membership row id):
// WorkspaceService resolves membership by (workspaceId, userId).
workspaceService.removeMember(id, targetUserId);
return R.ok();
}

View File

@ -269,12 +269,12 @@ public class WorkspaceService {
// 检查是否已是成员
WorkspaceMemberEntity existing = getMembership(workspaceId, userId);
if (existing != null) {
throw new MateClawException("err.workspace.member_exists", "用户已经是该工作区的成员");
throw new MateClawException("err.workspace.member_exists", 409, "用户已经是该工作区的成员");
}
WorkspaceMemberEntity member = new WorkspaceMemberEntity();
member.setWorkspaceId(workspaceId);
member.setUserId(userId);
member.setRole(role != null ? role : "member");
member.setRole(normalizeAssignableRole(role));
memberMapper.insert(member);
evictMembershipCache(workspaceId, userId);
log.info("Added member to workspace: userId={}, workspaceId={}, role={}", userId, workspaceId, member.getRole());
@ -284,24 +284,35 @@ public class WorkspaceService {
public WorkspaceMemberEntity updateMemberRole(Long workspaceId, Long userId, String role) {
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
if (member == null) {
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员");
throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
}
if ("owner".equals(member.getRole())) {
throw new MateClawException("err.workspace.cannot_modify_owner", "不能修改工作区拥有者的角色");
throw new MateClawException("err.workspace.cannot_modify_owner", 400, "不能修改工作区拥有者的角色");
}
member.setRole(role);
member.setRole(normalizeAssignableRole(role));
memberMapper.updateById(member);
evictMembershipCache(workspaceId, userId);
return member;
}
private String normalizeAssignableRole(String role) {
String normalized = role == null || role.isBlank() ? "member" : role.trim();
return switch (normalized) {
case "admin", "member", "viewer" -> normalized;
case "owner" -> throw new MateClawException(
"err.workspace.invalid_member_role", 400, "不能通过成员管理授予 owner 角色");
default -> throw new MateClawException(
"err.workspace.invalid_member_role", 400, "无效的成员角色: " + normalized);
};
}
public void removeMember(Long workspaceId, Long userId) {
WorkspaceMemberEntity member = getMembership(workspaceId, userId);
if (member == null) {
throw new MateClawException("err.workspace.not_member", "用户不是该工作区的成员");
throw new MateClawException("err.workspace.not_member", 404, "用户不是该工作区的成员");
}
if ("owner".equals(member.getRole())) {
throw new MateClawException("err.workspace.cannot_remove_owner", "不能移除工作区拥有者");
throw new MateClawException("err.workspace.cannot_remove_owner", 400, "不能移除工作区拥有者");
}
memberMapper.deleteById(member.getId());
evictMembershipCache(workspaceId, userId);

View File

@ -19,10 +19,13 @@ import static org.mockito.Mockito.when;
class GoalEvaluationDispatcherTest {
private OverAllState stateWith(boolean followup) {
return stateWith(followup, false);
}
private OverAllState stateWith(boolean followup, boolean terminal) {
OverAllState s = mock(OverAllState.class);
// The dispatcher only reads GOAL_FOLLOWUP_INJECTED; everything else
// can stay default.
lenient().when(s.value("goal_followup_injected", false)).thenReturn(followup);
lenient().when(s.value("goal_evaluated_this_run", false)).thenReturn(terminal);
return s;
}
@ -49,4 +52,25 @@ class GoalEvaluationDispatcherTest {
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("plan_generation", "__END__");
assertEquals("__END__", d.apply(stateWith(false)));
}
// ===== Run-to-completion loop guard =====
@Test
void followupOnNonTerminalPass_reentersLoop() throws Exception {
// The self-continuation loop: followup injected, not a terminal pass.
GoalEvaluationDispatcher d = new GoalEvaluationDispatcher("reasoning", "__END__");
assertEquals("reasoning", d.apply(stateWith(true, false)));
}
@Test
void followupFlagLingeringOnTerminalPass_routesToEnd() throws Exception {
// GOAL_FOLLOWUP_INJECTED uses REPLACE and is never cleared, so after a
// run-to-completion loop it can still be true on the final (completed /
// exhausted) pass. goalEvaluatedThisRun == true must win and END the run,
// otherwise the graph loops forever.
GoalEvaluationDispatcher react = new GoalEvaluationDispatcher("reasoning", "__END__");
assertEquals("__END__", react.apply(stateWith(true, true)));
GoalEvaluationDispatcher plan = new GoalEvaluationDispatcher("plan_generation", "__END__");
assertEquals("__END__", plan.apply(stateWith(true, true)));
}
}

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 java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import static org.junit.jupiter.api.Assertions.*;
@ -149,4 +152,38 @@ class DelegationContextTest {
assertEquals(1, DelegationContext.currentDepth());
assertEquals("main-thread-conv", DelegationContext.parentConversationId());
}
// ===== Explicit depth (async/parallel recursion-cap bypass guard) =====
@Test
@DisplayName("Explicit-depth enter reports the depth verbatim, not stack size")
void explicitDepthReportedVerbatim() {
DelegationContext.enter("conv", Set.of(), "root", "sub", 3);
assertEquals(3, DelegationContext.currentDepth());
DelegationContext.exit();
assertEquals(0, DelegationContext.currentDepth());
}
@Test
@DisplayName("Explicit childDepth survives the executor-thread hop (async/parallel bypass guard)")
void explicitDepthSurvivesExecutorThreadHop() throws Exception {
// Async/parallel children run on a fresh executor thread with an EMPTY
// stack. Before the fix, currentDepth() used stack size and reset to 1
// here, letting a child exceed MAX_DELEGATION_DEPTH at every hop.
ExecutorService executor = Executors.newSingleThreadExecutor();
try {
Future<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));
}
}