feat: 完善 Agent 标准交互与安全运行时

- 接入 AG-UI 运行投影、Turn 时间线和审批隔离

- 增加 Agent Skill 冻结绑定与运行时消费闭环

- 增加受控工作区、内置工具和私有 Artifact 生命周期
This commit is contained in:
2026-08-19 22:13:41 +08:00
parent 91d66e636d
commit 4e8640dcaf
241 changed files with 24382 additions and 2777 deletions

View File

@@ -0,0 +1,112 @@
package tech.easyflow.agent.config;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 五类产品级内置工具的类型化配置。
*/
public final class AgentBuiltinToolsConfig {
/** 当前配置结构版本。 */
public static final int SCHEMA_VERSION = 1;
private final ToolSwitch read;
private final ToolSwitch write;
private final ToolSwitch patch;
private final ToolSwitch shell;
private final ToolSwitch artifactPublish;
/**
* 创建内置工具配置。
*
* @param read 读取工具配置
* @param write 写入工具配置
* @param patch 补丁工具配置
* @param shell Shell 工具配置
* @param artifactPublish 产物发布工具配置
*/
public AgentBuiltinToolsConfig(ToolSwitch read,
ToolSwitch write,
ToolSwitch patch,
ToolSwitch shell,
ToolSwitch artifactPublish) {
this.read = read;
this.write = write;
this.patch = patch;
this.shell = shell;
this.artifactPublish = artifactPublish;
}
/**
* 返回新 Agent 的安全默认配置。
*
* @return 五项启用且仅 Shell 要求审批的配置
*/
public static AgentBuiltinToolsConfig newAgentDefaults() {
return new AgentBuiltinToolsConfig(
new ToolSwitch(true, false),
new ToolSwitch(true, false),
new ToolSwitch(true, false),
new ToolSwitch(true, true),
new ToolSwitch(true, false));
}
/**
* 返回旧发布快照的无扩权兼容配置。
*
* @return 五项全部禁用的配置
*/
public static AgentBuiltinToolsConfig allDisabled() {
ToolSwitch disabled = new ToolSwitch(false, false);
return new AgentBuiltinToolsConfig(disabled, disabled, disabled, disabled, disabled);
}
/** @return 读取工具配置 */
public ToolSwitch read() { return read; }
/** @return 写入工具配置 */
public ToolSwitch write() { return write; }
/** @return 补丁工具配置 */
public ToolSwitch patch() { return patch; }
/** @return Shell 工具配置 */
public ToolSwitch shell() { return shell; }
/** @return 产物发布工具配置 */
public ToolSwitch artifactPublish() { return artifactPublish; }
/**
* 转换为可写入 executionConfigJson 的稳定结构。
*
* @return 不含权限确认临时字段的安全 Map
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("schemaVersion", SCHEMA_VERSION);
result.put("read", read.toMap());
result.put("write", write.toMap());
result.put("patch", patch.toMap());
result.put("shell", shell.toMap());
result.put("artifactPublish", artifactPublish.toMap());
return result;
}
/**
* 单个内置工具的启用与审批配置。
*
* @param enabled 是否启用
* @param approvalRequired 是否要求调用前审批
*/
public record ToolSwitch(boolean enabled, boolean approvalRequired) {
/**
* 转换为持久化结构。
*
* @return 工具开关 Map
*/
public Map<String, Object> toMap() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("enabled", enabled);
result.put("approvalRequired", approvalRequired);
return result;
}
}
}

View File

