feat: add coding harness runtime and CMS module

This commit is contained in:
ageerle
2026-08-15 23:04:34 +08:00
parent 9d439d1dcf
commit 25ab33c79c
243 changed files with 24314 additions and 143 deletions

View File

@@ -112,6 +112,12 @@ services:
SPRING_DATA_REDIS_HOST: redis
SPRING_DATA_REDIS_PORT: 6379
SPRING_DATA_REDIS_DATABASE: 0
# Allow large presentation files for knowledge-base ingestion.
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: 600MB
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: 620MB
VECTOR_STORE_TYPE: weaviate
VECTOR_STORE_WEAVIATE_PROTOCOL: http
VECTOR_STORE_WEAVIATE_HOST: weaviate:8080
# 日志配置
LOGGING_LEVEL_ORG_RUOYI: info
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: warn

View File

@@ -0,0 +1,23 @@
-- ----------------------------
-- 内容管理表 cms_content
-- 2026-08-15 创建
-- ----------------------------
CREATE TABLE IF NOT EXISTS `cms_content` (
`content_id` bigint NOT NULL COMMENT '内容ID',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '000000' COMMENT '租户编号',
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '内容标题',
`summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '摘要',
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '正文内容',
`cover_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '封面图片',
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0草稿 1已发布 2下架)',
`publish_time` datetime NULL DEFAULT NULL COMMENT '发布时间',
`sort_order` int NULL DEFAULT 0 COMMENT '排序顺序',
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`content_id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '内容管理表' ROW_FORMAT = DYNAMIC;

View File

@@ -0,0 +1,62 @@
-- ============================================================
-- 内容管理(CMS)菜单权限脚本
-- 2026-08-15
-- 菜单 ID 使用 2099010100000000040–2099010100000000045 段(已验证空区间),
-- 与现有 snowflake id(2xxxxxxxxxxxxxxxxx)及 2099010100000000xxx 既有段不冲突。
-- 幂等:每行通过 WHERE NOT EXISTS 同时检查固定 menu_id 与对应的 path/component/perms 语义,
-- 避免同语义菜单已用其他 ID 存在时重复插入。
-- ============================================================
-- 目录:内容管理 (parent_id=0, path='cms', menu_type='M')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000040, '内容管理', 0, 6, 'cms', '', '', 1, 0, 'M', '0', '0', '', 'fluent:content-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容管理目录'
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000040
UNION
SELECT 1 FROM `sys_menu` WHERE `path` = 'cms' AND `menu_type` = 'M' AND `parent_id` = 0
);
-- 页面:内容列表 (parent_id=2099010100000000040, component='cms/content/index', menu_type='C')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000041, '内容列表', 2099010100000000040, 1, 'content', 'cms/content/index', NULL, 1, 0, 'C', '0', '0', 'cms:content:list', 'fluent:document-text-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容列表菜单'
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000041
UNION
SELECT 1 FROM `sys_menu` WHERE `component` = 'cms/content/index' AND `menu_type` = 'C'
);
-- 按钮:内容查询 (perms='cms:content:query')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000042, '内容查询', 2099010100000000041, 1, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:query', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000042
UNION
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:query' AND `menu_type` = 'F'
);
-- 按钮:内容新增 (perms='cms:content:add')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000043, '内容新增', 2099010100000000041, 2, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:add', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000043
UNION
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:add' AND `menu_type` = 'F'
);
-- 按钮:内容修改 (perms='cms:content:edit')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000044, '内容修改', 2099010100000000041, 3, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:edit', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000044
UNION
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:edit' AND `menu_type` = 'F'
);
-- 按钮:内容删除 (perms='cms:content:remove')
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
SELECT 2099010100000000045, '内容删除', 2099010100000000041, 4, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:remove', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
WHERE NOT EXISTS (
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000045
UNION
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:remove' AND `menu_type` = 'F'
);

View File

@@ -20,6 +20,17 @@ server:
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
worker: 256
# Harness 命令仍受工作区租约、执行计划和工具审批约束;开启后才能验证并运行生成的网站。
coding:
harness:
budget:
# 模型回合上限;工具额度按每回合最多 3 次保持同比例,避免工具预算先于回合耗尽。
max-iterations: 200
max-tool-calls: 600
tools:
execute-process:
enabled: true
--- # 小程序对话 WebSocket 兜底默认模型(前端未传 model 且无智能体绑定时使用)
chat:
default-model: deepseek-v4-flash
@@ -129,7 +140,6 @@ security:
- /*/api-docs/**
- /warm-flow-ui/config
- /workflow/run
- /coding/**
# 多租户配置
tenant:
# 是否开启

View File

@@ -8,6 +8,7 @@ import org.ruoyi.common.core.utils.SpringUtils;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.core.task.VirtualThreadTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@@ -39,6 +40,7 @@ public class ThreadPoolConfig {
/**
* 执行周期性或定时任务
*/
@Primary
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
// daemon 必须为 true

View File

@@ -9,6 +9,7 @@ import cn.dev33.satoken.router.SaRouter;
import cn.dev33.satoken.stp.StpUtil;
import cn.dev33.satoken.util.SaResult;
import cn.dev33.satoken.util.SaTokenConsts;
import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
@@ -50,9 +51,11 @@ public class SecurityConfig implements WebMvcConfigurer {
public void addInterceptors(InterceptorRegistry registry) {
// 注册路由拦截器,自定义验证规则
registry.addInterceptor(new SaInterceptor(handler -> {
HttpServletRequest request = ServletUtils.getRequest();
// 异步线程中 SaToken 上下文不存在,跳过检查
// 这避免了 SSE 流式响应完成后 emitter.complete() 触发的问题
if (SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
if (request.getDispatcherType() != DispatcherType.REQUEST
|| SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
return;
}
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
@@ -62,9 +65,6 @@ public class SecurityConfig implements WebMvcConfigurer {
.match(allUrlHandler.getUrls())
// 对未排除的路径进行检查
.check(() -> {
HttpServletRequest request = ServletUtils.getRequest();
HttpServletResponse response = ServletUtils.getResponse();
response.setContentType(SaTokenConsts.CONTENT_TYPE_APPLICATION_JSON);
// 检查是否登录 是否有token
StpUtil.checkLogin();

View File

@@ -84,6 +84,13 @@
<artifactId>HikariCP</artifactId>
</dependency>
<!-- Pure-Java, read-only worktree inspection for the shell-free coding Harness. -->
<dependency>
<groupId>org.eclipse.jgit</groupId>
<artifactId>org.eclipse.jgit</artifactId>
<version>7.6.0.202603022253-r</version>
</dependency>
<!--文档解析-->
<dependency>
<groupId>dev.langchain4j</groupId>

View File

@@ -1,5 +1,6 @@
package org.ruoyi.controller.coding;
import cn.dev33.satoken.annotation.SaCheckPermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.satoken.utils.LoginHelper;
@@ -11,6 +12,8 @@ import org.ruoyi.enums.ModelType;
import org.ruoyi.service.coding.CodingWorkspaceService;
import org.ruoyi.service.coding.ICodingService;
import org.springframework.http.MediaType;
import org.springframework.http.HttpStatus;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.GetMapping;
@@ -20,15 +23,15 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 编程能力接口(B 路径,不走 Supervisor 调度)
*
* <p>第一阶段 {@code /coding/**} 在 {@code application.yml} 的 security.excludes 中,
* 免鉴权直连。Controller 只做参数绑定 + 同步取 userId(Sa-Token 异步上下文丢失,
* 见 SecurityConfig 注释)+ 转发 Service。
* <p>{@code /coding/**} 使用全局 Sa-Token 拦截器验证登录态。Controller 只做参数绑定
* 与同步获取 userId(Sa-Token 异步上下文不传播),然后转发 Service。
*
* @author ageerle
*/
@@ -36,12 +39,20 @@ import java.util.List;
@RestController
@RequiredArgsConstructor
@RequestMapping("/coding")
@SaCheckPermission("coding:harness:use")
public class CodingController {
private final ICodingService codingService;
private final CodingWorkspaceService workspaceService;
private final IChatModelService chatModelService;
/**
* The legacy endpoint exposes an unbounded AiServices loop with delete and process tools. It is
* kept only as an explicit migration escape hatch; the durable Harness API is the safe default.
*/
@Value("${coding.legacy.enabled:false}")
private boolean legacyEnabled;
/**
* 编程对话(SSE 流式)
*
@@ -49,8 +60,10 @@ public class CodingController {
* @return SseEmitter
*/
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@SaCheckPermission({"coding:harness:write", "coding:harness:legacy-command"})
public SseEmitter chat(@Valid @RequestBody CodingRequestBo bo) {
// 同步线程取 userId;第一阶段免鉴权,可能为 null
requireLegacyEnabled();
// 在进入异步执行前于已鉴权的 HTTP 线程取 userId。
Long userId = LoginHelper.getUserId();
return codingService.chat(bo, userId);
}
@@ -58,11 +71,13 @@ public class CodingController {
@GetMapping("/workspace")
public R<CodingWorkspaceService.WorkspaceResult> workspace(
@RequestParam(required = false) String workspacePath) throws Exception {
requireLegacyEnabled();
return R.ok(workspaceService.list(workspacePath));
}
@GetMapping("/models")
public R<List<ModelOption>> models() {
requireLegacyEnabled();
// 编程对话只能用聊天模型,按 category=chat 过滤
ChatModelBo bo = new ChatModelBo();
bo.setCategory(ModelType.CHAT.getKey());
@@ -76,19 +91,31 @@ public class CodingController {
public R<CodingWorkspaceService.FileContent> file(
@RequestParam(required = false) String workspacePath,
@RequestParam String path) throws Exception {
requireLegacyEnabled();
return R.ok(workspaceService.read(workspacePath, path));
}
@PutMapping("/file")
@SaCheckPermission("coding:harness:write")
public R<CodingWorkspaceService.FileContent> saveFile(@RequestBody FileWriteRequest request) throws Exception {
requireLegacyEnabled();
return R.ok(workspaceService.write(request.workspacePath(), request.path(), request.content()));
}
@PostMapping("/command")
@SaCheckPermission({"coding:harness:write", "coding:harness:legacy-command"})
public R<CodingWorkspaceService.CommandResult> command(@RequestBody CommandRequest request) {
requireLegacyEnabled();
return R.ok(workspaceService.execute(request.workspacePath(), request.command()));
}
private void requireLegacyEnabled() {
if (!legacyEnabled) {
throw new ResponseStatusException(HttpStatus.GONE,
"Legacy coding endpoints are disabled; use /coding/harness");
}
}
public record FileWriteRequest(String workspacePath, String path, String content) { }
public record CommandRequest(String workspacePath, String command) { }
public record ModelOption(Long id, String name, String provider) { }

View File

@@ -0,0 +1,310 @@
package org.ruoyi.controller.coding;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.stp.StpUtil;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.service.coding.harness.app.CodingHarnessApplicationService;
import org.ruoyi.service.coding.harness.app.CreateHarnessRunCommand;
import org.ruoyi.service.coding.harness.app.CreateHarnessSessionCommand;
import org.ruoyi.service.coding.harness.app.QueueHarnessInputCommand;
import org.ruoyi.service.coding.harness.approval.ApprovalDecision;
import org.ruoyi.service.coding.harness.event.HarnessEventHub;
import org.ruoyi.service.coding.harness.event.HarnessEventSubscription;
import org.ruoyi.service.coding.harness.model.HarnessBudget;
import org.ruoyi.service.coding.harness.model.HarnessApprovalPolicy;
import org.ruoyi.service.coding.harness.model.HarnessEvent;
import org.ruoyi.service.coding.harness.model.HarnessInputKind;
import org.ruoyi.service.coding.harness.model.HarnessMessage;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
import org.ruoyi.service.coding.harness.model.HarnessRunState;
import org.ruoyi.service.coding.harness.model.HarnessSessionState;
import org.ruoyi.service.coding.harness.modelruntime.HarnessModelRegistry;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
/** Durable coding-agent Harness API. Authentication is mandatory for every endpoint. */
@Validated
@RestController
@RequestMapping("/coding/harness")
@SaCheckPermission("coding:harness:use")
public class CodingHarnessController {
private static final long STREAM_TIMEOUT_MILLIS = 24 * 60 * 60 * 1000L;
private final CodingHarnessApplicationService applicationService;
private final HarnessEventHub eventHub;
private final HarnessModelRegistry modelRegistry;
public CodingHarnessController(CodingHarnessApplicationService applicationService,
HarnessEventHub eventHub,
HarnessModelRegistry modelRegistry) {
this.applicationService = applicationService;
this.eventHub = eventHub;
this.modelRegistry = modelRegistry;
}
@PostMapping("/sessions")
public R<HarnessSessionState> createSession(@Valid @RequestBody CreateSessionRequest request) {
if (request.permissionMode() != HarnessPermissionMode.READ_ONLY) {
// Permission mode is a requested ceiling, never a client-granted authority.
StpUtil.checkPermission("coding:harness:write");
}
modelRegistry.requireConfigured(request.model());
return R.ok(applicationService.createSession(owner(), new CreateHarnessSessionCommand(
request.workspacePath(), request.model(), request.permissionMode(),
request.approvalPolicy(), request.title(), request.idempotencyKey())));
}
@GetMapping("/sessions")
public R<List<HarnessSessionState>> listSessions() {
return R.ok(applicationService.listSessions(owner()));
}
@GetMapping("/sessions/{sessionId}")
public R<HarnessSessionState> getSession(@PathVariable String sessionId) {
return R.ok(applicationService.getSession(owner(), sessionId));
}
@GetMapping("/sessions/{sessionId}/messages")
public R<List<HarnessMessage>> messages(
@PathVariable String sessionId,
@RequestParam(defaultValue = "0") @Min(0) long afterSequence,
@RequestParam(defaultValue = "500") @Min(1) @Max(10_000) int limit) {
return R.ok(applicationService.readMessages(owner(), sessionId, afterSequence, limit));
}
@PostMapping("/sessions/{sessionId}/runs")
public R<HarnessRunState> createRun(@PathVariable String sessionId,
@Valid @RequestBody CreateRunRequest request) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.createRun(owner(), sessionId,
new CreateHarnessRunCommand(request.requirement(), request.budget(),
request.idempotencyKey())));
}
@GetMapping("/sessions/{sessionId}/runs")
public R<List<HarnessRunState>> listRuns(@PathVariable String sessionId) {
return R.ok(applicationService.listRuns(owner(), sessionId));
}
@GetMapping("/sessions/{sessionId}/runs/{runId}")
public R<HarnessRunState> getRun(@PathVariable String sessionId, @PathVariable String runId) {
return R.ok(applicationService.getRun(owner(), sessionId, runId));
}
@GetMapping("/sessions/{sessionId}/runs/{runId}/events")
public R<List<HarnessEvent>> events(
@PathVariable String sessionId,
@PathVariable String runId,
@RequestParam(defaultValue = "0") @Min(0) long afterSequence,
@RequestParam(defaultValue = "1000") @Min(1) @Max(10_000) int limit) {
return R.ok(applicationService.readEvents(owner(), sessionId, runId, afterSequence, limit));
}
@GetMapping(value = "/sessions/{sessionId}/runs/{runId}/events/stream",
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter streamEvents(
@PathVariable String sessionId,
@PathVariable String runId,
@RequestParam(required = false) Long afterSequence,
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
HarnessOwner owner = owner();
applicationService.getRun(owner, sessionId, runId);
long cursor = resolveCursor(afterSequence, lastEventId);
SseEmitter emitter = new SseEmitter(STREAM_TIMEOUT_MILLIS);
AtomicReference<HarnessEventSubscription> subscription = new AtomicReference<>();
AtomicBoolean transportClosed = new AtomicBoolean(false);
Runnable closeSubscription = () -> {
transportClosed.set(true);
HarnessEventSubscription current = subscription.getAndSet(null);
if (current != null) {
current.close();
}
};
emitter.onCompletion(closeSubscription);
emitter.onTimeout(closeSubscription);
emitter.onError(ignored -> closeSubscription.run());
HarnessEventSubscription created = eventHub.subscribe(owner, sessionId, runId, cursor,
event -> sendEvent(emitter, event, closeSubscription), error -> {
closeSubscription.run();
try {
emitter.completeWithError(error);
} catch (IllegalStateException ignored) {
// The client has already disconnected.
}
});
subscription.set(created);
if (transportClosed.get() && subscription.compareAndSet(created, null)) {
created.close();
}
return emitter;
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/inputs")
public R<HarnessRunState> queueInput(@PathVariable String sessionId, @PathVariable String runId,
@Valid @RequestBody QueueInputRequest request) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.queueInput(owner(), sessionId, runId,
new QueueHarnessInputCommand(request.kind(), request.content(),
request.idempotencyKey())));
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/cancel")
public R<HarnessRunState> cancel(@PathVariable String sessionId, @PathVariable String runId) {
return R.ok(applicationService.cancel(owner(), sessionId, runId));
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/resume")
public R<HarnessRunState> resume(@PathVariable String sessionId, @PathVariable String runId) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.resume(owner(), sessionId, runId));
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/approvals/{approvalId}/resolve")
@SaCheckPermission("coding:harness:approve")
public R<HarnessRunState> resolveApproval(
@PathVariable String sessionId,
@PathVariable String runId,
@PathVariable String approvalId,
@Valid @RequestBody ResolveApprovalRequest request) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.resolveToolApproval(owner(), sessionId, runId, approvalId,
request.decisionId(), request.decision(), request.expectedRevision(),
request.argumentsSha256(), request.note()));
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/plan/approve")
public R<HarnessRunState> approvePlan(
@PathVariable String sessionId,
@PathVariable String runId,
@Valid @RequestBody ApprovePlanRequest request) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.approvePlan(owner(), sessionId, runId, request.taskId(),
request.expectedRevision(), request.expectedHash(), request.idempotencyKey()));
}
@PostMapping("/sessions/{sessionId}/runs/{runId}/plan/revision")
public R<HarnessRunState> requestPlanRevision(
@PathVariable String sessionId,
@PathVariable String runId,
@Valid @RequestBody RequestPlanRevisionRequest request) {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.requestPlanRevision(owner(), sessionId, runId,
request.taskId(), request.expectedRevision(), request.expectedHash(),
request.feedbackId(), request.content()));
}
private void sendEvent(SseEmitter emitter, HarnessEvent event, Runnable closeSubscription) {
try {
emitter.send(SseEmitter.event().id(Long.toString(event.sequence()))
.name(event.type()).data(event));
if (event.type().equals("run.completed") || event.type().equals("run.failed")
|| event.type().equals("run.cancelled")) {
closeSubscription.run();
emitter.complete();
}
} catch (IOException | IllegalStateException error) {
closeSubscription.run();
try {
emitter.completeWithError(error);
} catch (IllegalStateException ignored) {
// The response is already complete.
}
}
}
private long resolveCursor(Long queryCursor, String lastEventId) {
long cursor = queryCursor == null ? 0 : queryCursor;
if (lastEventId != null && !lastEventId.isBlank()) {
try {
cursor = Math.max(cursor, Long.parseLong(lastEventId));
} catch (NumberFormatException invalid) {
throw new IllegalArgumentException("Last-Event-ID must be a non-negative sequence");
}
}
if (cursor < 0) {
throw new IllegalArgumentException("Event cursor cannot be negative");
}
return cursor;
}
private HarnessOwner owner() {
return new HarnessOwner(LoginHelper.getTenantId(), LoginHelper.getUserId());
}
private void requireCurrentPermissionCeiling(String sessionId) {
HarnessSessionState session = applicationService.getSession(owner(), sessionId);
if (session.permissionMode() != HarnessPermissionMode.READ_ONLY) {
StpUtil.checkPermission("coding:harness:write");
}
}
public record CreateSessionRequest(
String workspacePath,
@NotBlank @Size(max = 200) String model,
@NotNull HarnessPermissionMode permissionMode,
HarnessApprovalPolicy approvalPolicy,
@Size(max = 200) String title,
@NotBlank @Size(max = 256) String idempotencyKey
) { }
public record CreateRunRequest(
@NotBlank @Size(max = 200_000) String requirement,
@Valid HarnessBudget budget,
@NotBlank @Size(max = 256) String idempotencyKey
) { }
public record QueueInputRequest(
@NotNull HarnessInputKind kind,
@NotBlank @Size(max = 200_000) String content,
@NotBlank @Size(max = 256) String idempotencyKey
) { }
public record ResolveApprovalRequest(
@NotBlank @Size(max = 256) String decisionId,
@NotNull ApprovalDecision decision,
@Min(0) long expectedRevision,
@NotBlank @Size(min = 64, max = 64) String argumentsSha256,
@Size(max = 2_048) String note
) { }
public record ApprovePlanRequest(
@NotNull UUID taskId,
@Min(0) long expectedRevision,
@NotBlank @Size(min = 64, max = 64) String expectedHash,
@NotBlank @Size(max = 256) String idempotencyKey
) { }
public record RequestPlanRevisionRequest(
@NotNull UUID taskId,
@Min(0) long expectedRevision,
@NotBlank @Size(min = 64, max = 64) String expectedHash,
@NotBlank @Size(max = 256) String feedbackId,
@NotBlank @Size(max = 20_000) String content
) { }
}

View File

@@ -0,0 +1,49 @@
package org.ruoyi.controller.coding;
import org.ruoyi.common.core.domain.R;
import org.ruoyi.service.coding.harness.app.HarnessConflictException;
import org.ruoyi.service.coding.harness.app.HarnessNotFoundException;
import org.ruoyi.service.coding.harness.runtime.HarnessSchedulerCapacityException;
import org.ruoyi.service.coding.harness.store.HarnessOptimisticLockException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(assignableTypes = CodingHarnessController.class)
public class CodingHarnessExceptionHandler {
@ExceptionHandler(HarnessNotFoundException.class)
public ResponseEntity<R<Void>> notFound(HarnessNotFoundException error) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(R.fail(HttpStatus.NOT_FOUND.value(), error.getMessage()));
}
@ExceptionHandler(HarnessConflictException.class)
public ResponseEntity<R<Void>> conflict(HarnessConflictException error) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(R.fail(HttpStatus.CONFLICT.value(), error.getMessage()));
}
@ExceptionHandler(HarnessSchedulerCapacityException.class)
public ResponseEntity<R<Void>> tooManyRequests(HarnessSchedulerCapacityException error) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.header("Retry-After", "5")
.body(R.fail(HttpStatus.TOO_MANY_REQUESTS.value(), error.getMessage()));
}
@ExceptionHandler(HarnessOptimisticLockException.class)
public ResponseEntity<R<Void>> optimisticConflict(HarnessOptimisticLockException error) {
return ResponseEntity.status(HttpStatus.CONFLICT)
.body(R.fail(HttpStatus.CONFLICT.value(), error.getMessage()));
}
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<R<Void>> badRequest(IllegalArgumentException error) {
return ResponseEntity.badRequest()
.body(R.fail(HttpStatus.BAD_REQUEST.value(), error.getMessage()));
}
}

View File

@@ -0,0 +1,24 @@
package org.ruoyi.service.chat;
import lombok.RequiredArgsConstructor;
import org.ruoyi.domain.vo.chat.ChatSessionVo;
import org.springframework.stereotype.Component;
/** Fail-closed ownership check shared by HTTP and WebSocket chat entry points. */
@Component
@RequiredArgsConstructor
public class ChatSessionOwnershipGuard {
private final IChatSessionService chatSessionService;
public void requireOwned(Long userId, Long sessionId) {
if (userId == null || userId <= 0 || sessionId == null || sessionId <= 0) {
throw new IllegalArgumentException("An authenticated chat session is required");
}
ChatSessionVo session = chatSessionService.queryById(sessionId);
if (session == null || !userId.equals(session.getUserId())) {
// Do not reveal whether a guessed identifier exists.
throw new IllegalArgumentException("Chat session is unavailable");
}
}
}

View File

@@ -8,11 +8,6 @@ import dev.langchain4j.data.message.AiMessage;
import dev.langchain4j.data.message.ChatMessage;
import dev.langchain4j.data.message.SystemMessage;
import dev.langchain4j.data.message.UserMessage;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.transport.McpTransport;
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
import dev.langchain4j.model.chat.ChatModel;
import dev.langchain4j.model.chat.StreamingChatModel;
@@ -22,9 +17,6 @@ import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
import dev.langchain4j.rag.content.Content;
import dev.langchain4j.rag.content.retriever.ContentRetriever;
import dev.langchain4j.service.tool.ToolProvider;
import dev.langchain4j.skills.FileSystemSkill;
import dev.langchain4j.skills.FileSystemSkillLoader;
import dev.langchain4j.skills.shell.ShellSkills;
import dev.langchain4j.rag.AugmentationRequest;
import dev.langchain4j.rag.AugmentationResult;
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
@@ -37,7 +29,6 @@ import lombok.extern.slf4j.Slf4j;
import org.ruoyi.agent.ChartGenerationAgent;
import org.ruoyi.agent.ChitChatAgent;
import org.ruoyi.agent.EchartsAgent;
import org.ruoyi.agent.SkillsAgent;
import org.ruoyi.agent.SqlAgent;
import org.ruoyi.agent.WebSearchAgent;
import org.ruoyi.agent.tool.ExecuteSqlQueryTool;
@@ -66,7 +57,6 @@ import org.ruoyi.common.trace.domain.TraceNode;
import org.ruoyi.common.trace.domain.TraceRun;
import org.ruoyi.common.trace.service.TraceRecordService;
import org.ruoyi.common.trace.util.TracePayloadUtils;
import org.ruoyi.config.agent.SkillsPathResolver;
import org.ruoyi.domain.bo.vector.QueryVectorBo;
import org.ruoyi.domain.vo.agent.AgentVo;
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
@@ -75,6 +65,7 @@ import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService;
import org.ruoyi.observability.*;
import org.ruoyi.service.agent.IAgentService;
import org.ruoyi.service.chat.AbstractChatService;
import org.ruoyi.service.chat.ChatSessionOwnershipGuard;
import org.ruoyi.service.chat.IChatMessageService;
import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore;
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
@@ -124,6 +115,8 @@ public class ChatServiceFacade implements IChatService {
private final IChatMessageService chatMessageService;
private final ChatSessionOwnershipGuard chatSessionOwnershipGuard;
private final IWorkFlowStarterService workFlowStarterService;
@@ -152,6 +145,7 @@ public class ChatServiceFacade implements IChatService {
public SseEmitter sseChat(ChatRequest chatRequest) {
Long userId = LoginHelper.getUserId();
String tokenValue = StpUtil.getTokenValue();
chatSessionOwnershipGuard.requireOwned(userId, chatRequest.getSessionId());
boolean workflowMode = Boolean.TRUE.equals(chatRequest.getEnableWorkFlow());
boolean agentMode = chatRequest.getAgentId() != null;
@@ -288,34 +282,28 @@ public class ChatServiceFacade implements IChatService {
Long userId = chatRequest.getUserId();
String sessionId = String.valueOf(chatRequest.getSessionId());
// 工具装配:智能体有关联工具ID时按ID装配,否则回退到原有硬编码 MCP 客户端
ToolProvider toolProvider;
// Only explicitly configured legacy MCP tools are installed. The old implicit
// Playwright/filesystem fallback bypassed Harness leases and approvals.
ToolProvider toolProvider = null;
if (agentVo != null && agentVo.getMcpToolIds() != null && !agentVo.getMcpToolIds().isEmpty()) {
toolProvider = langChain4jMcpToolProviderService.getToolProvider(agentVo.getMcpToolIds());
} else {
toolProvider = buildDefaultMcpToolProvider(sessionId);
}
// Skills 装配:智能体有勾选技能名时按名过滤磁盘 skills,否则加载全部
ShellSkills skills = buildShellSkills(agentVo);
// 构建子 Agent
WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
var searchAgentBuilder = AgenticServices.agentBuilder(WebSearchAgent.class)
.chatModel(plannerModel)
.toolProvider(toolProvider)
.listener(new MyAgentListener())
.build();
// SkillsAgent:仅当有可用 skills 时才注入 systemMessage + toolProvider
var skillsAgentBuilder = AgenticServices.agentBuilder(SkillsAgent.class)
.chatModel(plannerModel);
if (skills != null) {
skillsAgentBuilder
.systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
+ "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.")
.toolProvider(skills.toolProvider());
.listener(new MyAgentListener());
if (toolProvider != null) {
searchAgentBuilder.toolProvider(toolProvider);
}
WebSearchAgent searchAgent = searchAgentBuilder.build();
// Disk skills previously exposed unrestricted run_shell_command. Coding skills now live
// behind the Harness catalog, policy engine, and per-call approval flow.
if (agentVo != null && agentVo.getSkillNames() != null
&& !agentVo.getSkillNames().isEmpty()) {
log.warn("Legacy shell-backed skills are disabled; use the coding Harness skill runtime");
}
SkillsAgent skillsAgent = skillsAgentBuilder.build();
// 构建子 Agent 3: SqlAgent - 负责数据库查询
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
@@ -345,7 +333,7 @@ public class ChatServiceFacade implements IChatService {
// 构建监督者 Agent - 管理多个子 Agent
var supervisorBuilder = AgenticServices.supervisorBuilder()
.chatModel(plannerModel)
.subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, chitChatAgent)
.subAgents(searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, chitChatAgent)
.supervisorContext("仅当请求是问候或简单闲聊、不需要任何数据、搜索、技能或图表时,才使用 chitChatAgent;"
+ "其余情况必须使用对应的专业 Agent")
.responseStrategy(SupervisorResponseStrategy.LAST);
@@ -503,67 +491,6 @@ public class ChatServiceFacade implements IChatService {
}
}
/**
* 兜底 MCP 工具装配(无智能体时使用,保留原有 3 个硬编码客户端逻辑)
*
* @param sessionId 会话ID,用于 MCP 工具事件按会话推送 SSE
*/
private ToolProvider buildDefaultMcpToolProvider(String sessionId) {
String npxCommand = resolveNpxCommand();
McpTransport playwrightTransport = new StdioMcpTransport.Builder()
.command(List.of(npxCommand, "-y", "@playwright/mcp@latest"))
.logEvents(true)
.build();
McpClient playwrightMcpClient = new DefaultMcpClient.Builder()
.transport(playwrightTransport)
.listener(new MyMcpClientListener(sessionId))
.build();
String userDir = System.getProperty("user.dir");
McpTransport filesystemTransport = new StdioMcpTransport.Builder()
.command(List.of(npxCommand, "-y",
"@modelcontextprotocol/server-filesystem", userDir))
.logEvents(true)
.build();
McpClient filesystemMcpClient = new DefaultMcpClient.Builder()
.transport(filesystemTransport)
.listener(new MyMcpClientListener(sessionId))
.build();
return McpToolProvider.builder()
.mcpClients(List.of(playwrightMcpClient, filesystemMcpClient))
.build();
}
private String resolveNpxCommand() {
String configured = System.getProperty("mcp.npx.command");
if (StringUtils.isNotBlank(configured)) return configured;
String fromEnv = System.getenv("MCP_NPX_COMMAND");
if (StringUtils.isNotBlank(fromEnv)) return fromEnv;
return System.getProperty("os.name", "").toLowerCase().contains("win") ? "npx.cmd" : "npx";
}
/**
* 装配磁盘 ShellSkills:智能体勾选了技能名时按名过滤,否则加载全部。
* 无 skills 时返回 null(调用方据此跳过 SkillsAgent 的 toolProvider 注入)
*/
private ShellSkills buildShellSkills(AgentVo agentVo) {
java.nio.file.Path skillsPath = SkillsPathResolver.resolveSkillsPath();
List<FileSystemSkill> skillsList = FileSystemSkillLoader.loadSkills(skillsPath);
if (skillsList == null || skillsList.isEmpty()) {
return null;
}
if (agentVo != null && agentVo.getSkillNames() != null && !agentVo.getSkillNames().isEmpty()) {
skillsList = skillsList.stream()
.filter(s -> agentVo.getSkillNames().contains(s.name()))
.toList();
if (skillsList.isEmpty()) {
return null;
}
}
return ShellSkills.from(skillsList);
}
/**
* 智能体对话下的输入增强:智能体绑定知识库时,对原始 content 做多知识库 RAG 增强。
* 无知识库时原样返回 content。

View File

@@ -1,6 +1,8 @@
package org.ruoyi.service.coding;
import org.ruoyi.mcp.tools.ExecuteCommandTool;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
@@ -9,7 +11,10 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Comparator;
import java.util.HexFormat;
import java.util.List;
/** Basic, workspace-scoped file and command operations for the Copilot UI. */
@@ -20,6 +25,21 @@ public class CodingWorkspaceService {
private static final long MAX_FILE_BYTES = 1024 * 1024;
private static final int MAX_ENTRIES = 500;
@Value("${coding.harness.workspace.owner-root:./data/coding-workspaces}")
private String harnessOwnerRoot = "./data/coding-workspaces";
/** Empty by default. Setting this is an explicit operator opt-in to a shared repository. */
@Value("${coding.harness.workspace.shared-root:}")
private String harnessSharedRoot = "";
public CodingWorkspaceService() {
}
CodingWorkspaceService(Path ownerRoot, Path sharedRoot) {
this.harnessOwnerRoot = ownerRoot.toString();
this.harnessSharedRoot = sharedRoot == null ? "" : sharedRoot.toString();
}
public WorkspaceResult list(String workspacePath) throws IOException {
Path root = resolveRoot(workspacePath);
Files.createDirectories(root);
@@ -76,11 +96,67 @@ public class CodingWorkspaceService {
if (workspacePath == null || workspacePath.isBlank()) return configured;
Path requested = Paths.get(workspacePath).toAbsolutePath().normalize();
if (!requested.equals(configured)) {
throw new IllegalArgumentException("Workspace is not allowed: " + requested);
throw new IllegalArgumentException(
"Workspace is not allowed. Allowed workspace: " + configured + "; requested: " + requested);
}
return configured;
}
/**
* Resolves a Harness lease from authenticated ownership, never from a caller-selected path.
* A shared root is available only when the operator explicitly configures one; otherwise each
* tenant/user receives a physically distinct directory beneath the owner root.
*/
public Path resolveHarnessRoot(HarnessOwner owner, String requestedPath) {
if (owner == null) {
throw new IllegalArgumentException("Harness owner is required");
}
Path configuredShared = harnessSharedRoot == null || harnessSharedRoot.isBlank()
? null : Paths.get(harnessSharedRoot).toAbsolutePath().normalize();
Path expected;
if (configuredShared != null) {
expected = configuredShared;
} else {
Path ownerBase = Paths.get(harnessOwnerRoot).toAbsolutePath().normalize();
expected = ownerBase.resolve(tenantKey(owner.tenantId()))
.resolve(Long.toString(owner.userId())).normalize();
if (!expected.startsWith(ownerBase)) {
throw new IllegalArgumentException("Owner workspace escaped its configured root");
}
}
if (requestedPath != null && !requestedPath.isBlank()) {
Path requested = Paths.get(requestedPath).toAbsolutePath().normalize();
if (!requested.equals(expected)) {
throw new IllegalArgumentException(
"Workspace lease is not owned by the authenticated principal");
}
}
try {
Path boundary = configuredShared != null
? configuredShared : Paths.get(harnessOwnerRoot).toAbsolutePath().normalize();
Files.createDirectories(boundary);
Files.createDirectories(expected);
Path boundaryReal = boundary.toRealPath();
Path expectedReal = expected.toRealPath();
if (!expectedReal.startsWith(boundaryReal)) {
throw new IllegalArgumentException("Workspace lease resolves outside its boundary");
}
return expectedReal;
} catch (IOException error) {
throw new IllegalArgumentException("Cannot provision Harness workspace lease", error);
}
}
private String tenantKey(String tenantId) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(tenantId.getBytes(StandardCharsets.UTF_8));
return "t_" + HexFormat.of().formatHex(hash, 0, 12);
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private Path resolveFile(Path root, String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
throw new IllegalArgumentException("File path cannot be empty");

View File

@@ -1,6 +1,8 @@
package org.ruoyi.service.coding;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
/**
@@ -11,9 +13,9 @@ import java.nio.file.Path;
*
* <p>实现要点:
* <ul>
* <li>{@code root.toRealPath()} 解析符号链接(防软链接逃逸)</li>
* <li>{@code target.normalize()} 消除 {@code ..} 穿越段</li>
* <li>{@code startsWith} 是 Path 段前缀匹配,非字符串前缀({@code /workspace/abc} 不会误判成 {@code /workspace-evil})</li>
* <li>词法范围检查拦截外部绝对路径和 {@code ..} 穿越</li>
* <li>现存目标直接校验其真实路径</li>
* <li>待创建目标校验最近的现存父链,拦截软链接、junction 或 reparse point 逃逸</li>
* </ul>
*
* @author ageerle
@@ -31,20 +33,45 @@ public final class WorkspaceGuard {
* @return true 表示在 workspace 内,安全
*/
public static boolean isWithinWorkspace(Path root, Path target) {
if (root == null || target == null) {
return false;
}
try {
Path realRoot = root.toRealPath().normalize();
Path realTarget = target.normalize();
return realTarget.startsWith(realRoot);
} catch (IOException e) {
// 目标路径不存在或无法解析(如新建文件前其父目录链中有不存在的段)
// 退化为 normalize 后做段前缀匹配,仍能拦住明显的越界
try {
Path realRoot = root.toRealPath().normalize();
Path normalizedTarget = target.normalize();
return normalizedTarget.startsWith(realRoot);
} catch (IOException ignore) {
Path normalizedRoot = root.toAbsolutePath().normalize();
Path normalizedTarget = target.isAbsolute()
? target.toAbsolutePath().normalize()
: normalizedRoot.resolve(target).normalize();
// 先用调用方看到的 workspace 路径做词法边界检查。
if (!normalizedTarget.startsWith(normalizedRoot)) {
return false;
}
Path realRoot = normalizedRoot.toRealPath().normalize();
Path nearestExisting = nearestExistingAncestor(normalizedTarget);
Path realExisting = nearestExisting.toRealPath().normalize();
return realExisting.startsWith(realRoot);
} catch (IOException | SecurityException e) {
// 无法确定真实路径时必须 fail closed,不再退化为纯字符串判断。
return false;
}
}
/**
* 查找目标或其父链中最近的现存路径。
*
* <p>{@link LinkOption#NOFOLLOW_LINKS} 可识别悬空软链接;随后的
* {@code toRealPath()} 会解析软链接以及 Windows junction/reparse point。
*/
private static Path nearestExistingAncestor(Path target) throws IOException {
Path current = target;
while (current != null) {
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
return current;
}
current = current.getParent();
}
throw new IOException("Target has no existing ancestor: " + target);
}
}

View File

@@ -0,0 +1,24 @@
package org.ruoyi.service.coding.harness.app;
import org.ruoyi.service.coding.harness.model.HarnessBudget;
import java.util.UUID;
public record CreateHarnessRunCommand(String requirement, HarnessBudget budget,
String idempotencyKey) {
public CreateHarnessRunCommand(String requirement, HarnessBudget budget) {
this(requirement, budget, UUID.randomUUID().toString());
}
public CreateHarnessRunCommand {
if (requirement == null || requirement.isBlank()) {
throw new IllegalArgumentException("requirement is required");
}
if (idempotencyKey == null || idempotencyKey.isBlank()
|| idempotencyKey.length() > 256) {
throw new IllegalArgumentException("idempotencyKey is required and must not exceed 256 characters");
}
idempotencyKey = idempotencyKey.strip();
}
}

View File

@@ -0,0 +1,38 @@
package org.ruoyi.service.coding.harness.app;
import org.ruoyi.service.coding.harness.model.HarnessApprovalPolicy;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
import java.util.UUID;
public record CreateHarnessSessionCommand(
String workspacePath,
String model,
HarnessPermissionMode permissionMode,
HarnessApprovalPolicy approvalPolicy,
String title,
String idempotencyKey
) {
public CreateHarnessSessionCommand(String workspacePath, String model,
HarnessPermissionMode permissionMode, String title) {
this(workspacePath, model, permissionMode, HarnessApprovalPolicy.ON_REQUEST,
title, UUID.randomUUID().toString());
}
public CreateHarnessSessionCommand(String workspacePath, String model,
HarnessPermissionMode permissionMode, String title,
String idempotencyKey) {
this(workspacePath, model, permissionMode, HarnessApprovalPolicy.ON_REQUEST,
title, idempotencyKey);
}
public CreateHarnessSessionCommand {
approvalPolicy = approvalPolicy == null ? HarnessApprovalPolicy.ON_REQUEST : approvalPolicy;
if (idempotencyKey == null || idempotencyKey.isBlank()
|| idempotencyKey.length() > 256) {
throw new IllegalArgumentException("idempotencyKey is required and must not exceed 256 characters");
}
idempotencyKey = idempotencyKey.strip();
}
}

View File

@@ -0,0 +1,64 @@
package org.ruoyi.service.coding.harness.app;
import org.ruoyi.service.coding.harness.model.HarnessBudget;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
/** Server-authoritative ceiling; zero leaves a token dimension unbounded. */
@Component
public final class HarnessBudgetPolicy {
private static final HarnessBudget LEGACY_DEFAULT =
new HarnessBudget(80, 240, 0, 100_000, 6 * 60 * 60 * 1000L);
private final HarnessBudget ceiling;
@Autowired
public HarnessBudgetPolicy(
@Value("${coding.harness.budget.max-iterations:200}") int maxIterations,
@Value("${coding.harness.budget.max-tool-calls:600}") int maxToolCalls,
@Value("${coding.harness.budget.max-input-tokens:0}") long maxInputTokens,
@Value("${coding.harness.budget.max-output-tokens:100000}") long maxOutputTokens,
@Value("${coding.harness.budget.max-wall-time-millis:21600000}") long maxWallTimeMillis
) {
this(new HarnessBudget(maxIterations, maxToolCalls, maxInputTokens, maxOutputTokens,
maxWallTimeMillis));
if (maxOutputTokens == 0) {
throw new IllegalArgumentException("Server Harness output-token ceiling must be finite");
}
}
public HarnessBudgetPolicy(HarnessBudget ceiling) {
if (ceiling == null || ceiling.maxOutputTokens() == 0) {
throw new IllegalArgumentException("A finite Harness output-token ceiling is required");
}
this.ceiling = ceiling;
}
public static HarnessBudgetPolicy secureDefaults() {
return new HarnessBudgetPolicy(HarnessBudget.defaults());
}
public HarnessBudget enforce(HarnessBudget requested) {
HarnessBudget value = requested == null ? HarnessBudget.defaults() : requested;
return new HarnessBudget(
Math.min(value.maxIterations(), ceiling.maxIterations()),
Math.min(value.maxToolCalls(), ceiling.maxToolCalls()),
bounded(value.maxInputTokens(), ceiling.maxInputTokens()),
bounded(value.maxOutputTokens(), ceiling.maxOutputTokens()),
Math.min(value.maxWallTimeMillis(), ceiling.maxWallTimeMillis()));
}
/** Upgrades only the former server default; explicitly smaller custom budgets stay smaller. */
public HarnessBudget forFollowUp(HarnessBudget inherited) {
return enforce(LEGACY_DEFAULT.equals(inherited) ? null : inherited);
}
private long bounded(long requested, long maximum) {
if (maximum == 0) {
return 0;
}
return requested == 0 ? maximum : Math.min(requested, maximum);
}
}

View File

@@ -0,0 +1,11 @@
package org.ruoyi.service.coding.harness.app;
public class HarnessConflictException extends RuntimeException {
public HarnessConflictException(String message) {
super(message);
}
public HarnessConflictException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,7 @@
package org.ruoyi.service.coding.harness.app;
public class HarnessNotFoundException extends RuntimeException {
public HarnessNotFoundException(String resource, String id) {
super("Unknown Harness " + resource + ": " + id);
}
}

View File

@@ -0,0 +1,22 @@
package org.ruoyi.service.coding.harness.app;
import org.ruoyi.service.coding.harness.model.HarnessInputKind;
import java.util.UUID;
public record QueueHarnessInputCommand(HarnessInputKind kind, String content,
String idempotencyKey) {
public QueueHarnessInputCommand {
if (kind == null || kind == HarnessInputKind.INITIAL || content == null || content.isBlank()
|| idempotencyKey == null || idempotencyKey.isBlank()
|| idempotencyKey.length() > 256) {
throw new IllegalArgumentException("A queued input must be STEER or FOLLOW_UP and have content");
}
content = content.strip();
idempotencyKey = idempotencyKey.strip();
}
public QueueHarnessInputCommand(HarnessInputKind kind, String content) {
this(kind, content, UUID.randomUUID().toString());
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.approval;
/** The exact invocation arguments no longer match the approved digest. */
public class ApprovalArgumentsMismatchException extends SecurityException {
public ApprovalArgumentsMismatchException() {
super("Tool arguments do not match the approval-bound SHA-256 digest");
}
}

View File

@@ -0,0 +1,42 @@
package org.ruoyi.service.coding.harness.approval;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
/** Persisted receipt proving which worker consumed the one-shot approval. */
public record ApprovalClaimReceipt(
String claimId,
String workerId,
long expectedRevision,
long consumedRevision,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision,
long consumedAt
) {
public ApprovalClaimReceipt {
claimId = ApprovalValidation.requireId(claimId, "claimId");
workerId = ApprovalValidation.requireId(workerId, "workerId");
if (expectedRevision < 0 || consumedRevision != expectedRevision + 1 || owner == null
|| permissionMode == null || permissionRevision < 0 || consumedAt <= 0) {
throw new IllegalArgumentException("Invalid approval claim receipt");
}
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
}
public boolean matches(ClaimApprovalCommand command) {
return command != null
&& claimId.equals(command.claimId())
&& workerId.equals(command.workerId())
&& expectedRevision == command.expectedRevision()
&& argumentsSha256.equals(command.argumentsSha256())
&& owner.equals(command.owner())
&& sessionId.equals(command.sessionId())
&& permissionMode == command.permissionMode()
&& permissionRevision == command.permissionRevision();
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.approval;
/** A command id or one-shot approval was already used by a different payload. */
public class ApprovalConflictException extends IllegalStateException {
public ApprovalConflictException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,7 @@
package org.ruoyi.service.coding.harness.approval;
/** Human control-plane decision. A decision never executes the tool. */
public enum ApprovalDecision {
APPROVE,
DENY
}

View File

@@ -0,0 +1,41 @@
package org.ruoyi.service.coding.harness.approval;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import java.util.Objects;
/** Persisted idempotency receipt for an HTTP/control-plane decision. */
public record ApprovalDecisionReceipt(
String decisionId,
ApprovalDecision decision,
long expectedRevision,
long resolvedRevision,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
String note,
long resolvedAt
) {
public ApprovalDecisionReceipt {
decisionId = ApprovalValidation.requireId(decisionId, "decisionId");
if (decision == null || expectedRevision < 0 || resolvedRevision != expectedRevision + 1
|| owner == null || resolvedAt <= 0) {
throw new IllegalArgumentException("Invalid approval decision receipt");
}
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
note = ApprovalValidation.normalizeOptional(note);
}
public boolean matches(ResolveApprovalCommand command) {
return command != null
&& decisionId.equals(command.decisionId())
&& decision == command.decision()
&& expectedRevision == command.expectedRevision()
&& argumentsSha256.equals(command.argumentsSha256())
&& owner.equals(command.owner())
&& sessionId.equals(command.sessionId())
&& Objects.equals(note, command.note());
}
}

View File

@@ -0,0 +1,13 @@
package org.ruoyi.service.coding.harness.approval;
/** The tool approval deadline has passed and execution is denied. */
public class ApprovalExpiredException extends IllegalStateException {
public ApprovalExpiredException() {
super(ApprovalOutcomeReason.EXPIRED.code());
}
public ApprovalOutcomeReason reason() {
return ApprovalOutcomeReason.EXPIRED;
}
}

View File

@@ -0,0 +1,25 @@
package org.ruoyi.service.coding.harness.approval;
/** Stable machine-readable reasons used to synthesize a fail-closed tool outcome. */
public enum ApprovalOutcomeReason {
DENIED("approval_denied", "Tool call was denied before execution."),
EXPIRED("approval_expired", "Tool approval expired before execution."),
PERMISSION_CHANGED("approval_permission_changed",
"Tool approval no longer matches the active permission snapshot.");
private final String code;
private final String message;
ApprovalOutcomeReason(String code, String message) {
this.code = code;
this.message = message;
}
public String code() {
return code;
}
public String message() {
return message;
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.approval;
/** Owner/session context does not match the approval authority boundary. */
public class ApprovalOwnershipException extends SecurityException {
public ApprovalOwnershipException() {
super("Tool approval owner or session does not match");
}
}

View File

@@ -0,0 +1,13 @@
package org.ruoyi.service.coding.harness.approval;
/** A worker's current permission snapshot differs from the snapshot awaiting approval. */
public class ApprovalPermissionChangedException extends SecurityException {
public ApprovalPermissionChangedException() {
super(ApprovalOutcomeReason.PERMISSION_CHANGED.code());
}
public ApprovalOutcomeReason reason() {
return ApprovalOutcomeReason.PERMISSION_CHANGED;
}
}

View File

@@ -0,0 +1,14 @@
package org.ruoyi.service.coding.harness.approval;
/** Authoritative lifecycle of one exact tool invocation approval. */
public enum ApprovalState {
PENDING,
APPROVED,
DENIED,
EXPIRED,
CONSUMED;
public boolean isTerminal() {
return this == DENIED || this == EXPIRED || this == CONSUMED;
}
}

View File

@@ -0,0 +1,22 @@
package org.ruoyi.service.coding.harness.approval;
/** Stable synthetic error result for a denied or expired invocation. */
public record ApprovalSyntheticOutcome(
String toolCallId,
String toolName,
ApprovalState state,
String reason,
String message
) {
public ApprovalSyntheticOutcome {
if (toolCallId == null || toolCallId.isBlank() || toolName == null || toolName.isBlank()
|| state == null || reason == null || reason.isBlank()
|| message == null || message.isBlank()) {
throw new IllegalArgumentException("Invalid synthetic approval outcome");
}
if (state != ApprovalState.DENIED && state != ApprovalState.EXPIRED) {
throw new IllegalArgumentException("Synthetic outcome requires DENIED or EXPIRED state");
}
}
}

View File

@@ -0,0 +1,44 @@
package org.ruoyi.service.coding.harness.approval;
import java.util.regex.Pattern;
final class ApprovalValidation {
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
private ApprovalValidation() {
}
static String requireId(String value, String field) {
if (value == null || value.isBlank() || value.length() > 256) {
throw new IllegalArgumentException(field + " must be a non-blank bounded id");
}
String normalized = value.strip();
for (int i = 0; i < normalized.length(); i++) {
char character = normalized.charAt(i);
if (Character.isISOControl(character)) {
throw new IllegalArgumentException(field + " must not contain control characters");
}
}
return normalized;
}
static String requireSha256(String value) {
if (value == null || !SHA_256.matcher(value).matches()) {
throw new IllegalArgumentException(
"argumentsSha256 must be an exact lowercase SHA-256 digest");
}
return value;
}
static String normalizeOptional(String value) {
if (value == null || value.isBlank()) {
return null;
}
String normalized = value.strip();
if (normalized.length() > 2_048) {
throw new IllegalArgumentException("Approval note exceeds its limit");
}
return normalized;
}
}

View File

@@ -0,0 +1,28 @@
package org.ruoyi.service.coding.harness.approval;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
/** Worker-only one-shot claim that binds execution to the current security snapshot. */
public record ClaimApprovalCommand(
String claimId,
String workerId,
long expectedRevision,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision
) {
public ClaimApprovalCommand {
claimId = ApprovalValidation.requireId(claimId, "claimId");
workerId = ApprovalValidation.requireId(workerId, "workerId");
if (expectedRevision < 0 || owner == null || permissionMode == null
|| permissionRevision < 0) {
throw new IllegalArgumentException("Invalid approval claim command");
}
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
}
}

View File

@@ -0,0 +1,222 @@
package org.ruoyi.service.coding.harness.approval;
import jakarta.annotation.PreDestroy;
import lombok.extern.slf4j.Slf4j;
import org.ruoyi.service.coding.harness.event.HarnessEventHub;
import org.ruoyi.service.coding.harness.event.HarnessEventOutboxService;
import org.ruoyi.service.coding.harness.model.HarnessApproval;
import org.ruoyi.service.coding.harness.model.HarnessApprovalStatus;
import org.ruoyi.service.coding.harness.model.HarnessEvent;
import org.ruoyi.service.coding.harness.model.HarnessRunState;
import org.ruoyi.service.coding.harness.model.HarnessRunStatus;
import org.ruoyi.service.coding.harness.runtime.HarnessRunRequest;
import org.ruoyi.service.coding.harness.runtime.HarnessScheduler;
import org.ruoyi.service.coding.harness.runtime.HarnessSessionGate;
import org.ruoyi.service.coding.harness.store.HarnessRunScanPage;
import org.ruoyi.service.coding.harness.store.HarnessStore;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import java.util.Map;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* Expires abandoned approval waits and continuously redispatches durable runnable work. The
* rotating cursor also completes a bounded startup-recovery scan without retaining an unbounded
* in-memory continuation.
*/
@Component
@Slf4j
public final class HarnessApprovalExpiryMonitor {
private final HarnessStore store;
private final HarnessScheduler scheduler;
private final HarnessSessionGate sessionGate;
private final HarnessEventHub eventHub;
private final HarnessEventOutboxService eventOutboxService;
private final ScheduledExecutorService maintenance;
private final boolean enabled;
private final long intervalMillis;
private final int pageSize;
private final int maxRuns;
private final AtomicBoolean started = new AtomicBoolean();
private volatile ScheduledFuture<?> future;
private volatile String scanCursor;
@Autowired
public HarnessApprovalExpiryMonitor(
HarnessStore store,
HarnessScheduler scheduler,
HarnessSessionGate sessionGate,
HarnessEventHub eventHub,
HarnessEventOutboxService eventOutboxService,
@Qualifier("codingHarnessMaintenanceScheduler") ScheduledExecutorService maintenance,
@Value("${coding.harness.approvals.expiry-monitor.enabled:true}") boolean enabled,
@Value("${coding.harness.approvals.expiry-monitor.interval-millis:5000}")
long intervalMillis,
@Value("${coding.harness.approvals.expiry-monitor.page-size:64}") int pageSize,
@Value("${coding.harness.approvals.expiry-monitor.max-runs:1000}") int maxRuns
) {
if (intervalMillis < 100 || pageSize < 1 || pageSize > 1_000 || maxRuns < 1) {
throw new IllegalArgumentException("Invalid approval expiry monitor limits");
}
this.store = store;
this.scheduler = scheduler;
this.sessionGate = sessionGate;
this.eventHub = eventHub;
this.eventOutboxService = eventOutboxService;
this.maintenance = maintenance;
this.enabled = enabled;
this.intervalMillis = intervalMillis;
this.pageSize = pageSize;
this.maxRuns = maxRuns;
}
public HarnessApprovalExpiryMonitor(
HarnessStore store,
HarnessScheduler scheduler,
HarnessSessionGate sessionGate,
HarnessEventHub eventHub,
ScheduledExecutorService maintenance,
boolean enabled,
long intervalMillis,
int pageSize,
int maxRuns
) {
this(store, scheduler, sessionGate, eventHub,
new HarnessEventOutboxService(store, eventHub), maintenance, enabled,
intervalMillis, pageSize, maxRuns);
}
@EventListener(ApplicationReadyEvent.class)
@Order(Ordered.LOWEST_PRECEDENCE)
public void start() {
if (enabled && started.compareAndSet(false, true)) {
future = maintenance.scheduleWithFixedDelay(this::sweepSafely, 0,
intervalMillis, TimeUnit.MILLISECONDS);
}
}
private void sweepSafely() {
try {
sweepOnce();
} catch (RuntimeException failure) {
// One maintenance failure must not terminate future expiry passes.
log.error("Harness approval expiry sweep failed", failure);
}
}
/** Visible for deterministic tests and operational probes. */
public int sweepOnce() {
int scanned = 0;
int expired = 0;
String cursor = scanCursor;
long now = System.currentTimeMillis();
while (scanned < maxRuns) {
HarnessRunScanPage page = store.scanRunsForRecovery(cursor,
Math.min(pageSize, maxRuns - scanned));
for (HarnessRunState discovered : page.runs()) {
scanned++;
HarnessRunState run = eventOutboxService.drainBestEffort(discovered.owner(),
discovered);
if (run.status() == HarnessRunStatus.WAITING_FOR_APPROVAL) {
expired += expireRun(run, now);
} else if (run.status() == HarnessRunStatus.QUEUED
|| run.status() == HarnessRunStatus.RUNNING) {
scheduleRunnable(run);
}
}
if (!page.hasMore() || page.runs().isEmpty()) {
scanCursor = null;
break;
}
cursor = page.nextCursor();
scanCursor = cursor;
if (scanned >= maxRuns) {
break;
}
}
return expired;
}
private int expireRun(HarnessRunState discovered, long now) {
return sessionGate.withSession(discovered.owner(), discovered.sessionId(), () -> {
HarnessRunState run = store.findRun(discovered.owner(), discovered.sessionId(),
discovered.runId()).orElse(null);
if (run == null || run.status() != HarnessRunStatus.WAITING_FOR_APPROVAL) {
return 0;
}
HarnessRunState next = run;
int count = 0;
for (ToolCallApprovalAggregate approval : run.toolApprovals().values()) {
if ((approval.state() == ApprovalState.PENDING
|| approval.state() == ApprovalState.APPROVED)
&& now >= approval.expiresAt()) {
ToolCallApprovalAggregate expired = approval.expire(now);
next = next.withToolApproval(expired, now);
HarnessApproval preview = next.approvals().get(approval.approvalId());
if (preview != null) {
next = next.withApproval(new HarnessApproval(preview.approvalId(),
preview.toolCallId(), preview.toolName(), preview.capability(),
preview.summary(), preview.argumentsPreview(),
HarnessApprovalStatus.EXPIRED, preview.createdAt(), now, null,
"Approval expired before execution"), now);
}
count++;
}
}
if (count == 0) {
return 0;
}
boolean unresolved = next.toolApprovals().values().stream().anyMatch(approval ->
approval.state() == ApprovalState.PENDING
|| approval.state() == ApprovalState.APPROVED);
if (!unresolved) {
next = next.transition(HarnessRunStatus.QUEUED, null, now);
}
HarnessRunState saved = store.saveRun(run.owner(), next, run.revision());
for (ToolCallApprovalAggregate approval : saved.toolApprovals().values()) {
if (approval.state() == ApprovalState.EXPIRED
&& approval.updatedAt() == now) {
eventHub.publish(run.owner(), HarnessEvent.draft(run.sessionId(), run.runId(),
"approval.expired", null, approval.toolCallId(), approval.approvalId(),
Map.of("state", approval.state().name()), now));
}
}
if (saved.status() == HarnessRunStatus.QUEUED) {
scheduleRunnable(saved);
eventHub.publish(run.owner(), HarnessEvent.draft(run.sessionId(), run.runId(),
"run.queued", null, null, null,
Map.of("reason", "approval_expired"), now));
}
return count;
});
}
private void scheduleRunnable(HarnessRunState run) {
try {
scheduler.schedule(new HarnessRunRequest(run.owner(), run.sessionId(), run.runId()));
} catch (RuntimeException rejected) {
// QUEUED/RUNNING is the durable dispatch state. A later cursor rotation retries it.
log.warn("Harness runnable-run dispatch will be retried for run {}", run.runId(),
rejected);
}
}
@PreDestroy
public void stop() {
ScheduledFuture<?> scheduled = future;
if (scheduled != null) {
scheduled.cancel(false);
}
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.approval;
/** Requested action is incompatible with the authoritative approval state. */
public class InvalidApprovalTransitionException extends IllegalStateException {
public InvalidApprovalTransitionException(ApprovalState state, String action) {
super("Cannot " + action + " while tool approval is " + state);
}
}

View File

@@ -0,0 +1,25 @@
package org.ruoyi.service.coding.harness.approval;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
/** Idempotent control-plane command; resolving it cannot execute a tool. */
public record ResolveApprovalCommand(
String decisionId,
ApprovalDecision decision,
long expectedRevision,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
String note
) {
public ResolveApprovalCommand {
decisionId = ApprovalValidation.requireId(decisionId, "decisionId");
if (decision == null || expectedRevision < 0 || owner == null) {
throw new IllegalArgumentException("Invalid approval resolution command");
}
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
note = ApprovalValidation.normalizeOptional(note);
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.approval;
/** Optimistic revision supplied by a decision or worker no longer matches. */
public class StaleApprovalException extends IllegalStateException {
public StaleApprovalException(long expected, long actual) {
super("Stale tool approval revision: expected=" + expected + ", actual=" + actual);
}
}

View File

@@ -0,0 +1,356 @@
package org.ruoyi.service.coding.harness.approval;
import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
/**
* Persistable source of truth for one exact tool-call approval.
*
* <p>{@link #resolve(ResolveApprovalCommand, long)} is deliberately only a control-plane state
* transition. It has no executor callback and never consumes or runs the tool. A worker must
* separately call {@link #claimForExecution(ClaimApprovalCommand, long)}, which atomically moves
* an approved invocation to the one-shot {@link ApprovalState#CONSUMED} state.</p>
*/
public record ToolCallApprovalAggregate(
int schemaVersion,
String approvalId,
String runId,
String toolCallId,
String toolName,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision,
ApprovalState state,
long revision,
ApprovalDecisionReceipt decisionReceipt,
ApprovalClaimReceipt claimReceipt,
ApprovalOutcomeReason outcomeReason,
long createdAt,
long expiresAt,
long updatedAt
) {
public static final int CURRENT_SCHEMA_VERSION = 1;
public ToolCallApprovalAggregate {
if (schemaVersion < 1 || owner == null || permissionMode == null || permissionRevision < 0
|| state == null || revision < 0) {
throw new IllegalArgumentException("Invalid tool approval identity or state");
}
approvalId = ApprovalValidation.requireId(approvalId, "approvalId");
runId = ApprovalValidation.requireId(runId, "runId");
toolCallId = ApprovalValidation.requireId(toolCallId, "toolCallId");
toolName = ApprovalValidation.requireId(toolName, "toolName");
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
if (createdAt <= 0 || expiresAt <= createdAt || updatedAt < createdAt) {
throw new IllegalArgumentException("Invalid tool approval timestamps");
}
validateReceipts(argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
revision, createdAt, expiresAt, updatedAt, decisionReceipt, claimReceipt);
validateState(state, revision, decisionReceipt, claimReceipt, outcomeReason);
}
public static ToolCallApprovalAggregate create(
String approvalId,
String runId,
String toolCallId,
String toolName,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision,
long createdAt,
long expiresAt
) {
return new ToolCallApprovalAggregate(CURRENT_SCHEMA_VERSION, approvalId, runId,
toolCallId, toolName, argumentsSha256, owner, sessionId, permissionMode,
permissionRevision, ApprovalState.PENDING, 0, null, null, null,
createdAt, expiresAt, createdAt);
}
public static ToolCallApprovalAggregate create(
String runId,
String toolCallId,
String toolName,
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision,
long createdAt,
long expiresAt
) {
return create(UUID.randomUUID().toString(), runId, toolCallId, toolName,
argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
createdAt, expiresAt);
}
/**
* Resolves the human decision only. An APPROVE result remains non-executable until claimed by
* a worker. Replaying the exact decisionId and payload returns this aggregate unchanged.
*/
public ToolCallApprovalAggregate resolve(ResolveApprovalCommand command, long now) {
Objects.requireNonNull(command, "command");
if (decisionReceipt != null) {
if (decisionReceipt.decisionId().equals(command.decisionId())) {
if (decisionReceipt.matches(command)) {
return this;
}
throw new ApprovalConflictException(
"decisionId was already used with a different payload");
}
throw new ApprovalConflictException("Tool approval was already resolved");
}
requireState(ApprovalState.PENDING, "resolve");
requireActive(now);
requireOwnerAndSession(command.owner(), command.sessionId());
requireRevision(command.expectedRevision());
requireArguments(command.argumentsSha256());
long nextRevision = revision + 1;
ApprovalDecisionReceipt receipt = new ApprovalDecisionReceipt(command.decisionId(),
command.decision(), command.expectedRevision(), nextRevision,
command.argumentsSha256(), command.owner(), command.sessionId(), command.note(), now);
ApprovalState nextState = command.decision() == ApprovalDecision.APPROVE
? ApprovalState.APPROVED : ApprovalState.DENIED;
ApprovalOutcomeReason reason = command.decision() == ApprovalDecision.DENY
? ApprovalOutcomeReason.DENIED : null;
return next(nextState, nextRevision, receipt, null, reason, now);
}
/**
* Worker-only one-shot transition. Successful return authorizes only the exact bound arguments
* under the unchanged owner/session and permission snapshot recorded by the receipt.
*/
public ToolCallApprovalAggregate claimForExecution(ClaimApprovalCommand command, long now) {
Objects.requireNonNull(command, "command");
if (claimReceipt != null) {
if (claimReceipt.claimId().equals(command.claimId())) {
if (claimReceipt.matches(command)) {
return this;
}
throw new ApprovalConflictException(
"claimId was already used with a different payload");
}
throw new ApprovalConflictException("Tool approval was already consumed");
}
requireState(ApprovalState.APPROVED, "claim for execution");
requireActive(now);
requireOwnerAndSession(command.owner(), command.sessionId());
requireRevision(command.expectedRevision());
requireArguments(command.argumentsSha256());
if (permissionMode != command.permissionMode()
|| permissionRevision != command.permissionRevision()) {
throw new ApprovalPermissionChangedException();
}
long nextRevision = revision + 1;
ApprovalClaimReceipt receipt = new ApprovalClaimReceipt(command.claimId(),
command.workerId(), command.expectedRevision(), nextRevision,
command.argumentsSha256(), command.owner(), command.sessionId(),
command.permissionMode(), command.permissionRevision(), now);
return next(ApprovalState.CONSUMED, nextRevision, decisionReceipt, receipt, null, now);
}
/** Durably marks an unconsumed approval expired once its deadline is reached. */
public ToolCallApprovalAggregate expire(long now) {
if (state == ApprovalState.EXPIRED) {
return this;
}
if (state != ApprovalState.PENDING && state != ApprovalState.APPROVED) {
throw new InvalidApprovalTransitionException(state, "expire");
}
if (now < expiresAt) {
throw new IllegalStateException("Tool approval has not reached its expiry time");
}
return next(ApprovalState.EXPIRED, revision + 1, decisionReceipt, null,
ApprovalOutcomeReason.EXPIRED, now);
}
/**
* Optional durable invalidation hook for a permission update observed before worker claim.
* A mismatch always fails closed, even when the new mode is more permissive.
*/
public ToolCallApprovalAggregate invalidateForPermissionChange(
HarnessPermissionMode currentMode, long currentPermissionRevision, long now) {
if (currentMode == null || currentPermissionRevision < 0) {
throw new IllegalArgumentException("Invalid current permission snapshot");
}
if (permissionMode == currentMode && permissionRevision == currentPermissionRevision) {
return this;
}
if (state == ApprovalState.EXPIRED
&& outcomeReason == ApprovalOutcomeReason.PERMISSION_CHANGED) {
return this;
}
if (state != ApprovalState.PENDING && state != ApprovalState.APPROVED) {
throw new InvalidApprovalTransitionException(state,
"invalidate after a permission change");
}
return next(ApprovalState.EXPIRED, revision + 1, decisionReceipt, null,
ApprovalOutcomeReason.PERMISSION_CHANGED, now);
}
public ApprovalSyntheticOutcome syntheticOutcome() {
if (state != ApprovalState.DENIED && state != ApprovalState.EXPIRED) {
throw new InvalidApprovalTransitionException(state, "create a synthetic outcome");
}
return new ApprovalSyntheticOutcome(toolCallId, toolName, state,
outcomeReason.code(), outcomeReason.message());
}
/** Utility for hashing the exact canonical argument bytes before creating a request. */
public static String sha256(byte[] canonicalArguments) {
Objects.requireNonNull(canonicalArguments, "canonicalArguments");
try {
return HexFormat.of().formatHex(
MessageDigest.getInstance("SHA-256").digest(canonicalArguments));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 is unavailable", e);
}
}
private ToolCallApprovalAggregate next(ApprovalState nextState, long nextRevision,
ApprovalDecisionReceipt nextDecisionReceipt,
ApprovalClaimReceipt nextClaimReceipt,
ApprovalOutcomeReason nextOutcomeReason,
long now) {
if (now < updatedAt) {
throw new IllegalArgumentException("Tool approval update time must be monotonic");
}
return new ToolCallApprovalAggregate(schemaVersion, approvalId, runId, toolCallId,
toolName, argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
nextState, nextRevision, nextDecisionReceipt, nextClaimReceipt, nextOutcomeReason,
createdAt, expiresAt, now);
}
private void requireState(ApprovalState expected, String action) {
if (state != expected) {
if (state == ApprovalState.EXPIRED) {
throw new ApprovalExpiredException();
}
throw new InvalidApprovalTransitionException(state, action);
}
}
private void requireActive(long now) {
if (now < updatedAt) {
throw new IllegalArgumentException("Tool approval update time must be monotonic");
}
if (now >= expiresAt) {
throw new ApprovalExpiredException();
}
}
private void requireRevision(long expectedRevision) {
if (revision != expectedRevision) {
throw new StaleApprovalException(expectedRevision, revision);
}
}
private void requireOwnerAndSession(HarnessOwner commandOwner, String commandSessionId) {
if (!owner.equals(commandOwner) || !sessionId.equals(commandSessionId)) {
throw new ApprovalOwnershipException();
}
}
private void requireArguments(String commandHash) {
if (!MessageDigest.isEqual(argumentsSha256.getBytes(StandardCharsets.US_ASCII),
commandHash.getBytes(StandardCharsets.US_ASCII))) {
throw new ApprovalArgumentsMismatchException();
}
}
private static void validateReceipts(
String argumentsSha256,
HarnessOwner owner,
String sessionId,
HarnessPermissionMode permissionMode,
long permissionRevision,
long revision,
long createdAt,
long expiresAt,
long updatedAt,
ApprovalDecisionReceipt decisionReceipt,
ApprovalClaimReceipt claimReceipt
) {
if (decisionReceipt != null) {
if (!decisionReceipt.argumentsSha256().equals(argumentsSha256)
|| !decisionReceipt.owner().equals(owner)
|| !decisionReceipt.sessionId().equals(sessionId)
|| decisionReceipt.resolvedRevision() > revision
|| decisionReceipt.resolvedAt() < createdAt
|| decisionReceipt.resolvedAt() >= expiresAt
|| decisionReceipt.resolvedAt() > updatedAt) {
throw new IllegalArgumentException("Decision receipt does not belong to this approval");
}
}
if (claimReceipt != null) {
if (!claimReceipt.argumentsSha256().equals(argumentsSha256)
|| !claimReceipt.owner().equals(owner)
|| !claimReceipt.sessionId().equals(sessionId)
|| claimReceipt.permissionMode() != permissionMode
|| claimReceipt.permissionRevision() != permissionRevision
|| claimReceipt.consumedRevision() > revision
|| claimReceipt.consumedAt() < createdAt
|| claimReceipt.consumedAt() >= expiresAt
|| claimReceipt.consumedAt() > updatedAt) {
throw new IllegalArgumentException("Claim receipt does not belong to this approval");
}
}
}
private static void validateState(ApprovalState state,
long revision,
ApprovalDecisionReceipt decisionReceipt,
ApprovalClaimReceipt claimReceipt,
ApprovalOutcomeReason outcomeReason) {
switch (state) {
case PENDING -> requireInvariant(decisionReceipt == null && claimReceipt == null
&& outcomeReason == null && revision == 0,
"PENDING approval cannot have receipts, outcome, or advanced revision");
case APPROVED -> requireInvariant(decisionReceipt != null
&& decisionReceipt.decision() == ApprovalDecision.APPROVE
&& decisionReceipt.resolvedRevision() == revision
&& claimReceipt == null && outcomeReason == null,
"APPROVED approval requires only an approve receipt");
case DENIED -> requireInvariant(decisionReceipt != null
&& decisionReceipt.decision() == ApprovalDecision.DENY
&& decisionReceipt.resolvedRevision() == revision
&& claimReceipt == null && outcomeReason == ApprovalOutcomeReason.DENIED,
"DENIED approval requires a deny receipt and reason");
case EXPIRED -> requireInvariant(claimReceipt == null
&& (decisionReceipt == null
|| decisionReceipt.decision() == ApprovalDecision.APPROVE)
&& (decisionReceipt == null ? revision == 1
: revision == decisionReceipt.resolvedRevision() + 1)
&& (outcomeReason == ApprovalOutcomeReason.EXPIRED
|| outcomeReason == ApprovalOutcomeReason.PERMISSION_CHANGED),
"EXPIRED approval has invalid receipts or reason");
case CONSUMED -> requireInvariant(decisionReceipt != null
&& decisionReceipt.decision() == ApprovalDecision.APPROVE
&& claimReceipt != null
&& claimReceipt.expectedRevision() == decisionReceipt.resolvedRevision()
&& claimReceipt.consumedRevision() == revision
&& outcomeReason == null,
"CONSUMED approval requires approve and claim receipts");
}
}
private static void requireInvariant(boolean valid, String message) {
if (!valid) {
throw new IllegalArgumentException(message);
}
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.artifact;
/** Raised when stored bytes no longer match their content-addressed SHA-256 id. */
public class ArtifactCorruptedException extends ArtifactStoreException {
public ArtifactCorruptedException(String expectedHash, String actualHash) {
super("Artifact integrity check failed: expected=" + expectedHash + ", actual=" + actualHash);
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.artifact;
/** Raised before an oversized artifact or read can be accepted. */
public class ArtifactLimitExceededException extends ArtifactStoreException {
public ArtifactLimitExceededException(String message) {
super(message);
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.artifact;
/** Durable artifact discovery metadata; content remains available only through bounded reads. */
public record ArtifactManifestEntry(
String sourceRunId,
String artifactId,
long byteSize,
long createdAt
) { }

View File

@@ -0,0 +1,13 @@
package org.ruoyi.service.coding.harness.artifact;
import java.util.List;
public record ArtifactManifestPage(
List<ArtifactManifestEntry> artifacts,
String nextCursor,
boolean hasMore
) {
public ArtifactManifestPage {
artifacts = artifacts == null ? List.of() : List.copyOf(artifacts);
}
}

View File

@@ -0,0 +1,9 @@
package org.ruoyi.service.coding.harness.artifact;
/** Raised when a valid scoped artifact id has no stored object. */
public class ArtifactNotFoundException extends ArtifactStoreException {
public ArtifactNotFoundException(String artifactId) {
super("Artifact was not found: " + artifactId);
}
}

View File

@@ -0,0 +1,8 @@
package org.ruoyi.service.coding.harness.artifact;
public record ArtifactReadResult(
String artifactId,
long offset,
int byteLength,
String content
) { }

View File

@@ -0,0 +1,44 @@
package org.ruoyi.service.coding.harness.artifact;
import java.util.Objects;
/** Durable, portable reference to a content-addressed artifact. */
public record ArtifactRef(
String hash,
String relativeHandle,
String mediaType,
long byteSize,
String headPreview,
String tailPreview,
long createdAt
) {
public ArtifactRef {
hash = ArtifactStore.requireHash(hash);
relativeHandle = requireSafeHandle(relativeHandle);
mediaType = ArtifactStore.requireMediaType(mediaType);
if (byteSize < 0) {
throw new IllegalArgumentException("Artifact byteSize must not be negative");
}
headPreview = Objects.requireNonNull(headPreview, "headPreview");
tailPreview = Objects.requireNonNull(tailPreview, "tailPreview");
if (createdAt <= 0) {
throw new IllegalArgumentException("Artifact createdAt must be positive");
}
}
private static String requireSafeHandle(String handle) {
if (handle == null || handle.isBlank() || handle.startsWith("/")
|| handle.endsWith("/") || handle.contains("\\") || handle.contains(":")) {
throw new IllegalArgumentException("Artifact relativeHandle is not a safe relative path");
}
String[] segments = handle.split("/", -1);
for (String segment : segments) {
if (segment.isEmpty() || segment.equals(".") || segment.equals("..")
|| !segment.matches("[a-z0-9][a-z0-9_-]{0,127}")) {
throw new IllegalArgumentException("Artifact relativeHandle contains an unsafe segment");
}
}
return handle;
}
}

View File

@@ -0,0 +1,38 @@
package org.ruoyi.service.coding.harness.artifact;
import java.util.Locale;
import java.util.Set;
import java.util.regex.Pattern;
/** Canonical owner/session/run namespace for an artifact. */
public record ArtifactScope(
String ownerId,
String sessionId,
String runId
) {
private static final Pattern SAFE_ID =
Pattern.compile("[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?");
private static final Set<String> WINDOWS_RESERVED = Set.of(
"con", "prn", "aux", "nul",
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
);
public ArtifactScope {
ownerId = canonicalId(ownerId, "ownerId");
sessionId = canonicalId(sessionId, "sessionId");
runId = canonicalId(runId, "runId");
}
private static String canonicalId(String value, String field) {
if (value == null || value.isBlank()) {
throw new IllegalArgumentException(field + " must not be blank");
}
String canonical = value.strip().toLowerCase(Locale.ROOT);
if (!SAFE_ID.matcher(canonical).matches() || WINDOWS_RESERVED.contains(canonical)) {
throw new IllegalArgumentException(field + " is not a safe artifact scope id");
}
return canonical;
}
}

View File

@@ -0,0 +1,13 @@
package org.ruoyi.service.coding.harness.artifact;
/** Raised when an artifact path is unsafe or escapes its configured root. */
public class ArtifactSecurityException extends ArtifactStoreException {
public ArtifactSecurityException(String message) {
super(message);
}
public ArtifactSecurityException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@@ -0,0 +1,13 @@
package org.ruoyi.service.coding.harness.artifact;
/** Base exception for artifact persistence and integrity failures. */
public class ArtifactStoreException extends RuntimeException {
public ArtifactStoreException(String message) {
super(message);
}
public ArtifactStoreException(String message, Throwable cause) {
super(message, cause);
}
}

Some files were not shown because too many files have changed in this diff Show More