fix: fence runtime goal completion with current authenticated owner

This commit is contained in:
mateaix 2026-09-14 22:03:20 +08:00
parent 4a7f0e211e
commit 8cca7159fc
9 changed files with 97 additions and 13 deletions

View File

@ -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)

View File

@ -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);

View File

@ -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<GoalJsonBindingService.State>>(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;

View File

@ -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.

View File

@ -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.

View File

@ -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 暴露的免身份入口。

View File

@ -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<GraphEventPublisher.GraphEvent>) 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"));

View File

@ -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);
}
}

View File

@ -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