@@ -0,0 +1,209 @@
package tech.easyflow.agent.config;
import org.springframework.stereotype.Component;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.system.service.CategoryPermissionService;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Agent 内置工具配置的默认值、兼容和权限统一解析器。
*/
@Component
public class AgentBuiltinToolsConfigResolver {
/** executionConfigJson 中的内置工具字段。 */
public static final String BUILTIN_TOOLS_KEY = "builtinTools";
/** 超级管理员关闭 Shell 审批时提交的一次性确认字段。 */
public static final String SHELL_RISK_CONFIRMATION_KEY = "shellApprovalRiskConfirmed";
private final CategoryPermissionService categoryPermissionService;
/**
* 创建解析器。
*
* @param categoryPermissionService 平台超级管理员判定服务
*/
public AgentBuiltinToolsConfigResolver(CategoryPermissionService categoryPermissionService) {
this.categoryPermissionService = categoryPermissionService;
}
/**
* 为草稿详情补齐展示默认值,但不直接写回数据库。
*
* @param source 原执行配置
* @return 带完整五项配置的副本
*/
public Map<String, Object> normalizeForDraftRead(Map<String, Object> source) {
return replaceBuiltinTools(source, parse(source, AgentBuiltinToolsConfig.newAgentDefaults()));
}
/**
* 规范化一次显式草稿保存,并校验关闭 Shell 审批的权限与风险确认。
*
* @param source 客户端提交的执行配置
* @param existingSource 更新前执行配置;新建时为 null
* @param account 当前账号
* @return 可持久化且已移除一次性确认字段的配置
*/
public Map<String, Object> normalizeForDraftSave(Map<String, Object> source,
Map<String, Object> existingSource,
LoginAccount account) {
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
AgentBuiltinToolsConfig existing = existingSource == null
? AgentBuiltinToolsConfig.newAgentDefaults()
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
boolean disablesShellApproval = disablesShellApproval(incoming, existing);
if (disablesShellApproval) {
if (!categoryPermissionService.isSuperAdmin(account)) {
throw new BusinessException(403, 403, "仅平台超级管理员可以关闭 Shell 调用前确认");
}
if (!riskConfirmed(source)) {
throw new BusinessException(400, 400, "关闭 Shell 调用前确认前必须完成高风险确认");
}
}
return replaceBuiltinTools(source, incoming);
}
/**
* 判断保存前后是否真实发生了 Shell 审批关闭变更。
*
* @param source 已规范化或待保存的执行配置
* @param existingSource 保存前执行配置;新建时为 null
* @return 从需审批或禁用状态切换到启用且免审批时为 true
*/
public boolean isShellApprovalDisableTransition(Map<String, Object> source,
Map<String, Object> existingSource) {
AgentBuiltinToolsConfig incoming = parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
AgentBuiltinToolsConfig existing = existingSource == null
? AgentBuiltinToolsConfig.newAgentDefaults()
: parse(existingSource, AgentBuiltinToolsConfig.newAgentDefaults());
return disablesShellApproval(incoming, existing);
}
/**
* 解析草稿运行配置;缺失时使用新 Agent 默认值。
*
* @param source 执行配置
* @return 类型化配置
*/
public AgentBuiltinToolsConfig resolveDraftRuntime(Map<String, Object> source) {
return parse(source, AgentBuiltinToolsConfig.newAgentDefaults());
}
/**
* 解析发布快照;旧快照缺失内置工具字段时全部禁用。
*
* @param source 发布快照中的执行配置
* @return 类型化配置
*/
public AgentBuiltinToolsConfig resolvePublishedRuntime(Map<String, Object> source) {
return parse(source, AgentBuiltinToolsConfig.allDisabled());
}
/**
* 规范化发布运行配置;旧快照缺失字段时显式写入五项禁用结果。
*
* @param source 发布快照执行配置
* @return 无静默扩权的完整配置副本
*/
public Map<String, Object> normalizeForPublishedRuntime(Map<String, Object> source) {
return replaceBuiltinTools(source, resolvePublishedRuntime(source));
}
/**
* 判断执行配置是否显式包含内置工具结构。
*
* @param source 执行配置
* @return 包含时为 true
*/
public boolean hasBuiltinTools(Map<String, Object> source) {
return source != null && source.containsKey(BUILTIN_TOOLS_KEY);
}
private Map<String, Object> replaceBuiltinTools(Map<String, Object> source,
AgentBuiltinToolsConfig config) {
Map<String, Object> result = source == null ? new LinkedHashMap<>() : new LinkedHashMap<>(source);
result.put(BUILTIN_TOOLS_KEY, config.toMap());
return result;
}
private AgentBuiltinToolsConfig parse(Map<String, Object> source, AgentBuiltinToolsConfig fallback) {
if (source == null || !source.containsKey(BUILTIN_TOOLS_KEY)) {
return fallback;
}
Map<String, Object> raw = requireMap(source.get(BUILTIN_TOOLS_KEY), "builtinTools 必须为对象");
validateSchemaVersion(raw.get("schemaVersion"));
return new AgentBuiltinToolsConfig(
tool(raw, "read", fallback.read()),
tool(raw, "write", fallback.write()),
tool(raw, "patch", fallback.patch()),
tool(raw, "shell", fallback.shell()),
tool(raw, "artifactPublish", fallback.artifactPublish()));
}
private boolean disablesShellApproval(AgentBuiltinToolsConfig incoming,
AgentBuiltinToolsConfig existing) {
return incoming.shell().enabled()
&& !incoming.shell().approvalRequired()
&& (existing.shell().approvalRequired() || !existing.shell().enabled());
}
private void validateSchemaVersion(Object value) {
if (value == null) {
return;
}
if (!(value instanceof Number number)
|| number.doubleValue() != number.intValue()
|| number.intValue() != AgentBuiltinToolsConfig.SCHEMA_VERSION) {
throw new BusinessException("不支持的 Agent 内置工具配置版本");
}
}
private AgentBuiltinToolsConfig.ToolSwitch tool(Map<String, Object> source,
String key,
AgentBuiltinToolsConfig.ToolSwitch fallback) {
if (!source.containsKey(key)) {
return fallback;
}
Map<String, Object> raw = requireMap(source.get(key), "Agent 内置工具项必须为对象: " + key);
return new AgentBuiltinToolsConfig.ToolSwitch(
booleanValue(raw, "enabled", fallback.enabled()),
booleanValue(raw, "approvalRequired", fallback.approvalRequired()));
}
private boolean riskConfirmed(Map<String, Object> source) {
Map<String, Object> raw = mapValue(source == null ? null : source.get(BUILTIN_TOOLS_KEY));
return raw != null && Boolean.TRUE.equals(raw.get(SHELL_RISK_CONFIRMATION_KEY));
}
private boolean booleanValue(Map<String, Object> source, String key, boolean fallback) {
if (!source.containsKey(key)) {
return fallback;
}
Object value = source.get(key);
if (value instanceof Boolean bool) {
return bool;
}
throw new BusinessException("Agent 内置工具开关必须为布尔值");
}
private Map<String, Object> requireMap(Object value, String message) {
Map<String, Object> mapped = mapValue(value);
if (mapped == null) {
throw new BusinessException(message);
}
return mapped;
}
private Map<String, Object> mapValue(Object value) {
if (!(value instanceof Map<?, ?> raw)) {
return null;
}
Map<String, Object> result = new LinkedHashMap<>();
raw.forEach((key, item) -> result.put(String.valueOf(key), item));
return result;
}
}

