From 8cca7159fc6c3f5bf22dffdb5cbecbf17d72f89d Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Mon, 14 Sep 2026 22:03:20 +0800 Subject: [PATCH] fix: fence runtime goal completion with current authenticated owner --- .../agent/graph/node/GoalEvaluationNode.java | 2 +- .../vip/mate/goal/service/GoalService.java | 8 +++- .../mate/goal/service/GoalServiceImpl.java | 35 ++++++++++++-- .../mate/tool/builtin/GoalManagementTool.java | 2 +- .../docs/en/managed-json-acceptance.md | 2 + .../docs/zh/managed-json-acceptance.md | 2 + .../GoalEvaluationNodeContinuationTest.java | 6 +-- .../GoalJsonAcceptanceIntegrationTest.java | 47 +++++++++++++++++++ .../tool/builtin/GoalManagementToolTest.java | 6 +-- 9 files changed, 97 insertions(+), 13 deletions(-) diff --git a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java index 6a8960d2..dff9b05f 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/graph/node/GoalEvaluationNode.java @@ -202,7 +202,7 @@ public class GoalEvaluationNode implements NodeAction { // Completion is the deterministic "all criteria passed" signal the // evaluator already folded into result.completed() — no score gate. if (result.completed()) { - GoalEntity completed = goalService.markEvaluatedCompleted(refreshed.getId(), result); + GoalEntity completed = goalService.markRuntimeEvaluatedCompleted(refreshed.getId(), result, accessor.chatOrigin()); return MateClawStateAccessor.output() .goalEvaluationResult(result.toMap()) .goalEvaluatedThisRun(true) diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java index e5fb1eff..d29a97d3 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalService.java @@ -56,12 +56,16 @@ public interface GoalService { GoalEntity resume(Long id, String username); GoalEntity abandon(Long id, String username); - /** Flip active->completed. Writes a 'completed' event. */ + /** Trusted platform completion. Runtime callers must use markRuntimeCompleted to carry their identity. */ GoalEntity markCompleted(Long id, GoalEvaluationResult result); - /** Complete an evaluator result only if the current active checklist still passes with evidence. */ + /** Trusted platform evaluation completion. Runtime callers must use markRuntimeEvaluatedCompleted. */ GoalEntity markEvaluatedCompleted(Long id, GoalEvaluationResult result); + /** Runtime entry points carry server-issued identity; selected JSON goals also fence the completing owner. */ + GoalEntity markRuntimeCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin); + GoalEntity markRuntimeEvaluatedCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin); + /** Flip active->exhausted with the reason that triggered it. */ GoalEntity markExhausted(Long id, String reason); diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index b455dd62..d401bafd 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -73,6 +73,10 @@ public class GoalServiceImpl implements GoalService { */ private vip.mate.memory.spi.MemoryManager memoryManager; private GoalJsonBindingService jsonBindings; + private ManagedGoalJsonService managedArtifacts; + + @Autowired + public void setManagedArtifacts(ManagedGoalJsonService managedArtifacts) { this.managedArtifacts = managedArtifacts; } @Autowired public void setJsonBindings(GoalJsonBindingService jsonBindings) { @@ -380,7 +384,7 @@ public class GoalServiceImpl implements GoalService { @Override @Transactional public GoalEntity markCompleted(Long id, GoalEvaluationResult result) { - return completeGoal(id, result, false); + return completeGoal(id, result, false, null, false); } @Override @@ -391,10 +395,28 @@ public class GoalServiceImpl implements GoalService { throw new MateClawException("err.goal.completion_not_verified", 409, "Automatic completion requires a completed evaluation"); } - return completeGoal(id, result, true); + return completeGoal(id, result, true, null, false); } - private GoalEntity completeGoal(Long id, GoalEvaluationResult result, boolean evaluated) { + @Override + @Transactional + public GoalEntity markRuntimeCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) { + return completeGoal(id, result, false, origin, true); + } + + @Override + @Transactional + public GoalEntity markRuntimeEvaluatedCompleted(Long id, GoalEvaluationResult result, vip.mate.agent.context.ChatOrigin origin) { + if (result == null || !result.completed() + || !GoalEvaluationResult.DECISION_COMPLETED.equals(result.decision())) { + throw new MateClawException("err.goal.completion_not_verified", 409, + "Automatic completion requires a completed evaluation"); + } + return completeGoal(id, result, true, origin, true); + } + + private GoalEntity completeGoal(Long id, GoalEvaluationResult result, boolean evaluated, + vip.mate.agent.context.ChatOrigin origin, boolean runtimeCaller) { boolean[] transitioned = {false}; var jsonProof = new java.util.concurrent.atomic.AtomicReference>(List.of()); GoalEntity g = retryOptimistic(id, "markCompleted", fresh -> { @@ -408,6 +430,12 @@ public class GoalServiceImpl implements GoalService { } return null; // idempotent } + ManagedGoalJsonService.RuntimeScope runtime = null; + if (runtimeCaller && fresh.isJsonAcceptanceRequired()) { + if (managedArtifacts == null) throw new MateClawException(409, "Managed JSON runtime verification is unavailable"); + runtime = managedArtifacts.runtimeGoal(origin); + if (runtime.goal().id() != fresh.getId()) throw new MateClawException(403, "Completion runtime goal mismatch"); + } if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) { throw new MateClawException("err.goal.completion_not_verified", 409, "Automatic completion requires the current evaluation definition revision"); @@ -441,6 +469,7 @@ public class GoalServiceImpl implements GoalService { if (jsonBindings == null) throw new MateClawException("err.goal.json_acceptance_required", 409, "Managed JSON verification service is unavailable"); jsonProof.set(jsonBindings.requireForCompletion(fresh)); + if (runtime != null) ManagedGoalJsonService.verifyLease(runtime); } bumpVersionAndTime(w); transitioned[0] = true; diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java index 6a0fcdd0..d59aa980 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/GoalManagementTool.java @@ -166,7 +166,7 @@ public class GoalManagementTool { true, "manual", 0, 0L, java.util.List.of(), null); try { - GoalEntity completed = goalService.markCompleted(goal.getId(), synthetic); + GoalEntity completed = goalService.markRuntimeCompleted(goal.getId(), synthetic, ChatOrigin.from(ctx)); // Broadcast a goal_completed event with the same shape as the // GoalEvaluationNode auto-completed path, so the frontend // handler doesn't need to branch on which path completed it. 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 59e00484..1c095bfd 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 @@ -34,3 +34,5 @@ After publication, call `POST /checks/{criterionKey}` with `expectedRequirementR This is an explicit per-goal managed JSON protocol with a limited scope. The broad execution-evidence ledger retains its existing prerequisites for global ENFORCE. Ordinary tool-success text and diagnostic MATCH results never become bindings automatically. Backend services cover success, invalidation, races and rollback; full browser/service flows, restart and external database validation are still in progress. ReAct, Plan and persistent-goal continuations receive managed JSON instructions. Business-skill tool allowlists retain the three goal-level read, publish and check tools, while service identity checks and child-agent restrictions still apply. For selected goals, follow-up and scheduling projections cannot end on a model completion claim or segment Complete alone: the Goal must already have committed completed status. Rejected automatic completion produces a continue result with recheck guidance. + +Runtime completion must also carry server-issued identity. The completeGoal tool and automatic evaluation node use runtime completion entry points that recheck the enabled account, current goal/conversation/workspace/agent and scheduled-owner leases in the same transaction as all bindings. Valid bindings do not authorize an expired owner, a different goal or a revoked identity to complete. Internal platform completion APIs retain the binding gate; they are not identity-free model or HTTP entry points. 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 310c1ae5..e468ff26 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 @@ -34,3 +34,5 @@ 这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。 代理在 ReAct、Plan 和持久 Goal 续跑入口都会收到受管 JSON 操作指引。业务技能的工具白名单保留读取、发布和检查这三个 Goal 通用工具,仍执行服务端身份校验与子代理禁用。选中模式下,follow-up 和调度投影不能凭模型的“已完成”或 segment Complete 声明结束;必须先有已提交的 Goal completed 状态。自动完成被拒绝时,向运行时返回 continue 和重检指引,不暴露已接受完成的信号。 + +运行时完成也必须携带服务端身份:completeGoal 工具与自动评估节点使用专门的 runtime 完成入口,在同一事务中复查启用账户、当前 Goal/对话/工作区/Agent 和调度 owner 租约,再检查所有绑定。即使绑定仍有效,旧租约、跨 Goal 或已撤销的身份也不能发起完成。平台内部完成 API 仍执行绑定门;它不是向模型或 HTTP 暴露的免身份入口。 diff --git a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java index cf75bb43..9604cb8e 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/graph/node/GoalEvaluationNodeContinuationTest.java @@ -220,10 +220,10 @@ class GoalEvaluationNodeContinuationTest { Fixture f = new Fixture(); var completed = new GoalEvaluationResult(1.0, "", "completed", true, "fixture", 1, 0, List.of(), null); when(f.evaluationService.evaluate(any(), anyList(), anyString())).thenReturn(completed); - when(f.goalService.markEvaluatedCompleted(eq(1L), eq(completed))) + when(f.goalService.markRuntimeEvaluatedCompleted(eq(1L), eq(completed), any())) .thenThrow(new vip.mate.exception.MateClawException(409, "current criteria changed")); var out = f.node().apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)); - verify(f.goalService).markEvaluatedCompleted(1L, completed); + verify(f.goalService).markRuntimeEvaluatedCompleted(eq(1L), eq(completed), any()); verify(f.goalService, never()).markCompleted(any(), any()); @SuppressWarnings("unchecked") var events = (List) out.get(MateClawStateKeys.PENDING_EVENTS); @@ -237,7 +237,7 @@ class GoalEvaluationNodeContinuationTest { when(f.goalService.getById(1L)).thenReturn(goal); var claim = new GoalEvaluationResult(1, "done", "completed", true, "fixture", 1, 0, List.of(), null); when(f.evaluationService.evaluate(any(), anyList(), anyString())).thenReturn(claim); - when(f.goalService.markEvaluatedCompleted(1L, claim)).thenThrow(new vip.mate.exception.MateClawException(409, "binding missing")); + when(f.goalService.markRuntimeEvaluatedCompleted(eq(1L), eq(claim), any())).thenThrow(new vip.mate.exception.MateClawException(409, "binding missing")); var out = f.node().apply(f.state(FinishReason.NORMAL.getValue(), 0, 0)); var result = (Map) out.get(MateClawStateKeys.GOAL_EVALUATION_RESULT); assertEquals(false, result.get("completed")); 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 64d16b6a..d8ce5d97 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -554,4 +554,51 @@ class GoalJsonAcceptanceIntegrationTest { assertEquals(GoalStatus.COMPLETED, goals.getById(goal.getId()).getStatus()); } + @Test void expiredRuntimeCannotCompleteEvenWithCurrentPassingBindings() { + GoalEntity goal = goal(true); + goals.appendCriterion(goal.getId(), "report", alice); + var evaluation = new GoalEvaluationResult(1, "checked", "completed", true, "fixture", 1, 0, + List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "semantic fixture")), null); + goals.recordEvaluation(goal.getId(), evaluation, 1, 1); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var run = claimed(goal); + var origin = attemptOrigin(goal, run); + var version = artifacts.publishForRuntime(origin, "report", publication(0, "{\"summary\":true}")); + bindings.checkForRuntime(origin, "r", checkRequest(1, version)); + var properties = new vip.mate.goal.config.GoalProperties(); properties.setEnabled(true); + var tool = new vip.mate.tool.builtin.GoalManagementTool(goals, properties, new com.fasterxml.jackson.databind.ObjectMapper(), null); + jdbc.update("UPDATE mate_goal_attempt SET lease_until=? WHERE attempt_id=?", java.time.LocalDateTime.now().minusSeconds(1), run.attempt().id()); + assertTrue(tool.completeGoal(origin.toToolContext()).contains("error")); + assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus()); + jdbc.update("UPDATE mate_goal_attempt SET lease_until=? WHERE attempt_id=?", java.time.LocalDateTime.now().plusSeconds(60), run.attempt().id()); + assertTrue(tool.completeGoal(origin.toToolContext()).contains("\"status\":\"completed\"")); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void runtimeCompletionRejectsMissingForeignAndRevokedIdentity(boolean automatic) { + GoalEntity goal = goal(false); + goals.appendCriterion(goal.getId(), "report", alice); + var evaluation = new GoalEvaluationResult(1, "checked", "completed", true, "fixture", 1, 0, + List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "semantic fixture")), null); + goals.recordEvaluation(goal.getId(), evaluation, 1, 1); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice); + bindings.check(goal.getId(), "r", checkRequest(1, version), alice); + var owner = accountOrigin(goal, alice); + for (var origin : List.of(vip.mate.agent.context.ChatOrigin.EMPTY, accountOrigin(goal, bob), + owner.withAgent(999L), owner.withWorkspace(999L, null), accountOrigin(goal(false), alice))) { + assertThrows(MateClawException.class, () -> runtimeComplete(goal, evaluation, origin, automatic)); + } + jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice); + assertThrows(MateClawException.class, () -> runtimeComplete(goal, evaluation, owner, automatic)); + assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus()); + jdbc.update("UPDATE mate_user SET enabled=TRUE WHERE username=?", alice); + assertEquals(GoalStatus.COMPLETED, runtimeComplete(goal, evaluation, owner, automatic).getStatus()); + } + + private GoalEntity runtimeComplete(GoalEntity goal, GoalEvaluationResult evaluation, vip.mate.agent.context.ChatOrigin origin, boolean automatic) { + return automatic ? goals.markRuntimeEvaluatedCompleted(goal.getId(), evaluation, origin) + : goals.markRuntimeCompleted(goal.getId(), evaluation, origin); + } + } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java index 15f381a2..b7d49176 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/GoalManagementToolTest.java @@ -142,14 +142,14 @@ class GoalManagementToolTest { when(goalService.findActiveByConversation("conv-1")).thenReturn(null); String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); assertTrue(result.contains("No active goal")); - verify(goalService, never()).markCompleted(any(), any(GoalEvaluationResult.class)); + verify(goalService, never()).markRuntimeCompleted(any(), any(GoalEvaluationResult.class), any()); } @Test void completeGoal_happyPath_callsMarkCompleted() { when(goalService.findActiveByConversation("conv-1")).thenReturn(goal(GoalStatus.ACTIVE)); GoalEntity completed = goal(GoalStatus.COMPLETED); - when(goalService.markCompleted(eq(123L), any(GoalEvaluationResult.class))) + when(goalService.markRuntimeCompleted(eq(123L), any(GoalEvaluationResult.class), any())) .thenReturn(completed); when(goalService.toResponse(any())).thenReturn(new vip.mate.goal.model.GoalResponse()); String result = tool.completeGoal(ctxWith("conv-1", 10L, "alice")); @@ -246,7 +246,7 @@ class GoalManagementToolTest { assertTrue(result.contains("\"status\":\"paused\"")); assertTrue(result.contains("Need the deployment hostname")); verify(streamTracker).broadcastObject(eq("conv-1"), eq("goal_updated"), any()); - verify(goalService, never()).markCompleted(any(), any()); + verify(goalService, never()).markRuntimeCompleted(any(), any(), any()); } @Test