diff --git a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java index 9268d59c..24c83824 100644 --- a/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java +++ b/mateclaw-server/src/main/java/vip/mate/channel/web/ChatController.java @@ -1367,11 +1367,18 @@ public class ChatController { String username, boolean approve) { if (goalApprovalRuns != null && jsonAcceptance != null) { var origin = approvalService.restoreChatOrigin(pending.getChatOrigin()); - if (origin != null && goalApprovalRuns.requiresHandoff(origin.withApprovalId(pending.getPendingId()))) { - return jsonAcceptance.withAuthenticatedUser(requesterUserIdOf(auth), username, - current -> approve - ? approvalService.resolveAndConsume(pending.getPendingId(), current) - : approvalService.resolve(pending.getPendingId(), current, "denied")); + if (origin != null && goalApprovalRuns.requiresCurrentApprover(origin.withApprovalId(pending.getPendingId()))) { + Long currentUserId = requesterUserIdOf(auth); + return jsonAcceptance.withAuthenticatedUser(currentUserId, username, current -> { + var attribution = origin.executionAttribution(); + if (attribution == null || attribution.goalId() == null) { + if (!java.util.Objects.equals(currentUserId, origin.requesterUserId())) { + throw new vip.mate.exception.MateClawException(403, "Approval belongs to another account"); + } + } + return approve ? approvalService.resolveAndConsume(pending.getPendingId(), current) + : approvalService.resolve(pending.getPendingId(), current, "denied"); + }); } } return approve ? approvalService.resolveAndConsume(pending.getPendingId(), username) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java index 1e677a1d..475317e4 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalApprovalRunService.java @@ -42,6 +42,18 @@ public class GoalApprovalRunService { return required.isEmpty() || Boolean.TRUE.equals(required.getFirst()); } + /** Selected interactive Goals must retain their original authenticated requester at approval time. */ + public boolean requiresCurrentApprover(ChatOrigin origin) { + if (requiresHandoff(origin)) return true; + if (origin == null || origin.conversationId() == null || origin.agentId() == null) return false; + Integer selected = jdbc.queryForObject(""" + SELECT COUNT(*) FROM mate_agent_goal + WHERE conversation_id=? AND agent_id=? AND json_acceptance_required=TRUE + AND status IN ('active','paused') AND deleted=0 + """, Integer.class, origin.conversationId(), origin.agentId()); + return selected != null && selected > 0; + } + @Transactional public ReplayRun claim(ChatOrigin requested, String toolCallPayload) { ExecutionAttribution link = requested == null ? null : requested.executionAttribution(); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md index f78af830..fdc9f3fd 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -74,3 +74,5 @@ V200 records the exact attempt that settled into approval waiting and makes the Approval snapshots use the identity bound to tool execution in graph state, rather than missing or unrelated ambient thread identity. Background replay restores the previous thread context immediately after constructing graph state; persisted account, Goal, and attempt associations are still rechecked during execution. For a Goal with managed JSON requirements, Web approval consumption also locks and rechecks the requesting account ID through the approval transaction. If an account is retired and replaced under the same username after JWT validation, the old in-flight request neither consumes the pending approval nor starts replay; a newly signed-in account can still approve. Denial uses the same current-account check. + +An interactive managed Goal approval also retains the original requester account ID. A newly signed-in account with the same username cannot consume the old account’s pending approval; the user must make a new request under the new account. A persistent Goal attempt approval checks the current approver account ID and can be approved after the new account signs in. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md index 7c092d00..27863a2e 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -76,3 +76,5 @@ V200 保存待审批结算对应的准确 attempt,并对审批生成的新 att 审批快照从工具执行所绑定的图身份创建,不借用环境线程中缺失或属于其他请求的身份。后台重放构建图状态后立即恢复原线程上下文;持久账户、Goal与attempt关联仍在执行时重新校验。 选定JSON要求的Goal在Web审批消费时,也按当前请求的认证账户ID持锁复查,直到审批写入事务结束。若请求通过JWT校验后旧账户被停用并由同名新账户取代,旧请求不会消费待审批记录或触发重放;新账户重新登录后仍可批准。拒绝审批同样按当前账户复查。 + +交互式选定Goal的旧审批还绑定最初请求者的账户ID。同名新账户重新登录也不能消费旧账户留下的待审批记录;用户需要在新账户下发起新的请求。后台Goal的持久attempt审批按当前批准者账户ID复查,允许新账户在重新登录后批准。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java index 0fc1ea6a..23584da7 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonHttpRuntimeIntegrationTest.java @@ -86,7 +86,8 @@ class GoalJsonHttpRuntimeIntegrationTest { "false,scheduled-detached-approval,true", "true,scheduled-detached-approval,true", "false,foreign-approval,true", "true,foreign-approval,true", "false,scheduled-foreign-approval,true", "true,scheduled-foreign-approval,true", - "false,scheduled-reassigned-approval,true", "true,scheduled-reassigned-approval,true"}) + "false,scheduled-reassigned-approval,true", "true,scheduled-reassigned-approval,true", + "false,reassigned-approval,true", "true,reassigned-approval,true"}) void authenticatedGoalCompletesThroughHttpOrScheduledProductionRuntime(boolean plan, String entry, boolean accepted) throws Exception { boolean approval = entry.endsWith("approval"); boolean doubleApproval = entry.equals("scheduled-double-approval"); @@ -359,6 +360,15 @@ class GoalJsonHttpRuntimeIntegrationTest { assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId())); token = request("POST", "/api/v1/auth/login", null, Map.of("username", username, "password", password)).path("data").path("token").asText(); assertFalse(token.isBlank()); + if (!scheduled) { + var newAccountReplay = requestBody("POST", "/api/v1/chat/stream", token, + Map.of("agentId", String.valueOf(agentId), "conversationId", conversation, + "message", "/approve", "pendingApprovalId", pendingId)); + assertEquals("PENDING", jdbc.queryForObject("SELECT status FROM mate_tool_approval WHERE pending_id=?", String.class, pendingId), newAccountReplay); + assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId())); + return; + } } planApprovalReplay.set(plan); String replay = requestBody("POST", "/api/v1/chat/stream", token,