View File

@@ -16,7 +16,9 @@ import org.springframework.scheduling.annotation.EnableScheduling;
@EnableConfigurationProperties({
AgentRuntimeProperties.class,
AgentMediaProperties.class,
AgentDocumentProperties.class
AgentDocumentProperties.class,
AgentWorkspaceProperties.class,
AgentShellProperties.class
})
public class AgentModuleConfig {
}

View File

@@ -0,0 +1,68 @@
package tech.easyflow.agent.config;
import com.easyagents.agent.runtime.tool.operate.ControlledShellTool;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
/**
* 在应用启动后报告受控 Shell 固定白名单命令的实际可用性。
*/
@Component
public class AgentShellCommandAvailabilityReporter {
private static final Logger LOG = LoggerFactory.getLogger(AgentShellCommandAvailabilityReporter.class);
/**
* 检查当前进程 PATH并报告已安装与缺失的白名单命令。
*
* @param event 应用就绪事件
*/
@EventListener(ApplicationReadyEvent.class)
public void report(ApplicationReadyEvent event) {
List<String> available = new ArrayList<>();
List<String> missing = new ArrayList<>();
for (String command : ControlledShellTool.DEFAULT_ALLOWED_COMMANDS.stream().sorted().toList()) {
(isAvailable(command) ? available : missing).add(command);
}
LOG.info("Agent controlled Shell allowlist check completed, available={}", available);
if (!missing.isEmpty()) {
LOG.warn("Agent controlled Shell commands are unavailable in this runtime: {}", missing);
}
}
/**
* 判断一个不含路径分隔符的固定命令是否存在于当前 PATH。
*
* @param command 固定白名单命令
* @return 存在可执行普通文件时为 true
*/
private boolean isAvailable(String command) {
String pathValue = System.getenv("PATH");
if (pathValue == null || pathValue.isBlank()) {
return false;
}
for (String directory : pathValue.split(java.io.File.pathSeparator)) {
if (directory == null || directory.isBlank()) {
continue;
}
try {
Path executable = Path.of(directory).resolve(command);
if (Files.isRegularFile(executable) && Files.isExecutable(executable)) {
return true;
}
} catch (InvalidPathException ignored) {
// PATH 中的无效目录仅视为不可用,避免把宿主路径写入普通日志。
}
}
return false;
}
}

