From 1931b0698e2f757d91a52a91f26c2ebdbd8a8743 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Tue, 15 Sep 2026 01:20:03 +0800 Subject: [PATCH] fix(goal): recheck live conversation scope for managed JSON --- .../goal/service/ManagedGoalJsonService.java | 8 ++++++ .../docs/en/managed-json-acceptance.md | 2 +- .../docs/zh/managed-json-acceptance.md | 2 ++ .../GoalJsonAcceptanceIntegrationTest.java | 28 +++++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java index 686dc897..07a44af2 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java @@ -101,6 +101,14 @@ public class ManagedGoalJsonService { var goal = acceptance.authorizedGoal(ids.getFirst(), users.getFirst(), true); if (!Objects.equals(goal.conversationId(), origin.conversationId()) || goal.workspaceId() != origin.workspaceId() || goal.agentId() != origin.agentId()) throw failure(403, "Runtime goal scope mismatch"); + // authorizedGoal already holds this conversation row. Recheck mutable + // runtime scope as well as the Goal's original identity on every operation. + var currentConversations = jdbc.queryForList(""" + SELECT conversation_id FROM mate_conversation + WHERE conversation_id=? AND workspace_id=? AND agent_id=? AND deleted=0 + AND (archived IS NULL OR archived=0) FOR UPDATE + """, String.class, origin.conversationId(), origin.workspaceId(), origin.agentId()); + if (currentConversations.size() != 1) throw failure(403, "Runtime conversation scope changed or was archived"); if (!attempt) { // Recheck the immutable user id after authorizedGoal acquired the user lock. Long userId = jdbc.queryForObject("SELECT id FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0", Long.class, users.getFirst()); 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 94026628..153f9aed 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 @@ -22,7 +22,7 @@ Managed bodies live independently in the database. Ordinary workspace files, cac ## Agent publication -`getManagedGoalJsonSlots` returns current user requirements, slots and generations. `publishManagedGoalJson` accepts `artifactSlot`, a string `expectedGeneration` and `jsonContent`. Tools cannot configure requirements or supply goal IDs, accounts or owner fences. Interactive sessions require the authenticated account's internal ID. Scheduled persistent-goal execution must match the current continuation, attempt, owner token and live leases. Both paths recheck the conversation, workspace, agent and enabled account. The default delegation deny list includes both tools; the service still independently validates identity. +`getManagedGoalJsonSlots` returns current user requirements, slots and generations. `publishManagedGoalJson` accepts `artifactSlot`, a string `expectedGeneration` and `jsonContent`. Tools cannot configure requirements or supply goal IDs, accounts or owner fences. Interactive sessions require the authenticated account's internal ID. Scheduled persistent-goal execution must match the current continuation, attempt, owner token and live leases. Both paths recheck the conversation, workspace, agent and enabled account. A conversation that has been archived or assigned to another agent no longer authorizes its old runtime to read managed state, publish, check or complete; authorized users can still read the stored evidence. The default delegation deny list includes both tools; the service still independently validates identity. Publication and scheduler settlement serialize through the goal lock, rejecting late writes by former owners. Ending a lease does not mutate previously published versions. Anonymous sessions and cron runs without a bound goal attempt are outside this publication protocol. Missing identity is rejected instead of trusting a display username. 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 df921db8..21d52f55 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 @@ -60,3 +60,5 @@ V198 同样以绝对时间保存调度租约截止。升级时旧租约失效, Web排队消息从V199起保存入队时已认证账户的内部ID,普通Web续跑同时携带当前会话工作区;受管工具执行时仍重新校验账户、归属和当前要求。旧队列项不按用户名补造身份,不能用于受管JSON操作;需要用户重新发送已认证请求。持久Goal工作器消费输入时继续使用原有attempt owner校验,没有转换成免租约的账户路径。 恢复执行会收到先核实已有证据、不要重放未知副作用的提示。首次恢复执行若在实际运行前延期,下一次领取仍保留恢复关联;已经执行过后的普通续跑不会因此变成新恢复。 + +会话归档或改绑 Agent 后,旧运行上下文不能再读取托管运行状态、发布、检查或完成目标;有权用户仍可读取已保存的证据。此检查与当前 Goal 身份和租约校验处于同一事务。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java index bbfde073..680c487c 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -687,6 +687,34 @@ class GoalJsonAcceptanceIntegrationTest { : goals.markRuntimeCompleted(goal.getId(), evaluation, origin); } + @ParameterizedTest + @org.junit.jupiter.params.provider.CsvSource({"false,agent", "true,agent", "false,archived", "true,archived"}) + void staleConversationRuntimeCannotUseManagedOperationsAfterScopeChanges(boolean scheduled, String change) { + GoalEntity goal = goal(scheduled); + goals.appendCriterion(goal.getId(), "report", alice); + var evaluation = new GoalEvaluationResult(1, "offline fixture", "completed", true, "fixture", 1, 0, + List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "fixture only")), null); + goals.recordEvaluation(goal.getId(), evaluation, 1, 1); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var origin = scheduled ? attemptOrigin(goal, claimed(goal)) : accountOrigin(goal, alice); + var version = artifacts.publishForRuntime(origin, "report", publication(0, "{\"summary\":true}")); + bindings.checkForRuntime(origin, "r", checkRequest(1, version)); + if (change.equals("agent")) jdbc.update("UPDATE mate_conversation SET agent_id=99 WHERE conversation_id=?", goal.getConversationId()); + else jdbc.update("UPDATE mate_conversation SET archived=1 WHERE conversation_id=?", goal.getConversationId()); + assertThrows(MateClawException.class, () -> bindings.snapshotForRuntime(origin)); + assertThrows(MateClawException.class, () -> artifacts.publishForRuntime(origin, "report", publication(1, "{\"summary\":false}"))); + assertThrows(MateClawException.class, () -> bindings.checkForRuntime(origin, "r", checkRequest(1, version))); + assertThrows(MateClawException.class, () -> goals.markRuntimeCompleted(goal.getId(), null, origin)); + assertThrows(MateClawException.class, () -> goals.markRuntimeEvaluatedCompleted(goal.getId(), evaluation, origin)); + assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus()); + // User history remains readable; rejecting a stale runtime does not erase evidence. + assertEquals("{\"summary\":true}", artifacts.read(goal.getId(), version.artifactId(), alice).jsonContent()); + assertTrue(acceptance.get(goal.getId(), alice).required()); + jdbc.update("UPDATE mate_conversation SET agent_id=1,archived=0 WHERE conversation_id=?", goal.getConversationId()); + assertTrue(bindings.snapshotForRuntime(origin).checks().getFirst().acceptanceEligible()); + assertEquals(GoalStatus.COMPLETED, goals.markRuntimeCompleted(goal.getId(), null, origin).getStatus()); + } + @Test void userAndRuntimeSnapshotsShareCurrentRequirementsVersionsAndChecks() { GoalEntity goal = goal(false); acceptance.configure(goal.getId(), "r", request(0, "summary"), alice);