View File

@@ -0,0 +1,62 @@
package tech.easyflow.agent.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/**
* Agent 受控 Shell 的统一平台限制。
*/
@ConfigurationProperties(prefix = "easyflow.agent.shell")
public class AgentShellProperties implements InitializingBean {
private Duration defaultTimeout = Duration.ofSeconds(60);
private Duration maxTimeout = Duration.ofSeconds(300);
private int maxCommandLength = 4_096;
private DataSize maxOutputSize = DataSize.ofMegabytes(1);
private int maxConcurrentPerInstance = 2;
/** @return 默认超时 */
public Duration getDefaultTimeout() { return defaultTimeout; }
/** @param defaultTimeout 默认超时 */
public void setDefaultTimeout(Duration defaultTimeout) { this.defaultTimeout = defaultTimeout; }
/** @return 最大超时 */
public Duration getMaxTimeout() { return maxTimeout; }
/** @param maxTimeout 最大超时 */
public void setMaxTimeout(Duration maxTimeout) { this.maxTimeout = maxTimeout; }
/** @return 命令最大字符数 */
public int getMaxCommandLength() { return maxCommandLength; }
/** @param maxCommandLength 命令最大字符数 */
public void setMaxCommandLength(int maxCommandLength) { this.maxCommandLength = maxCommandLength; }
/** @return 输出最大字节数 */
public DataSize getMaxOutputSize() { return maxOutputSize; }
/** @param maxOutputSize 输出最大字节数 */
public void setMaxOutputSize(DataSize maxOutputSize) { this.maxOutputSize = maxOutputSize; }
/** @return 单实例最大并发数 */
public int getMaxConcurrentPerInstance() { return maxConcurrentPerInstance; }
/** @param maxConcurrentPerInstance 单实例最大并发数 */
public void setMaxConcurrentPerInstance(int maxConcurrentPerInstance) {
this.maxConcurrentPerInstance = maxConcurrentPerInstance;
}
/**
* 启动期校验 Shell 限制。
*/
@Override
public void afterPropertiesSet() {
if (!positive(defaultTimeout) || !positive(maxTimeout)
|| maxTimeout.compareTo(defaultTimeout) < 0) {
throw new IllegalStateException("Shell 最大超时必须大于等于正值默认超时");
}
if (maxCommandLength <= 0 || maxOutputSize == null || maxOutputSize.toBytes() <= 0
|| maxConcurrentPerInstance <= 0) {
throw new IllegalStateException("Shell 命令长度、输出大小和并发数必须为正值");
}
}
private boolean positive(Duration value) {
return value != null && !value.isZero() && !value.isNegative();
}
}

View File

@@ -0,0 +1,84 @@
package tech.easyflow.agent.config;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.util.StringUtils;
import org.springframework.util.unit.DataSize;
import java.time.Duration;
/**
* Agent 单会话工作区的路径、配额与保留配置。
*/
@ConfigurationProperties(prefix = "easyflow.agent.workspace")
public class AgentWorkspaceProperties implements InitializingBean {
private String root = "./agent-workspaces";
private DataSize maxTotalSize = DataSize.ofMegabytes(512);
private DataSize maxSingleFileSize = DataSize.ofMegabytes(100);
private int maxFileCount = 2_000;
private DataSize maxReadSize = DataSize.ofMegabytes(2);
private Duration retention = Duration.ofHours(24);
private Duration cleanupInterval = Duration.ofMinutes(30);
/** @return 工作区根目录 */
public String getRoot() { return root; }
/** @param root 工作区根目录 */
public void setRoot(String root) { this.root = root; }
/** @return 单会话工作区总量上限 */
public DataSize getMaxTotalSize() { return maxTotalSize; }
/** @param maxTotalSize 单会话工作区总量上限 */
public void setMaxTotalSize(DataSize maxTotalSize) { this.maxTotalSize = maxTotalSize; }
/** @return 单文件大小上限 */
public DataSize getMaxSingleFileSize() { return maxSingleFileSize; }
/** @param maxSingleFileSize 单文件大小上限 */
public void setMaxSingleFileSize(DataSize maxSingleFileSize) { this.maxSingleFileSize = maxSingleFileSize; }
/** @return 文件数量上限 */
public int getMaxFileCount() { return maxFileCount; }
/** @param maxFileCount 文件数量上限 */
public void setMaxFileCount(int maxFileCount) { this.maxFileCount = maxFileCount; }
/** @return 单次读取大小上限 */
public DataSize getMaxReadSize() { return maxReadSize; }
/** @param maxReadSize 单次读取大小上限 */
public void setMaxReadSize(DataSize maxReadSize) { this.maxReadSize = maxReadSize; }
/** @return 工作区保留期 */
public Duration getRetention() { return retention; }
/** @param retention 工作区保留期 */
public void setRetention(Duration retention) { this.retention = retention; }
/** @return 清理周期 */
public Duration getCleanupInterval() { return cleanupInterval; }
/** @param cleanupInterval 清理周期 */
public void setCleanupInterval(Duration cleanupInterval) { this.cleanupInterval = cleanupInterval; }
/**
* 启动期校验工作区配置,避免以无界或互相矛盾的限制启动。
*/
@Override
public void afterPropertiesSet() {
if (!StringUtils.hasText(root)) {
throw new IllegalStateException("easyflow.agent.workspace.root 不能为空");
}
requirePositive(maxTotalSize, "max-total-size");
requirePositive(maxSingleFileSize, "max-single-file-size");
requirePositive(maxReadSize, "max-read-size");
if (maxTotalSize.toBytes() < maxSingleFileSize.toBytes()) {
throw new IllegalStateException("工作区总量上限不能小于单文件大小上限");
}
if (maxReadSize.toBytes() > maxSingleFileSize.toBytes()) {
throw new IllegalStateException("工作区读取上限不能大于单文件大小上限");
}
if (maxFileCount <= 0 || !positive(retention) || !positive(cleanupInterval)) {
throw new IllegalStateException("工作区文件数量、保留期和清理周期必须为正值");
}
}
private void requirePositive(DataSize value, String name) {
if (value == null || value.toBytes() <= 0) {
throw new IllegalStateException("easyflow.agent.workspace." + name + " 必须为正值");
}
}
private boolean positive(Duration value) {
return value != null && !value.isZero() && !value.isNegative();
}
}