diff --git a/.gitignore b/.gitignore index b5711fbe..bbab869d 100644 --- a/.gitignore +++ b/.gitignore @@ -112,12 +112,17 @@ celerybeat.pid # Environments .env .venv +/.venv-*/ env/ venv/ ENV/ env.bak/ venv.bak/ +# Repository-local runtimes and package-manager caches +/.runtime/ +/main/manager-web/.npm-cache/ + # Spyder project settings .spyderproject .spyproject diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java index 255e606d..de4b57b2 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/controller/AgentSnapshotController.java @@ -6,17 +6,20 @@ import org.springframework.web.bind.annotation.DeleteMapping; 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.RequestMapping; import org.springframework.web.bind.annotation.RestController; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.AllArgsConstructor; import xiaozhi.common.exception.RenException; import xiaozhi.common.page.PageData; import xiaozhi.common.user.UserDetail; import xiaozhi.common.utils.Result; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; +import xiaozhi.modules.agent.dto.AgentSnapshotRestoreDTO; import xiaozhi.modules.agent.service.AgentService; import xiaozhi.modules.agent.service.AgentSnapshotService; import xiaozhi.modules.agent.vo.AgentSnapshotVO; @@ -51,9 +54,10 @@ public class AgentSnapshotController { @PostMapping("/{snapshotId}/restore") @Operation(summary = "恢复智能体快照") @RequiresPermissions("sys:role:normal") - public Result restore(@PathVariable String agentId, @PathVariable String snapshotId) { + public Result restore(@PathVariable String agentId, @PathVariable String snapshotId, + @RequestBody @Valid AgentSnapshotRestoreDTO request) { checkPermission(agentId); - agentSnapshotService.restoreSnapshot(agentId, snapshotId); + agentSnapshotService.restoreSnapshot(agentId, snapshotId, request.getCurrentStateToken()); return new Result<>(); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java index 4def2b67..9ad1536d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentDao.java @@ -43,4 +43,13 @@ public interface AgentDao extends BaseDao { * @param agentId 智能体ID */ AgentEntity selectByIdForUpdate(@Param("agentId") String agentId); + + /** + * 精确写入快照覆盖的智能体字段,包括目标快照中的 null 值。 + * 不更新所属用户、创建信息等不属于快照的字段。 + * + * @param agent 已应用目标快照的智能体 + * @return 受影响行数 + */ + int updateSnapshotFields(@Param("agent") AgentEntity agent); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java index ee17f15f..ec8f39e8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java @@ -1,5 +1,7 @@ package xiaozhi.modules.agent.dao; +import java.util.List; + import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; @@ -17,4 +19,11 @@ public interface AgentSnapshotDao extends BaseDao { int insertWithNextVersion(@Param("snapshot") AgentSnapshotEntity snapshot); int deleteOlderThanKeepLimit(@Param("agentId") String agentId, @Param("keepLimit") int keepLimit); + + List selectLegacyRedactionBatch(@Param("afterId") String afterId, + @Param("limit") int limit, + @Param("targetRedactionVersion") int targetRedactionVersion); + + int updateRedactedSnapshots(@Param("snapshots") List snapshots, + @Param("redactionVersion") int redactionVersion); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java new file mode 100644 index 00000000..aa003fa4 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/dto/AgentSnapshotRestoreDTO.java @@ -0,0 +1,13 @@ +package xiaozhi.modules.agent.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import lombok.Data; + +@Data +@Schema(description = "智能体快照恢复请求") +public class AgentSnapshotRestoreDTO { + @NotBlank + @Schema(description = "预览时由服务端生成的当前配置状态指纹") + private String currentStateToken; +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java index 0d99fd36..c4ad695d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/entity/AgentSnapshotEntity.java @@ -47,4 +47,7 @@ public class AgentSnapshotEntity { @Schema(description = "创建时间") private Date createdAt; + + @Schema(description = "快照数据脱敏规则版本") + private Integer redactionVersion; } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java index f88c47ea..57e03ebf 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/AgentSnapshotService.java @@ -13,11 +13,13 @@ public interface AgentSnapshotService extends BaseService { AgentSnapshotVO getSnapshot(String agentId, String snapshotId); - void restoreSnapshot(String agentId, String snapshotId); + void restoreSnapshot(String agentId, String snapshotId, String currentStateToken); void deleteSnapshot(String agentId, String snapshotId); Integer getCurrentVersionNo(String agentId); void deleteByAgentId(String agentId); + + long redactLegacySnapshots(); } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java index 3ad213df..615b80c2 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentServiceImpl.java @@ -57,9 +57,9 @@ import xiaozhi.modules.model.dto.VoiceDTO; import xiaozhi.modules.model.entity.ModelConfigEntity; import xiaozhi.modules.model.service.ModelConfigService; import xiaozhi.modules.model.service.ModelProviderService; -import xiaozhi.modules.security.user.SecurityUser; -import xiaozhi.modules.sys.enums.SuperAdminEnum; -import xiaozhi.modules.timbre.service.TimbreService; +import xiaozhi.modules.security.user.SecurityUser; +import xiaozhi.modules.sys.enums.SuperAdminEnum; +import xiaozhi.modules.timbre.service.TimbreService; @Service @AllArgsConstructor @@ -374,8 +374,9 @@ public class AgentServiceImpl extends BaseServiceImpl imp // 锁定后查询现有实体和关联配置 AgentEntity existingEntity = this.getAgentById(agentId); - if (createSnapshot && agentSnapshotService.getCurrentVersionNo(agentId) == 0) { - agentSnapshotService.createSnapshot(agentId, "initial"); + if (createSnapshot) { + int currentVersionNo = agentSnapshotService.getCurrentVersionNo(agentId); + agentSnapshotService.createSnapshot(agentId, currentVersionNo == 0 ? "initial" : "current"); } // 只更新提供的非空字段 @@ -614,16 +615,22 @@ public class AgentServiceImpl extends BaseServiceImpl imp template.setTtsVoiceId(timbre.getId()); } } - } - - entity.setTtsVoiceId(template.getTtsVoiceId()); - entity.setMemModelId(template.getMemModelId()); - entity.setIntentModelId(template.getIntentModelId()); - entity.setSystemPrompt(template.getSystemPrompt()); - entity.setSummaryMemory(template.getSummaryMemory()); - - // 根据记忆模型类型设置默认的chatHistoryConf值 - if (template.getMemModelId() != null) { + } + + entity.setTtsVoiceId(template.getTtsVoiceId()); + entity.setTtsLanguage(defaultIfBlank(template.getTtsLanguage(), + timbreModelService.getDefaultLanguageById(entity.getTtsVoiceId()))); + entity.setMemModelId(template.getMemModelId()); + entity.setIntentModelId(template.getIntentModelId()); + entity.setSystemPrompt(template.getSystemPrompt()); + entity.setSummaryMemory(template.getSummaryMemory()); + if (Constant.MEMORY_NO_MEM.equals(entity.getMemModelId()) + || Constant.MEMORY_MEM_REPORT_ONLY.equals(entity.getMemModelId())) { + entity.setSummaryMemory(""); + } + + // 根据记忆模型类型设置默认的chatHistoryConf值 + if (template.getMemModelId() != null) { if (template.getMemModelId().equals("Memory_nomem")) { // 无记忆功能的模型,默认不记录聊天记录 entity.setChatHistoryConf(0); @@ -678,13 +685,18 @@ public class AgentServiceImpl extends BaseServiceImpl imp mapping.setParamInfo(JsonUtils.toJsonString(paramInfo)); mapping.setAgentId(entity.getId()); toInsert.add(mapping); - } - // 保存默认插件 - agentPluginMappingService.saveBatch(toInsert); - return entity.getId(); - } - - private String getDefaultLLMModelId() { + } + // 保存默认插件 + agentPluginMappingService.saveBatch(toInsert); + agentSnapshotService.createSnapshot(entity.getId(), "initial"); + return entity.getId(); + } + + private String defaultIfBlank(String value, String defaultValue) { + return StringUtils.isBlank(value) ? defaultValue : value; + } + + private String getDefaultLLMModelId() { try { List llmConfigs = modelConfigService.getEnabledModelsByType("LLM"); if (llmConfigs == null || llmConfigs.isEmpty()) { diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java new file mode 100644 index 00000000..33ff67f6 --- /dev/null +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunner.java @@ -0,0 +1,59 @@ +package xiaozhi.modules.agent.service.impl; + +import java.util.concurrent.TimeUnit; + +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import xiaozhi.modules.agent.service.AgentSnapshotService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AgentSnapshotRedactionRunner implements SmartInitializingSingleton { + static final long ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS = 5_000; + static final long ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS = 15_000; + + private final AgentSnapshotService agentSnapshotService; + + @Override + public void afterSingletonsInstantiated() { + redactAndReport("startup"); + } + + @Scheduled(initialDelay = ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS, + fixedDelay = ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS) + public void redactLateRollingDeploymentWrites() { + redactAndReport("rolling-deployment"); + } + + private void redactAndReport(String trigger) { + long startedAt = System.nanoTime(); + try { + long migrated = agentSnapshotService.redactLegacySnapshots(); + long durationMillis = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt); + if (migrated > 0) { + log.warn("Agent snapshot legacy redaction trigger={} migrated={} durationMs={}. Rotate credentials " + + "that may have appeared in historical snapshot URLs, cookies, sessions, or structured " + + "headers.", trigger, migrated, durationMillis); + } else if ("startup".equals(trigger)) { + log.info("Agent snapshot legacy redaction startup pass completed: migrated=0 durationMs={}; " + + "rolling-deployment compensation starts after {} ms and repeats every {} ms.", + durationMillis, ROLLING_DEPLOYMENT_INITIAL_DELAY_MILLIS, + ROLLING_DEPLOYMENT_FIXED_DELAY_MILLIS); + } + } catch (RuntimeException exception) { + if ("startup".equals(trigger)) { + log.error("Agent snapshot legacy redaction failed during startup; blocking application startup " + + "before it can accept traffic.", exception); + } else { + log.error("Agent snapshot legacy redaction failed during rolling-deployment compensation; the " + + "scheduler will retry on its next run.", exception); + } + throw exception; + } + } +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java index 3919b917..5b97122b 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImpl.java @@ -1,10 +1,16 @@ package xiaozhi.modules.agent.service.impl; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.Date; import java.util.HashMap; +import java.util.HexFormat; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.TreeMap; @@ -21,6 +27,8 @@ import com.baomidou.mybatisplus.core.metadata.IPage; import com.baomidou.mybatisplus.core.metadata.OrderItem; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import xiaozhi.common.constant.Constant; @@ -37,6 +45,7 @@ import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; import xiaozhi.modules.agent.dto.AgentSnapshotTagDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO; +import xiaozhi.modules.agent.dto.ContextProviderDTO; import xiaozhi.modules.agent.entity.AgentContextProviderEntity; import xiaozhi.modules.agent.entity.AgentEntity; import xiaozhi.modules.agent.entity.AgentPluginMapping; @@ -61,13 +70,20 @@ import xiaozhi.modules.security.user.SecurityUser; public class AgentSnapshotServiceImpl extends BaseServiceImpl implements AgentSnapshotService { private static final int MAX_SNAPSHOTS_PER_AGENT = 100; + private static final int LEGACY_REDACTION_BATCH_SIZE = 100; + private static final int CURRENT_REDACTION_VERSION = 2; private static final TypeReference> STRING_LIST_TYPE = new TypeReference<>() { }; private static final TypeReference> PARAM_INFO_TYPE = new TypeReference<>() { }; private static final TypeReference> OBJECT_MAP_TYPE = new TypeReference<>() { }; + private static final ObjectMapper SNAPSHOT_OBJECT_MAPPER = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); private static final String SECRET_PLACEHOLDER = "__SNAPSHOT_SECRET_REDACTED__"; + private static final String SOURCE_CONFIG = "config"; + private static final String SOURCE_CURRENT_BACKUP = "current"; + private static final String SOURCE_RESTORE_RESULT = "restore"; private final AgentSnapshotDao agentSnapshotDao; private final AgentDao agentDao; @@ -91,8 +107,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields = getChangedFields(currentData, restoreData); - if (changedFields.isEmpty()) { + validateSensitiveRestoreIsReversible(currentData, restoreData); + List requestedChangedFields = getChangedFields(currentData, restoreData); + if (requestedChangedFields.isEmpty()) { return; } + AgentSnapshotEntity latestSnapshot = agentSnapshotDao.selectLatestSnapshot(agentId); + AgentSnapshotDataDTO latestData = latestSnapshot == null + ? null + : parseSnapshotData(latestSnapshot.getSnapshotData()); + List backupChangedFields = getChangedFields(latestData, currentData); + boolean backupCreated = !backupChangedFields.isEmpty(); + if (backupCreated) { + insertSnapshot(agentId, currentAgent.getUserId(), SOURCE_CURRENT_BACKUP, currentData, + backupChangedFields, null, null, false); + } + applyAgentFields(agent, restoreData); validateRestoreParams(agent); applyMemoryPolicy(agent); agent.setUpdater(SecurityUser.getUserId()); agent.setUpdatedAt(new Date()); - agentDao.updateById(agent); + int updatedRows = agentDao.updateSnapshotFields(agent); + // The row was already locked and verified above. Some MySQL configurations report 0 changed rows + // when a relation-only restore leaves all scalar columns (including second-precision updated_at) unchanged. + if (updatedRows < 0 || updatedRows > 1) { + throw new RenException("智能体快照恢复失败"); + } restoreFunctions(agentId, restoreData.getFunctions()); restoreContextProviders(agentId, restoreData.getContextProviders()); correctWordFileService.saveAgentCorrectWords(agentId, nullToEmpty(restoreData.getCorrectWordFileIds())); restoreTags(agentId, restoreData); - insertSnapshot(agentId, currentAgent.getUserId(), "restore", restoreData, changedFields, - snapshot.getId(), snapshot.getVersionNo()); + + AgentSnapshotDataDTO restoredData = buildSnapshotData(agentId); + List restoredChangedFields = getChangedFields(currentData, restoredData); + if (!restoredChangedFields.isEmpty()) { + insertSnapshot(agentId, currentAgent.getUserId(), SOURCE_RESTORE_RESULT, restoredData, + restoredChangedFields, snapshot.getId(), snapshot.getVersionNo(), false); + } + if (backupCreated || !restoredChangedFields.isEmpty()) { + pruneSnapshots(agentId); + } } @Override @Transactional(rollbackFor = Exception.class) public void deleteSnapshot(String agentId, String snapshotId) { + lockAgent(agentId); AgentSnapshotEntity entity = getSnapshotEntity(agentId, snapshotId); Integer maxVersionNo = agentSnapshotDao.selectMaxVersionNo(agentId); if (Objects.equals(entity.getVersionNo(), maxVersionNo)) { @@ -184,6 +234,32 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl().eq("agent_id", agentId)); } + @Override + public long redactLegacySnapshots() { + String afterId = null; + long migrated = 0; + while (true) { + List batch = agentSnapshotDao.selectLegacyRedactionBatch(afterId, + LEGACY_REDACTION_BATCH_SIZE, CURRENT_REDACTION_VERSION); + if (batch == null || batch.isEmpty()) { + return migrated; + } + for (AgentSnapshotEntity snapshot : batch) { + Map rawData = JsonUtils.parseObject(snapshot.getSnapshotData(), OBJECT_MAP_TYPE); + if (rawData == null) { + throw new RenException("历史快照数据无法解析,已中止脱敏迁移: " + snapshot.getId()); + } + snapshot.setSnapshotData(JsonUtils.toJsonString(redactSensitiveMap(rawData))); + } + int affected = agentSnapshotDao.updateRedactedSnapshots(batch, CURRENT_REDACTION_VERSION); + if (affected < 0 || affected > batch.size()) { + throw new RenException("历史快照批量脱敏迁移失败"); + } + migrated += affected; + afterId = batch.get(batch.size() - 1).getId(); + } + } + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, List changedFields) { insertSnapshot(agentId, userId, source, snapshotData, changedFields, null, null); @@ -191,22 +267,32 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo) { + insertSnapshot(agentId, userId, source, snapshotData, changedFields, restoreFromSnapshotId, + restoreFromVersionNo, true); + } + + private void insertSnapshot(String agentId, Long userId, String source, AgentSnapshotDataDTO snapshotData, + List changedFields, String restoreFromSnapshotId, Integer restoreFromVersionNo, + boolean pruneAfterInsert) { AgentSnapshotEntity entity = new AgentSnapshotEntity(); entity.setId(UUID.randomUUID().toString().replace("-", "")); entity.setAgentId(agentId); entity.setUserId(userId); entity.setSnapshotData(JsonUtils.toJsonString(redactSnapshotData(snapshotData))); entity.setChangedFields(JsonUtils.toJsonString(changedFields)); - entity.setSource(StringUtils.defaultIfBlank(source, "config")); + entity.setSource(StringUtils.defaultIfBlank(source, SOURCE_CONFIG)); entity.setRestoreFromSnapshotId(restoreFromSnapshotId); entity.setRestoreFromVersionNo(restoreFromVersionNo); entity.setCreator(SecurityUser.getUserId()); entity.setCreatedAt(new Date()); + entity.setRedactionVersion(CURRENT_REDACTION_VERSION); int inserted = agentSnapshotDao.insertWithNextVersion(entity); if (inserted != 1) { throw new RenException("快照版本号生成失败"); } - pruneSnapshots(agentId); + if (pruneAfterInsert) { + pruneSnapshots(agentId); + } } private void lockAgent(String agentId) { @@ -347,6 +433,19 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalized = new TreeMap<>(); + for (AgentSnapshotField field : AgentSnapshotField.values()) { + normalized.put(field.getFieldName(), normalizeForCompare(field, field.snapshotValue(data))); + } + byte[] payload = JsonUtils.toJsonString(normalized).getBytes(StandardCharsets.UTF_8); + try { + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload)); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 is unavailable", exception); + } + } + private boolean isChanged(AgentSnapshotField field, Object current, Object next) { return !Objects.equals(normalizeForCompare(field, current), normalizeForCompare(field, next)); } @@ -355,13 +454,22 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalizeFunctions((List) value); - case CONTEXT_PROVIDERS -> normalizeSortedJsonList((List) value); + case CONTEXT_PROVIDERS -> normalizeJsonList((List) value); case CORRECT_WORD_FILE_IDS -> normalizeStringList((List) value); case TAG_NAMES -> normalizeStringList((List) value); + case SUMMARY_MEMORY -> normalizeBlankText(value); default -> value; }; } + private String normalizeBlankText(Object value) { + if (value == null) { + return ""; + } + String text = String.valueOf(value); + return StringUtils.isBlank(text) ? "" : text; + } + private Map normalizeFunctions(List functions) { Map result = new TreeMap<>(); if (functions == null) { @@ -386,31 +494,37 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl List normalizeSortedJsonList(List values) { + private List normalizeJsonList(List values) { if (values == null) { return Collections.emptyList(); } return values.stream() .filter(Objects::nonNull) .map(value -> JsonUtils.toJsonString(normalizeValue(value))) - .sorted() .toList(); } private Object normalizeValue(Object value) { + return normalizeValue(value, null); + } + + private Object normalizeValue(Object value, String parentKey) { if (value == null) { return null; } + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return normalizeUrlForCompare(text.toString(), parentKey); + } if (value instanceof Map map) { - return normalizeMap(map); + return normalizeMap(map, parentKey); } if (value instanceof List list) { - return list.stream().map(this::normalizeValue).toList(); + return list.stream().map(item -> normalizeValue(item, parentKey)).toList(); } if (isScalarValue(value)) { return value; } - return normalizeMap(JsonUtils.parseObject(JsonUtils.toJsonString(value), OBJECT_MAP_TYPE)); + return normalizeMap(JsonUtils.parseObject(JsonUtils.toJsonString(value), OBJECT_MAP_TYPE), parentKey); } private boolean isScalarValue(Object value) { @@ -423,14 +537,27 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl normalizeMap(Map map) { + return normalizeMap(map, null); + } + + private Map normalizeMap(Map map, String parentSemanticKey) { Map result = new TreeMap<>(); if (map == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(map); map.forEach((key, value) -> { if (key != null) { String keyText = String.valueOf(key); - result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : normalizeValue(value)); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, SECRET_PLACEHOLDER); + } else if (value instanceof CharSequence text && shouldTreatAsUrl(semanticKey, text.toString())) { + result.put(keyText, normalizeUrlForCompare(text.toString(), semanticKey)); + } else { + result.put(keyText, normalizeValue(value, semanticKey)); + } } }); return result; @@ -538,17 +665,19 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl() .eq("tag_name", snapshotTag.getTagName()) - .eq("deleted", 0) .last("LIMIT 1")); } if (tag != null) { + if (Objects.equals(tag.getDeleted(), 1)) { + // Tags are global records shared by every agent that references them. Restoring a + // snapshot must not revive a globally deleted tag as a side effect for other agents + // (or tenants); require the user to recreate/select an active tag explicitly. + throw new RenException("快照引用的标签已被删除,无法恢复,请先重新创建或选择标签"); + } return tag.getId(); } @@ -579,8 +708,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl { if (function != null) { @@ -616,6 +762,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl { if (provider != null) { + provider.setUrl(redactSensitiveUrl(provider.getUrl(), "url")); provider.setHeaders(redactSensitiveMap(provider.getHeaders())); } }); @@ -627,7 +774,7 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl currentFunctions = nullToEmpty(current == null ? null : current.getFunctions()) .stream() .filter(function -> function != null && StringUtils.isNotBlank(function.getPluginId())) @@ -644,85 +791,187 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl currentProviders = nullToEmpty( - current == null ? null : current.getContextProviders()) - .stream() - .filter(provider -> provider != null && StringUtils.isNotBlank(provider.getUrl())) - .collect(Collectors.toMap(xiaozhi.modules.agent.dto.ContextProviderDTO::getUrl, Function.identity(), - (left, right) -> left)); - if (copy.getContextProviders() != null) { - copy.getContextProviders().forEach(provider -> { - if (provider == null) { - return; - } - xiaozhi.modules.agent.dto.ContextProviderDTO currentProvider = currentProviders.get(provider.getUrl()); - provider.setHeaders(preserveCurrentSensitiveMap(provider.getHeaders(), - currentProvider == null ? null : currentProvider.getHeaders())); - }); - } + copy.setContextProviders(preserveCurrentContextProviders(copy.getContextProviders(), + current == null ? null : current.getContextProviders())); return copy; } + private void validateSensitiveRestoreIsReversible(AgentSnapshotDataDTO current, + AgentSnapshotDataDTO restored) { + try { + // Simulate restoring the automatic pre-restore backup from the proposed + // result. If a current secret-bearing slot disappears, the reverse + // preservation fails because snapshots intentionally never store secrets. + preserveCurrentSensitiveValues(current, restored); + } catch (RenException exception) { + throw new RenException("目标版本会移除无法写入历史的敏感配置,请先手动处理相关密钥后再恢复"); + } + } + private HashMap redactSensitiveMap(Map map) { + return redactSensitiveMap(map, null); + } + + private HashMap redactSensitiveMap(Map map, String parentSemanticKey) { HashMap result = new HashMap<>(); if (map == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(map); map.forEach((key, value) -> { if (key != null) { String keyText = String.valueOf(key); - result.put(keyText, isSensitiveKey(keyText) ? SECRET_PLACEHOLDER : redactSensitiveValue(value)); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, SECRET_PLACEHOLDER); + } else if (value instanceof CharSequence text && shouldTreatAsUrl(semanticKey, text.toString())) { + result.put(keyText, redactSensitiveUrl(text.toString(), semanticKey)); + } else { + result.put(keyText, redactSensitiveValue(value, semanticKey)); + } } }); return result; } private Object redactSensitiveValue(Object value) { + return redactSensitiveValue(value, null); + } + + private Object redactSensitiveValue(Object value, String parentKey) { + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return redactSensitiveUrl(text.toString(), parentKey); + } if (value instanceof Map map) { - return redactSensitiveMap(map); + return redactSensitiveMap(map, parentKey); } if (value instanceof List list) { - return list.stream().map(this::redactSensitiveValue).toList(); + return list.stream().map(item -> redactSensitiveValue(item, parentKey)).toList(); } return value; } private HashMap preserveCurrentSensitiveMap(Map target, Map current) { + return preserveCurrentSensitiveMap(target, current, null); + } + + private HashMap preserveCurrentSensitiveMap(Map target, Map current, + String parentSemanticKey) { HashMap result = new HashMap<>(); if (target == null) { return result; } + String structuredSensitiveKey = getStructuredSensitiveKey(target); + if (structuredSensitiveKey != null) { + validateStructuredSensitiveDiscriminator(target, current); + } target.forEach((key, value) -> { if (key == null) { return; } String keyText = String.valueOf(key); + String semanticKey = resolveUrlSemanticKey(parentSemanticKey, keyText); Object currentValue = getMapValue(current, keyText); - if (isSensitiveKey(keyText)) { - result.put(keyText, currentValue == null || SECRET_PLACEHOLDER.equals(currentValue) ? "" : currentValue); + if (isSensitiveKey(keyText) + || (structuredSensitiveKey != null && "value".equalsIgnoreCase(keyText))) { + result.put(keyText, preserveSensitiveScalar(value, currentValue)); + } else if (value instanceof CharSequence targetUrl + && shouldTreatAsUrl(semanticKey, targetUrl.toString())) { + result.put(keyText, preserveCurrentSensitiveUrl(targetUrl.toString(), + currentValue instanceof CharSequence currentUrl ? currentUrl.toString() : null, semanticKey)); } else { - result.put(keyText, preserveCurrentSensitiveValue(value, currentValue)); + result.put(keyText, preserveCurrentSensitiveValue(value, currentValue, semanticKey)); } }); return result; } private Object preserveCurrentSensitiveValue(Object target, Object current) { + return preserveCurrentSensitiveValue(target, current, null); + } + + private Object preserveCurrentSensitiveValue(Object target, Object current, String parentKey) { + if (target instanceof CharSequence targetText && shouldTreatAsUrl(parentKey, targetText.toString())) { + return preserveCurrentSensitiveUrl(targetText.toString(), + current instanceof CharSequence currentText ? currentText.toString() : null, parentKey); + } if (target instanceof Map targetMap) { - return preserveCurrentSensitiveMap(targetMap, current instanceof Map currentMap ? currentMap : null); + return preserveCurrentSensitiveMap(targetMap, + current instanceof Map currentMap ? currentMap : null, parentKey); } if (target instanceof List targetList) { List currentList = current instanceof List list ? list : Collections.emptyList(); - List result = new ArrayList<>(); - for (int i = 0; i < targetList.size(); i++) { - Object currentItem = i < currentList.size() ? currentList.get(i) : null; - result.add(preserveCurrentSensitiveValue(targetList.get(i), currentItem)); - } - return result; + return preserveCurrentSensitiveList(targetList, currentList, parentKey); } return target; } + private List preserveCurrentSensitiveList(List target, List current, String parentKey) { + Map> targetByIdentity = indexListByStableIdentity(target, parentKey); + Map> currentByIdentity = indexListByStableIdentity(current, parentKey); + List result = new ArrayList<>(); + for (Object targetItem : target) { + List matchingCurrentItems = stableListIdentities(targetItem, parentKey).stream() + .filter(candidate -> targetByIdentity.getOrDefault(candidate, Collections.emptyList()).size() == 1) + .filter(candidate -> currentByIdentity.getOrDefault(candidate, Collections.emptyList()).size() == 1) + .map(candidate -> currentByIdentity.get(candidate).get(0)) + .distinct() + .toList(); + Object currentItem = matchingCurrentItems.size() == 1 ? matchingCurrentItems.get(0) : null; + if (currentItem == null && containsSensitiveMaterial(targetItem)) { + throw new RenException("快照中的敏感列表项缺少唯一稳定标识,无法安全恢复"); + } + result.add(preserveCurrentSensitiveValue(targetItem, currentItem, parentKey)); + } + return result; + } + + private Map> indexListByStableIdentity(List values, String parentKey) { + Map> result = new HashMap<>(); + for (Object value : values) { + for (String identity : stableListIdentities(value, parentKey)) { + result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(value); + } + } + return result; + } + + private List stableListIdentities(Object value, String parentKey) { + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + String identity = normalizeUrlIdentity(text.toString(), parentKey); + return StringUtils.isBlank(identity) ? Collections.emptyList() : List.of("url:" + identity); + } + if (!(value instanceof Map map)) { + return Collections.emptyList(); + } + List identities = new ArrayList<>(); + for (String field : List.of("id", "name", "key", "url")) { + Object identityValue = getMapValue(map, field); + if (identityValue == null || StringUtils.isBlank(String.valueOf(identityValue))) { + continue; + } + String text = String.valueOf(identityValue).trim(); + if ("url".equals(field)) { + text = normalizeUrlIdentity(text, resolveUrlSemanticKey(parentKey, field)); + } else if ("name".equals(field) || "key".equals(field)) { + text = text.toLowerCase(Locale.ROOT); + } + identities.add(field + ":" + text); + } + if (!map.isEmpty()) { + identities.add("fingerprint:" + JsonUtils.toJsonString(normalizeMap(map, parentKey))); + } + return identities; + } + + private Object preserveSensitiveScalar(Object target, Object current) { + if (current != null && !SECRET_PLACEHOLDER.equals(current)) { + return current; + } + throw new RenException("快照敏感值无法与当前配置可靠匹配"); + } + private Object getMapValue(Map map, String key) { if (map == null) { return null; @@ -731,21 +980,784 @@ public class AgentSnapshotServiceImpl extends BaseServiceImpl entry : map.entrySet()) { - if (entry.getKey() != null && Objects.equals(key, String.valueOf(entry.getKey()))) { + if (entry.getKey() != null && key.equalsIgnoreCase(String.valueOf(entry.getKey()))) { return entry.getValue(); } } return null; } + private List preserveCurrentContextProviders(List target, + List current) { + if (target == null) { + return null; + } + List currentProviders = nullToEmpty(current); + Map> targetByIdentity = indexProvidersByIdentity(target); + Map> currentByIdentity = indexProvidersByIdentity(currentProviders); + + for (ContextProviderDTO targetProvider : target) { + if (targetProvider == null) { + continue; + } + String identity = normalizeUrlIdentity(targetProvider.getUrl()); + ContextProviderDTO currentProvider = null; + if (StringUtils.isNotBlank(identity) + && targetByIdentity.getOrDefault(identity, Collections.emptyList()).size() == 1 + && currentByIdentity.getOrDefault(identity, Collections.emptyList()).size() == 1) { + currentProvider = currentByIdentity.get(identity).get(0); + } else if (hasSensitiveUrlParts(targetProvider.getUrl()) + || containsSensitiveMaterial(targetProvider.getHeaders())) { + throw new RenException("上下文源敏感配置缺少唯一 URL 标识,无法安全恢复"); + } + + if (targetProvider.getUrl() != null) { + targetProvider.setUrl(preserveCurrentSensitiveUrl(targetProvider.getUrl(), + currentProvider == null ? null : currentProvider.getUrl(), "url")); + } + targetProvider.setHeaders(preserveCurrentSensitiveMap(targetProvider.getHeaders(), + currentProvider == null ? null : currentProvider.getHeaders())); + } + return target; + } + + private Map> indexProvidersByIdentity(List providers) { + Map> result = new HashMap<>(); + for (ContextProviderDTO provider : providers) { + if (provider == null) { + continue; + } + String identity = normalizeUrlIdentity(provider.getUrl()); + if (StringUtils.isNotBlank(identity)) { + result.computeIfAbsent(identity, ignored -> new ArrayList<>()).add(provider); + } + } + return result; + } + + private String getStructuredSensitiveKey(Map map) { + if (map == null) { + return null; + } + for (String discriminator : List.of("key", "name")) { + Object value = getMapValue(map, discriminator); + if (value != null && isSensitiveKey(String.valueOf(value))) { + return String.valueOf(value); + } + } + return null; + } + + private void validateStructuredSensitiveDiscriminator(Map target, Map current) { + List targetIdentities = structuredSensitiveDiscriminatorIdentities(target); + List currentIdentities = structuredSensitiveDiscriminatorIdentities(current); + if (targetIdentities.size() != 1 || currentIdentities.size() != 1 + || !Objects.equals(targetIdentities.get(0), currentIdentities.get(0))) { + throw new RenException("快照结构化敏感值的类型标识无法与当前配置唯一匹配"); + } + } + + private List structuredSensitiveDiscriminatorIdentities(Map map) { + if (map == null) { + return Collections.emptyList(); + } + List identities = new ArrayList<>(); + for (String discriminator : List.of("key", "name")) { + Object value = getMapValue(map, discriminator); + if (value == null || !isSensitiveKey(String.valueOf(value))) { + continue; + } + String identity = String.valueOf(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (!identities.contains(identity)) { + identities.add(identity); + } + } + return identities; + } + + private boolean containsSensitiveMaterial(Object value) { + return containsSensitiveMaterial(value, null); + } + + private boolean containsSensitiveMaterial(Object value, String parentKey) { + if (SECRET_PLACEHOLDER.equals(value)) { + return true; + } + if (value instanceof Map map) { + if (getStructuredSensitiveKey(map) != null) { + return true; + } + for (Map.Entry entry : map.entrySet()) { + if (entry.getKey() == null) { + continue; + } + String key = String.valueOf(entry.getKey()); + String semanticKey = resolveUrlSemanticKey(parentKey, key); + if (isSensitiveKey(key) + || (entry.getValue() instanceof CharSequence text + && shouldTreatAsUrl(semanticKey, text.toString()) + && hasSensitiveUrlParts(text.toString(), semanticKey)) + || containsSensitiveMaterial(entry.getValue(), semanticKey)) { + return true; + } + } + return false; + } + if (value instanceof List list) { + return list.stream().anyMatch(item -> containsSensitiveMaterial(item, parentKey)); + } + if (value instanceof CharSequence text && shouldTreatAsUrl(parentKey, text.toString())) { + return hasSensitiveUrlParts(text.toString(), parentKey); + } + return false; + } + + private boolean isUrlKey(String key) { + String normalized = StringUtils.defaultString(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return normalized.endsWith("url") + || normalized.endsWith("uri") + || normalized.endsWith("endpoint") + || normalized.endsWith("webhook"); + } + + private boolean shouldTreatAsUrl(String key, String value) { + return isUrlKey(key) || isWebhookSemanticKey(key) || looksLikeUrl(value); + } + + private String resolveUrlSemanticKey(String parentSemanticKey, String childKey) { + if (isWebhookSemanticKey(childKey)) { + return childKey; + } + if (isWebhookSemanticKey(parentSemanticKey)) { + // Keep the concrete child name for URL detection while carrying the + // enclosing webhook capability semantics through arbitrary wrapper maps. + return StringUtils.isBlank(childKey) + ? parentSemanticKey + : parentSemanticKey + "." + childKey; + } + return childKey; + } + + private boolean looksLikeUrl(String value) { + String text = StringUtils.defaultString(value); + if (text.startsWith("//")) { + int pathStart = text.indexOf('/', 2); + String authority = pathStart < 0 ? text.substring(2) : text.substring(2, pathStart); + return StringUtils.isNotBlank(authority) && authority.chars().noneMatch(Character::isWhitespace); + } + int schemeEnd = text.indexOf("://"); + if (schemeEnd <= 0 || !Character.isLetter(text.charAt(0))) { + return false; + } + for (int i = 1; i < schemeEnd; i++) { + char character = text.charAt(i); + if (!Character.isLetterOrDigit(character) + && character != '+' + && character != '-' + && character != '.') { + return false; + } + } + return true; + } + + private String normalizeUrlForCompare(String value) { + return normalizeUrlForCompare(value, null); + } + + private String normalizeUrlForCompare(String value, String semanticKey) { + return redactSensitiveUrl(value, semanticKey); + } + + private String redactSensitiveUrl(String value) { + return redactSensitiveUrl(value, null); + } + + private String redactSensitiveUrl(String value, String semanticKey) { + if (value == null) { + return null; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return buildSensitiveUrl(analyzeSensitiveUrlPath(parts.base(), semanticKey).redactedBase(), + parts.userInfo() == null ? null : SECRET_PLACEHOLDER, + redactSensitiveUrlParameters(parts.query()), + redactSensitiveUrlFragment(parts.fragment())); + } + + private String preserveCurrentSensitiveUrl(String target, String current) { + return preserveCurrentSensitiveUrl(target, current, null); + } + + private String preserveCurrentSensitiveUrl(String target, String current, String semanticKey) { + if (target == null) { + return null; + } + SensitiveUrlParts targetParts = splitSensitiveUrl(target); + SensitiveUrlParts currentParts = splitSensitiveUrl(StringUtils.defaultString(current)); + SensitivePathAnalysis targetPath = analyzeSensitiveUrlPath(targetParts.base(), semanticKey); + boolean forceCurrentGenericMarker = targetPath.slots().stream() + .anyMatch(slot -> slot.identity().startsWith("webhook-suffix:")); + SensitivePathAnalysis currentPath = analyzeSensitiveUrlPath(currentParts.base(), semanticKey, + forceCurrentGenericMarker); + boolean copiesSensitiveComponent = targetParts.userInfo() != null + || !targetPath.slots().isEmpty() + || containsSensitiveUrlParameter(targetParts.query()) + || (targetParts.fragment() != null + && (!isStructuredUrlComponent(targetParts.fragment()) + || containsSensitiveUrlParameter(targetParts.fragment()))); + if (copiesSensitiveComponent && !Objects.equals(targetPath.redactedBase(), currentPath.redactedBase())) { + throw new RenException("快照 URL 敏感信息无法与当前配置的公开地址标识匹配"); + } + String result = buildSensitiveUrl(preserveSensitiveUrlPath(targetParts.base(), currentParts.base(), semanticKey), + preserveUrlComponent(targetParts.userInfo(), currentParts.userInfo()), + preserveSensitiveUrlParameters(targetParts.query(), currentParts.query()), + preserveSensitiveUrlFragment(targetParts.fragment(), currentParts.fragment())); + if (result.contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中仍包含脱敏占位符,无法安全恢复"); + } + return result; + } + + private String preserveUrlComponent(String target, String current) { + if (target == null) { + return null; + } + if (current == null || current.contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中的敏感信息无法与当前配置可靠匹配"); + } + return current; + } + + private String redactSensitiveUrlParameters(String value) { + if (value == null) { + return null; + } + return buildUrlParameters(parseUrlParameters(value).stream() + .map(parameter -> isSensitiveUrlParameter(parameter) + ? new UrlParameter(parameter.rawKey(), SECRET_PLACEHOLDER, parameter.hasEquals()) + : parameter) + .toList()); + } + + private String redactSensitiveUrlFragment(String value) { + if (value == null) { + return null; + } + return isStructuredUrlComponent(value) + ? redactSensitiveUrlParameters(value) + : SECRET_PLACEHOLDER; + } + + private String preserveSensitiveUrlParameters(String target, String current) { + if (target == null) { + return null; + } + List targetParameters = parseUrlParameters(target); + List currentParameters = parseUrlParameters(StringUtils.defaultString(current)); + Map> targetSensitiveParameters = indexSensitiveUrlParameters(targetParameters); + Map> currentSensitiveParameters = indexSensitiveUrlParameters(currentParameters); + List result = new ArrayList<>(); + + for (UrlParameter targetParameter : targetParameters) { + if (!isSensitiveUrlParameter(targetParameter)) { + result.add(targetParameter); + continue; + } + String identity = normalizeUrlParameterName(targetParameter.rawKey()); + List matchingTargets = targetSensitiveParameters.getOrDefault(identity, + Collections.emptyList()); + List matchingCurrent = currentSensitiveParameters.getOrDefault(identity, + Collections.emptyList()); + if (matchingTargets.size() != 1 || matchingCurrent.size() != 1) { + throw new RenException("快照 URL 中的敏感参数无法与当前配置唯一匹配"); + } + UrlParameter currentParameter = matchingCurrent.get(0); + if (currentParameter.rawValue().contains(SECRET_PLACEHOLDER)) { + throw new RenException("快照 URL 中的敏感信息无法与当前配置可靠匹配"); + } + result.add(new UrlParameter(targetParameter.rawKey(), currentParameter.rawValue(), + currentParameter.hasEquals())); + } + return buildUrlParameters(result); + } + + private String preserveSensitiveUrlFragment(String target, String current) { + if (target == null) { + return null; + } + return isStructuredUrlComponent(target) + ? preserveSensitiveUrlParameters(target, current) + : preserveUrlComponent(target, current); + } + + private Map> indexSensitiveUrlParameters(List parameters) { + Map> result = new HashMap<>(); + for (UrlParameter parameter : parameters) { + if (isSensitiveUrlParameter(parameter)) { + result.computeIfAbsent(normalizeUrlParameterName(parameter.rawKey()), ignored -> new ArrayList<>()) + .add(parameter); + } + } + return result; + } + + private List parseUrlParameters(String value) { + List result = new ArrayList<>(); + for (String part : StringUtils.defaultString(value).split("&", -1)) { + int equalsIndex = part.indexOf('='); + if (equalsIndex < 0) { + result.add(new UrlParameter(part, "", false)); + } else { + result.add(new UrlParameter(part.substring(0, equalsIndex), part.substring(equalsIndex + 1), true)); + } + } + return result; + } + + private String buildUrlParameters(List parameters) { + return parameters.stream() + .map(parameter -> parameter.rawKey() + + (parameter.hasEquals() ? "=" + parameter.rawValue() : "")) + .collect(Collectors.joining("&")); + } + + private boolean isSensitiveUrlParameter(UrlParameter parameter) { + String decodedName = decodeUrlParameterName(parameter.rawKey()); + String normalized = decodedName.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return isSensitiveKey(decodedName) + || normalized.equals("key") + || normalized.equals("sig") + || normalized.equals("signature") + || normalized.equals("xamzsignature") + || normalized.equals("xgoogsignature") + || normalized.equals("sas"); + } + + private String normalizeUrlParameterName(String value) { + return decodeUrlParameterName(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + } + + private String decodeUrlParameterName(String value) { + try { + return URLDecoder.decode(StringUtils.defaultString(value), StandardCharsets.UTF_8); + } catch (IllegalArgumentException ignored) { + return StringUtils.defaultString(value); + } + } + + private boolean isStructuredUrlComponent(String value) { + return value != null && (value.contains("=") || value.contains("&")); + } + + private boolean hasSensitiveUrlParts(String value) { + return hasSensitiveUrlParts(value, null); + } + + private boolean hasSensitiveUrlParts(String value, String semanticKey) { + if (StringUtils.isBlank(value)) { + return false; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return parts.userInfo() != null + || !analyzeSensitiveUrlPath(parts.base(), semanticKey).slots().isEmpty() + || containsSensitiveUrlParameter(parts.query()) + || (parts.fragment() != null + && (!isStructuredUrlComponent(parts.fragment()) + || containsSensitiveUrlParameter(parts.fragment()))); + } + + private boolean containsSensitiveUrlParameter(String value) { + return value != null && parseUrlParameters(value).stream().anyMatch(this::isSensitiveUrlParameter); + } + + private String normalizeUrlIdentity(String value) { + return normalizeUrlIdentity(value, null); + } + + private String normalizeUrlIdentity(String value, String semanticKey) { + if (StringUtils.isBlank(value)) { + return ""; + } + SensitiveUrlParts parts = splitSensitiveUrl(value); + return analyzeSensitiveUrlPath(parts.base(), semanticKey).redactedBase(); + } + + private String preserveSensitiveUrlPath(String targetBase, String currentBase, String semanticKey) { + SensitivePathAnalysis target = analyzeSensitiveUrlPath(targetBase, semanticKey); + if (target.slots().isEmpty()) { + return targetBase; + } + boolean forceCurrentGenericMarker = target.slots().stream() + .anyMatch(slot -> slot.identity().startsWith("webhook-suffix:")); + SensitivePathAnalysis current = analyzeSensitiveUrlPath(currentBase, semanticKey, forceCurrentGenericMarker); + if (!Objects.equals(target.redactedBase(), current.redactedBase()) + || target.slots().size() != current.slots().size()) { + throw new RenException("快照 URL 路径敏感信息无法与当前配置的公开标识唯一匹配"); + } + + Map> currentSlots = current.slots().stream() + .collect(Collectors.groupingBy(SensitivePathSlot::identity)); + Map replacements = new HashMap<>(); + for (SensitivePathSlot targetSlot : target.slots()) { + List matches = currentSlots.getOrDefault(targetSlot.identity(), Collections.emptyList()); + if (matches.size() != 1 || matches.get(0).secretSegments().isEmpty() + || matches.get(0).secretSegments().stream() + .anyMatch(secret -> StringUtils.isBlank(secret) || secret.contains(SECRET_PLACEHOLDER))) { + throw new RenException("快照 URL 路径敏感信息无法与当前配置可靠匹配"); + } + if (replacements.put(targetSlot.identity(), matches.get(0)) != null) { + throw new RenException("快照 URL 路径敏感信息存在歧义,无法安全恢复"); + } + } + return rebuildSensitivePath(target, replacements); + } + + private SensitivePathAnalysis analyzeSensitiveUrlPath(String base, String semanticKey) { + return analyzeSensitiveUrlPath(base, semanticKey, false); + } + + private SensitivePathAnalysis analyzeSensitiveUrlPath(String base, String semanticKey, + boolean forceGenericMarker) { + UrlPathParts pathParts = splitUrlPath(base); + if (pathParts == null || pathParts.segments().isEmpty()) { + return new SensitivePathAnalysis(base, base, Collections.emptyList()); + } + List segments = pathParts.segments(); + List slots = new ArrayList<>(); + List claimedMarkers = new ArrayList<>(); + + if (isSlackHooksHost(pathParts.host())) { + for (int i = 0; i + 3 < segments.size(); i++) { + if ("services".equalsIgnoreCase(segments.get(i)) + && allPathSegmentsPresent(segments, i + 1, i + 4)) { + addSensitivePathSlot(slots, new SensitivePathSlot("slack:" + i, i + 3, i + 4, "", + List.of(segments.get(i + 3)))); + } + } + } + + if (isDiscordHost(pathParts.host())) { + for (int i = 0; i + 2 < segments.size(); i++) { + if ("webhooks".equalsIgnoreCase(segments.get(i)) + && isDiscordWebhookPrefix(segments, i) + && allPathSegmentsPresent(segments, i + 1, i + 3)) { + addSensitivePathSlot(slots, new SensitivePathSlot("discord:" + i, i + 2, i + 3, "", + List.of(segments.get(i + 2)))); + claimedMarkers.add(i); + } + } + } + + if (isTelegramHost(pathParts.host())) { + for (int i = 0; i < segments.size(); i++) { + String segment = segments.get(i); + int colon = segment.indexOf(':'); + if (segment.regionMatches(true, 0, "bot", 0, 3) + && colon > 3 && colon < segment.length() - 1) { + String publicPrefix = segment.substring(0, colon + 1); + addSensitivePathSlot(slots, new SensitivePathSlot("telegram:" + i + ":" + publicPrefix, + i, i + 1, publicPrefix, List.of(segment.substring(colon + 1)))); + } + } + } + + for (int i = 0; i < segments.size(); i++) { + if (!isWebhookPathMarker(segments.get(i)) || claimedMarkers.contains(i) || i + 1 >= segments.size()) { + continue; + } + int suffixEnd = segments.size(); + while (suffixEnd > i + 1 && StringUtils.isBlank(segments.get(suffixEnd - 1))) { + suffixEnd--; + } + List capabilitySuffix = new ArrayList<>(segments.subList(i + 1, suffixEnd)); + if (capabilitySuffix.isEmpty()) { + continue; + } + if (capabilitySuffix.stream().anyMatch(StringUtils::isBlank)) { + continue; + } + if (!forceGenericMarker + && !isHighConfidenceWebhookCapability(pathParts, semanticKey, capabilitySuffix)) { + continue; + } + addSensitivePathSlot(slots, new SensitivePathSlot("webhook-suffix:" + i, i + 1, suffixEnd, "", + capabilitySuffix)); + break; + } + + if (slots.isEmpty() && isWebhookSemanticKey(semanticKey)) { + int lastSegment = segments.size() - 1; + while (lastSegment >= 0 && StringUtils.isBlank(segments.get(lastSegment))) { + lastSegment--; + } + if (lastSegment >= 0 && StringUtils.isNotBlank(segments.get(lastSegment))) { + addSensitivePathSlot(slots, new SensitivePathSlot("webhook-key:" + lastSegment, + lastSegment, lastSegment + 1, "", List.of(segments.get(lastSegment)))); + } + } + + if (slots.isEmpty()) { + return new SensitivePathAnalysis(base, base, Collections.emptyList()); + } + SensitivePathAnalysis analysis = new SensitivePathAnalysis(base, null, slots); + return new SensitivePathAnalysis(base, rebuildSensitivePath(analysis, Collections.emptyMap()), slots); + } + + private String rebuildSensitivePath(SensitivePathAnalysis analysis, + Map replacements) { + UrlPathParts pathParts = splitUrlPath(analysis.originalBase()); + if (pathParts == null) { + return analysis.originalBase(); + } + Map slotsByStart = analysis.slots().stream() + .collect(Collectors.toMap(SensitivePathSlot::startIndex, Function.identity(), (left, right) -> left)); + List result = new ArrayList<>(); + for (int i = 0; i < pathParts.segments().size();) { + SensitivePathSlot slot = slotsByStart.get(i); + if (slot == null) { + result.add(pathParts.segments().get(i)); + i++; + continue; + } + SensitivePathSlot replacement = replacements.get(slot.identity()); + if (replacement == null) { + result.add(slot.publicPrefix() + SECRET_PLACEHOLDER); + } else if (StringUtils.isNotEmpty(slot.publicPrefix())) { + result.add(slot.publicPrefix() + replacement.secretSegments().get(0)); + } else { + result.addAll(replacement.secretSegments()); + } + i = slot.endIndex(); + } + return pathParts.prefix() + String.join("/", result); + } + + private UrlPathParts splitUrlPath(String base) { + String value = StringUtils.defaultString(base); + int schemeIndex = value.indexOf("://"); + if (schemeIndex < 0 && !value.startsWith("/")) { + if (StringUtils.isBlank(value) || value.chars().anyMatch(Character::isWhitespace)) { + return null; + } + return new UrlPathParts("", + new ArrayList<>(List.of(value.split("/", -1))), ""); + } + if (schemeIndex < 0 && !value.startsWith("//")) { + return new UrlPathParts("", + new ArrayList<>(List.of(value.split("/", -1))), ""); + } + int authorityStart = schemeIndex < 0 ? 2 : schemeIndex + 3; + int pathStart = value.indexOf('/', authorityStart); + String authority = pathStart < 0 ? value.substring(authorityStart) : value.substring(authorityStart, pathStart); + String host = normalizeAuthorityHost(authority); + if (pathStart < 0) { + return new UrlPathParts(value, Collections.emptyList(), host); + } + return new UrlPathParts(value.substring(0, pathStart), + new ArrayList<>(List.of(value.substring(pathStart).split("/", -1))), host); + } + + private String normalizeAuthorityHost(String authority) { + String value = StringUtils.defaultString(authority).toLowerCase(Locale.ROOT); + if (value.startsWith("[")) { + int closingBracket = value.indexOf(']'); + return closingBracket < 0 ? value : value.substring(0, closingBracket + 1); + } + int portSeparator = value.lastIndexOf(':'); + return portSeparator < 0 ? value : value.substring(0, portSeparator); + } + + private boolean isSlackHooksHost(String host) { + return "hooks.slack.com".equals(host) || "hooks.slack-gov.com".equals(host); + } + + private boolean isDiscordHost(String host) { + return "discord.com".equals(host) || host.endsWith(".discord.com") + || "discordapp.com".equals(host) || host.endsWith(".discordapp.com"); + } + + private boolean isTelegramHost(String host) { + return "api.telegram.org".equals(host); + } + + private boolean isDiscordWebhookPrefix(List segments, int markerIndex) { + if (markerIndex >= 1 && "api".equalsIgnoreCase(segments.get(markerIndex - 1))) { + return true; + } + return markerIndex >= 2 + && "api".equalsIgnoreCase(segments.get(markerIndex - 2)) + && isVersionPathSegment(segments.get(markerIndex - 1)); + } + + private boolean isVersionPathSegment(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT); + return normalized.length() > 1 && normalized.charAt(0) == 'v' + && normalized.substring(1).chars().allMatch(Character::isDigit); + } + + private boolean isWebhookPathMarker(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT); + return normalized.equals("webhook") || normalized.equals("webhooks") + || normalized.equals("hook") || normalized.equals("hooks"); + } + + private boolean isHighConfidenceWebhookCapability(UrlPathParts pathParts, String semanticKey, + List capabilitySuffix) { + return isWebhookSemanticKey(semanticKey) + || hasWebhookHostLabel(pathParts.host()) + || isSlackHooksHost(pathParts.host()) + || isDiscordHost(pathParts.host()) + || isTelegramHost(pathParts.host()) + || capabilitySuffix.stream().anyMatch(this::looksLikeCapabilitySecret); + } + + private boolean hasWebhookHostLabel(String host) { + return List.of(StringUtils.defaultString(host).split("\\.")).stream() + .anyMatch(this::isWebhookPathMarker); + } + + private boolean looksLikeCapabilitySecret(String value) { + String text = StringUtils.defaultString(value); + String normalized = text.toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + if (normalized.contains("secret") + || normalized.contains("token") + || normalized.contains("capability") + || normalized.contains("credential") + || normalized.contains("signature") + || normalized.contains("apikey") + || normalized.contains("accesskey") + || normalized.contains("authkey")) { + return true; + } + if (text.length() < 20) { + return false; + } + boolean hasLetter = text.chars().anyMatch(Character::isLetter); + boolean hasDigit = text.chars().anyMatch(Character::isDigit); + boolean hasSymbol = text.chars().anyMatch(character -> !Character.isLetterOrDigit(character)); + long distinctCharacters = text.chars().distinct().count(); + return distinctCharacters >= 10 + && ((hasLetter && hasDigit) || (hasLetter && hasSymbol) || (hasDigit && hasSymbol)); + } + + private boolean isWebhookSemanticKey(String value) { + String normalized = StringUtils.defaultString(value).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); + return normalized.contains("webhook") || normalized.equals("hook") || normalized.equals("hooks") + || normalized.endsWith("hook") || normalized.endsWith("hooks"); + } + + private boolean allPathSegmentsPresent(List segments, int fromInclusive, int toExclusive) { + return segments.subList(fromInclusive, toExclusive).stream().noneMatch(StringUtils::isBlank); + } + + private void addSensitivePathSlot(List slots, SensitivePathSlot candidate) { + boolean overlaps = slots.stream().anyMatch(slot -> candidate.startIndex() < slot.endIndex() + && slot.startIndex() < candidate.endIndex()); + if (!overlaps) { + slots.add(candidate); + } + } + + private SensitiveUrlParts splitSensitiveUrl(String value) { + String remaining = StringUtils.defaultString(value); + String fragment = null; + int fragmentIndex = remaining.indexOf('#'); + if (fragmentIndex >= 0) { + fragment = remaining.substring(fragmentIndex + 1); + remaining = remaining.substring(0, fragmentIndex); + } + String query = null; + int queryIndex = remaining.indexOf('?'); + if (queryIndex >= 0) { + query = remaining.substring(queryIndex + 1); + remaining = remaining.substring(0, queryIndex); + } + + String userInfo = null; + int schemeIndex = remaining.indexOf("://"); + int authorityStart = schemeIndex >= 0 + ? schemeIndex + 3 + : remaining.startsWith("//") ? 2 : -1; + if (authorityStart >= 0) { + int pathStart = remaining.indexOf('/', authorityStart); + if (pathStart < 0) { + pathStart = remaining.length(); + } + String authority = remaining.substring(authorityStart, pathStart); + int userInfoEnd = authority.lastIndexOf('@'); + if (userInfoEnd >= 0) { + userInfo = authority.substring(0, userInfoEnd); + remaining = remaining.substring(0, authorityStart) + + authority.substring(userInfoEnd + 1) + + remaining.substring(pathStart); + } + } + return new SensitiveUrlParts(remaining, userInfo, query, fragment); + } + + private String buildSensitiveUrl(String base, String userInfo, String query, String fragment) { + String result = StringUtils.defaultString(base); + if (userInfo != null) { + int schemeIndex = result.indexOf("://"); + int authorityStart = schemeIndex >= 0 + ? schemeIndex + 3 + : result.startsWith("//") ? 2 : -1; + if (authorityStart < 0) { + throw new RenException("带用户凭据的 URL 格式无效,无法安全恢复"); + } + result = result.substring(0, authorityStart) + userInfo + "@" + result.substring(authorityStart); + } + if (query != null) { + result += "?" + query; + } + if (fragment != null) { + result += "#" + fragment; + } + return result; + } + + private record SensitiveUrlParts(String base, String userInfo, String query, String fragment) { + } + + private record UrlPathParts(String prefix, List segments, String host) { + } + + private record SensitivePathAnalysis(String originalBase, String redactedBase, + List slots) { + } + + private record SensitivePathSlot(String identity, int startIndex, int endIndex, + String publicPrefix, List secretSegments) { + } + + private record UrlParameter(String rawKey, String rawValue, boolean hasEquals) { + } + private boolean isSensitiveKey(String key) { - String normalized = StringUtils.defaultString(key).toLowerCase().replaceAll("[^a-z0-9]", ""); + String normalized = StringUtils.defaultString(key).toLowerCase(Locale.ROOT).replaceAll("[^a-z0-9]", ""); return normalized.equals("authorization") + || normalized.contains("authorization") + || normalized.contains("authentication") + || normalized.equals("auth") + || normalized.endsWith("auth") + || normalized.equals("cookie") + || normalized.equals("cookie2") + || normalized.equals("setcookie") + || normalized.equals("setcookie2") + || normalized.endsWith("cookie") + || normalized.equals("session") + || normalized.endsWith("session") + || normalized.contains("sessionid") + || normalized.contains("sessionkey") + || normalized.contains("sessiontoken") + || normalized.contains("sessioncookie") + || normalized.endsWith("sessid") || normalized.equals("token") || normalized.endsWith("token") || normalized.contains("apikey") || normalized.contains("appkey") || normalized.contains("accesskey") + || normalized.contains("subscriptionkey") || normalized.contains("privatekey") || normalized.contains("password") || normalized.contains("passwd") diff --git a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java index 69547e58..d5a44e1d 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/agent/vo/AgentSnapshotVO.java @@ -18,13 +18,17 @@ public class AgentSnapshotVO { private List changedFields; private List fieldOrder; private String source; - @Schema(description = "恢复来源快照ID,仅恢复前自动备份版本有值") + @Schema(description = "恢复来源快照ID,仅恢复结果版本有值") private String restoreFromSnapshotId; - @Schema(description = "恢复来源版本号,仅恢复前自动备份版本有值") + @Schema(description = "恢复来源版本号,仅恢复结果版本有值") private Integer restoreFromVersionNo; @Schema(description = "创建者,表示触发本次快照写入的操作人") private Long creator; private Date createdAt; private AgentSnapshotDataDTO snapshotData; private AgentSnapshotDataDTO afterSnapshotData; + @Schema(description = "恢复预览对应的脱敏当前配置,仅详情接口有值") + private AgentSnapshotDataDTO currentSnapshotData; + @Schema(description = "恢复预览对应的当前配置状态指纹,仅详情接口有值") + private String currentStateToken; } diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/TimbreService.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/TimbreService.java index d8025b3c..3ba32f50 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/TimbreService.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/TimbreService.java @@ -57,6 +57,14 @@ public interface TimbreService extends BaseService { List getVoiceNames(String ttsModelId, String voiceName); + /** + * 获取普通音色或克隆音色配置的首个有效语言。 + * + * @param id 音色ID + * @return 默认语言;音色不存在或未配置有效语言时返回null + */ + String getDefaultLanguageById(String id); + /** * 根据ID获取音色名称 * @@ -73,4 +81,4 @@ public interface TimbreService extends BaseService { * @return 音色信息 */ VoiceDTO getByVoiceCode(String ttsModelId, String voiceCode); -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java index 165824f6..f86035f8 100644 --- a/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java +++ b/main/manager-api/src/main/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImpl.java @@ -1,6 +1,7 @@ package xiaozhi.modules.timbre.service.impl; import java.util.*; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.StringUtils; @@ -41,6 +42,8 @@ import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity; @Service public class TimbreServiceImpl extends BaseServiceImpl implements TimbreService { + private static final Pattern LANGUAGE_SEPARATOR = Pattern.compile("[、;;,,]"); + private final TimbreDao timbreDao; private final VoiceCloneDao voiceCloneDao; private final RedisUtils redisUtils; @@ -158,6 +161,32 @@ public class TimbreServiceImpl extends BaseServiceImpl return CollectionUtil.isEmpty(voiceDTOs) ? null : voiceDTOs; } + @Override + public String getDefaultLanguageById(String id) { + if (StringUtils.isBlank(id)) { + return null; + } + + TimbreEntity timbre = timbreDao.selectById(id); + if (timbre != null) { + return firstNonBlankLanguage(timbre.getLanguages()); + } + + VoiceCloneEntity voiceClone = voiceCloneDao.selectById(id); + return voiceClone == null ? null : firstNonBlankLanguage(voiceClone.getLanguages()); + } + + private String firstNonBlankLanguage(String languages) { + if (StringUtils.isBlank(languages)) { + return null; + } + return LANGUAGE_SEPARATOR.splitAsStream(languages) + .map(StringUtils::trimToNull) + .filter(Objects::nonNull) + .findFirst() + .orElse(null); + } + /** * 处理是不是tts模型的id */ @@ -214,4 +243,4 @@ public class TimbreServiceImpl extends BaseServiceImpl dto.setIsClone(false); // 设置为普通音色 return dto; } -} \ No newline at end of file +} diff --git a/main/manager-api/src/main/resources/db/changelog/202607101200.sql b/main/manager-api/src/main/resources/db/changelog/202607101200.sql new file mode 100644 index 00000000..7243f217 --- /dev/null +++ b/main/manager-api/src/main/resources/db/changelog/202607101200.sql @@ -0,0 +1,8 @@ +-- liquibase formatted sql + +-- changeset tykechen:202607101200 +ALTER TABLE `ai_agent_snapshot` + ADD COLUMN `redaction_version` TINYINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '快照脱敏规则版本' AFTER `created_at`, + ADD INDEX `idx_snapshot_redaction_version_id` (`redaction_version`, `id`); + +-- rollback ALTER TABLE `ai_agent_snapshot` DROP INDEX `idx_snapshot_redaction_version_id`, DROP COLUMN `redaction_version`; diff --git a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml index 27ed05a3..3ce5d234 100644 --- a/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml +++ b/main/manager-api/src/main/resources/db/changelog/db.changelog-master.yaml @@ -704,3 +704,10 @@ databaseChangeLog: - sqlFile: encoding: utf8 path: classpath:db/changelog/202607071530.sql + - changeSet: + id: 202607101200 + author: tykechen + changes: + - sqlFile: + encoding: utf8 + path: classpath:db/changelog/202607101200.sql diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml index c779a5e6..54a575fb 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentDao.xml @@ -89,4 +89,32 @@ FOR UPDATE + + UPDATE ai_agent + SET agent_code = #{agent.agentCode,jdbcType=VARCHAR}, + agent_name = #{agent.agentName,jdbcType=VARCHAR}, + asr_model_id = #{agent.asrModelId,jdbcType=VARCHAR}, + vad_model_id = #{agent.vadModelId,jdbcType=VARCHAR}, + llm_model_id = #{agent.llmModelId,jdbcType=VARCHAR}, + slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR}, + vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR}, + tts_model_id = #{agent.ttsModelId,jdbcType=VARCHAR}, + tts_voice_id = #{agent.ttsVoiceId,jdbcType=VARCHAR}, + tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR}, + tts_volume = #{agent.ttsVolume,jdbcType=INTEGER}, + tts_rate = #{agent.ttsRate,jdbcType=INTEGER}, + tts_pitch = #{agent.ttsPitch,jdbcType=INTEGER}, + mem_model_id = #{agent.memModelId,jdbcType=VARCHAR}, + intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR}, + chat_history_conf = #{agent.chatHistoryConf,jdbcType=INTEGER}, + system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR}, + summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR}, + lang_code = #{agent.langCode,jdbcType=VARCHAR}, + language = #{agent.language,jdbcType=VARCHAR}, + sort = #{agent.sort,jdbcType=INTEGER}, + updater = #{agent.updater,jdbcType=BIGINT}, + updated_at = #{agent.updatedAt,jdbcType=TIMESTAMP} + WHERE id = #{agent.id} + + diff --git a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml index ea5650c2..39e3211c 100644 --- a/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml +++ b/main/manager-api/src/main/resources/mapper/agent/AgentSnapshotDao.xml @@ -19,7 +19,8 @@ restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_version_no AS restoreFromVersionNo, creator, - created_at AS createdAt + created_at AS createdAt, + redaction_version AS redactionVersion FROM ai_agent_snapshot WHERE agent_id = #{agentId} ORDER BY version_no DESC @@ -37,7 +38,8 @@ restore_from_snapshot_id AS restoreFromSnapshotId, restore_from_version_no AS restoreFromVersionNo, creator, - created_at AS createdAt + created_at AS createdAt, + redaction_version AS redactionVersion FROM ai_agent_snapshot WHERE agent_id = #{agentId} AND version_no > #{versionNo} @@ -57,7 +59,8 @@ restore_from_snapshot_id, restore_from_version_no, creator, - created_at + created_at, + redaction_version ) SELECT #{snapshot.id}, #{snapshot.agentId}, @@ -69,7 +72,8 @@ #{snapshot.restoreFromSnapshotId}, #{snapshot.restoreFromVersionNo}, #{snapshot.creator}, - #{snapshot.createdAt} + #{snapshot.createdAt}, + #{snapshot.redactionVersion} FROM ai_agent_snapshot WHERE agent_id = #{snapshot.agentId} @@ -89,4 +93,31 @@ ) + + + + UPDATE ai_agent_snapshot + SET snapshot_data = CASE id + + WHEN #{snapshot.id} THEN #{snapshot.snapshotData} + + ELSE snapshot_data + END, + redaction_version = #{redactionVersion} + WHERE redaction_version < #{redactionVersion} + AND id IN + + #{snapshot.id} + + + diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java new file mode 100644 index 00000000..42dd244f --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotRedactionRunnerTest.java @@ -0,0 +1,77 @@ +package xiaozhi.modules.agent.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Method; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; +import org.springframework.scheduling.annotation.Scheduled; + +import xiaozhi.modules.agent.service.AgentSnapshotService; + +@ExtendWith(OutputCaptureExtension.class) +class AgentSnapshotRedactionRunnerTest { + + @Test + void startupRedactionRunsSynchronouslyAndReportsCompensationWindow(CapturedOutput output) { + assertTrue(SmartInitializingSingleton.class.isAssignableFrom(AgentSnapshotRedactionRunner.class)); + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + when(service.redactLegacySnapshots()).thenReturn(0L); + + runner.afterSingletonsInstantiated(); + + verify(service).redactLegacySnapshots(); + assertTrue(output.getAll().contains("startup pass completed")); + assertTrue(output.getAll().contains("starts after 5000 ms and repeats every 15000 ms")); + } + + @Test + void startupRedactionFailureIsLoggedAndPropagatedToKeepStartupFailClosed(CapturedOutput output) { + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + IllegalStateException failure = new IllegalStateException("database unavailable"); + when(service.redactLegacySnapshots()).thenThrow(failure); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + runner::afterSingletonsInstantiated); + + assertSame(failure, thrown); + assertTrue(output.getAll().contains("blocking application startup before it can accept traffic")); + } + + @Test + void rollingDeploymentRedactionReportsTriggerCountAndCredentialRotation(CapturedOutput output) { + AgentSnapshotService service = mock(AgentSnapshotService.class); + AgentSnapshotRedactionRunner runner = new AgentSnapshotRedactionRunner(service); + when(service.redactLegacySnapshots()).thenReturn(3L); + + runner.redactLateRollingDeploymentWrites(); + + assertTrue(output.getAll().contains("trigger=rolling-deployment migrated=3")); + assertTrue(output.getAll().contains("Rotate credentials")); + } + + @Test + void rollingDeploymentScheduleKeepsLegacyWriteExposureWindowShort() throws Exception { + Method method = AgentSnapshotRedactionRunner.class.getMethod("redactLateRollingDeploymentWrites"); + Scheduled scheduled = method.getAnnotation(Scheduled.class); + + assertNotNull(scheduled); + assertEquals(5_000, scheduled.initialDelay()); + assertEquals(15_000, scheduled.fixedDelay()); + assertTrue(scheduled.initialDelay() <= 10_000); + assertTrue(scheduled.fixedDelay() <= 30_000); + } +} diff --git a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java index e3aad1b1..78fd05b9 100644 --- a/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java +++ b/main/manager-api/src/test/java/xiaozhi/modules/agent/service/impl/AgentSnapshotServiceImplTest.java @@ -3,60 +3,78 @@ package xiaozhi.modules.agent.service.impl; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.verifyNoMoreInteractions; import static org.mockito.Mockito.when; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.fasterxml.jackson.core.type.TypeReference; import java.lang.reflect.Method; +import java.lang.reflect.InvocationTargetException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Date; import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.mockito.InOrder; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.transaction.annotation.Transactional; import xiaozhi.common.utils.JsonUtils; +import xiaozhi.common.exception.RenException; import xiaozhi.modules.agent.Enums.AgentSnapshotField; import xiaozhi.modules.agent.dao.AgentDao; import xiaozhi.modules.agent.dao.AgentSnapshotDao; import xiaozhi.modules.agent.dao.AgentTagDao; +import xiaozhi.modules.agent.dao.AgentTagRelationDao; +import xiaozhi.modules.agent.dto.AgentCreateDTO; import xiaozhi.modules.agent.dto.AgentSnapshotDataDTO; import xiaozhi.modules.agent.dto.AgentSnapshotPageDTO; import xiaozhi.modules.agent.dto.AgentSnapshotTagDTO; import xiaozhi.modules.agent.dto.AgentUpdateDTO; import xiaozhi.modules.agent.dto.ContextProviderDTO; import xiaozhi.modules.agent.entity.AgentEntity; +import xiaozhi.modules.agent.entity.AgentPluginMapping; +import xiaozhi.modules.agent.entity.AgentTemplateEntity; import xiaozhi.modules.agent.entity.AgentSnapshotEntity; import xiaozhi.modules.agent.entity.AgentTagEntity; +import xiaozhi.modules.agent.service.AgentChatHistoryService; +import xiaozhi.modules.agent.service.AgentPluginMappingService; import xiaozhi.modules.agent.service.AgentContextProviderService; import xiaozhi.modules.agent.service.AgentSnapshotService; +import xiaozhi.modules.agent.service.AgentTagService; +import xiaozhi.modules.agent.service.AgentTemplateService; import xiaozhi.modules.agent.vo.AgentSnapshotVO; import xiaozhi.modules.agent.vo.AgentInfoVO; import xiaozhi.modules.correctword.service.CorrectWordFileService; +import xiaozhi.modules.model.service.ModelProviderService; +import xiaozhi.modules.timbre.service.TimbreService; class AgentSnapshotServiceImplTest { @Test @SuppressWarnings("unchecked") - void normalizeSortedJsonListTreatsDtoAndMapShapesEqually() throws Exception { + void normalizeJsonListTreatsDtoAndMapShapesEqually() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, null, null); - Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("normalizeSortedJsonList", List.class); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("normalizeJsonList", List.class); method.setAccessible(true); ContextProviderDTO dto = new ContextProviderDTO(); @@ -70,6 +88,31 @@ class AgentSnapshotServiceImplTest { assertEquals(method.invoke(service, List.of(dto)), method.invoke(service, List.of(map))); } + @Test + @SuppressWarnings("unchecked") + void contextProviderOrderIsARealSnapshotChange() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + ContextProviderDTO first = new ContextProviderDTO(); + first.setUrl("https://example.com/first"); + first.setHeaders(Map.of()); + ContextProviderDTO second = new ContextProviderDTO(); + second.setUrl("https://example.com/second"); + second.setHeaders(Map.of()); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setContextProviders(List.of(first, second)); + AgentSnapshotDataDTO reordered = new AgentSnapshotDataDTO(); + reordered.setContextProviders(List.of(second, first)); + + List changedFields = (List) method.invoke(service, current, reordered); + + assertEquals(List.of(AgentSnapshotField.CONTEXT_PROVIDERS.getFieldName()), changedFields); + } + @Test void redactSnapshotDataMasksSensitiveFunctionAndHeaderValues() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, @@ -78,26 +121,131 @@ class AgentSnapshotServiceImplTest { AgentSnapshotDataDTO.class); method.setAccessible(true); - AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); - AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); - function.setPluginId("rag"); - function.setParamInfo(Map.of("api_key", "secret-key", "max_tokens", 32)); - ContextProviderDTO provider = new ContextProviderDTO(); - provider.setUrl("https://example.com/context"); - provider.setHeaders(Map.of("Authorization", "Bearer secret-token", "Accept", "application/json")); - data.setFunctions(List.of(function)); - data.setContextProviders(List.of(provider)); + AgentSnapshotDataDTO data = buildExtendedSensitiveSnapshot(); AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) method.invoke(service, data); String json = JsonUtils.toJsonString(redacted); - assertFalse(json.contains("secret-key")); - assertFalse(json.contains("secret-token")); + assertExtendedSecretsAbsent(json); assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); assertTrue(json.contains("max_tokens")); assertTrue(json.contains("application/json")); } + @Test + void snapshotDetailMasksCookieProxyAuthorizationAndSessionHeaders() throws Exception { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, null, + null, null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("toVO", + AgentSnapshotEntity.class, boolean.class); + method.setAccessible(true); + + AgentSnapshotEntity entity = snapshotEntity("snapshot-id", "agent-id", 4, buildExtendedSensitiveSnapshot()); + when(snapshotDao.selectNextSnapshot("agent-id", 4)).thenReturn(null); + + AgentSnapshotVO detail = (AgentSnapshotVO) method.invoke(service, entity, true); + String json = JsonUtils.toJsonString(detail); + + assertExtendedSecretsAbsent(json); + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(json.contains("application/json")); + } + + @Test + void snapshotDetailReturnsCurrentPreviewDataAndTokenFromTheSameServerRead() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, null, + contextProviderService, null, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String snapshotId = "snapshot-id"; + AgentInfoVO current = snapshotAgentInfo(agentId, 7L, "current", "current-summary"); + AgentPluginMapping mapping = new AgentPluginMapping(); + mapping.setPluginId("plugin"); + mapping.setParamInfo("{\"api_key\":\"live-secret\",\"visible\":\"kept\"," + + "\"webhookUrl\":\"https://hooks.slack.com/services/T111/B222/live-path-secret\"}"); + current.setFunctions(List.of(mapping)); + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(snapshotId)) + .thenReturn(snapshotEntity(snapshotId, agentId, 2, snapshotData("target", "target-summary"))); + when(snapshotDao.selectNextSnapshot(agentId, 2)).thenReturn(null); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(current); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + AgentSnapshotVO detail = service.getSnapshot(agentId, snapshotId); + + assertNotNull(detail.getCurrentSnapshotData()); + assertEquals(64, detail.getCurrentStateToken().length()); + String currentJson = JsonUtils.toJsonString(detail.getCurrentSnapshotData()); + assertFalse(currentJson.contains("live-secret")); + assertFalse(currentJson.contains("live-path-secret")); + assertTrue(currentJson.contains("__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(currentJson.contains("kept")); + } + + @Test + void legacyRedactionMigrationCleansStructuredAndUrlSecretsWithoutDroppingUnknownFields() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, + null, null, null, null); + AgentSnapshotEntity legacy = new AgentSnapshotEntity(); + legacy.setId("legacy-id"); + legacy.setRedactionVersion(1); + legacy.setSnapshotData(JsonUtils.toJsonString(Map.of( + "futureField", Map.of( + "kept", true, + "primaryEndpoint", "https://api.example.com/hooks/events/status", + "secondaryEndpoint", "https://api.example.com/webhooks/events/status", + "singleHookEndpoint", "https://api.example.com/hooks/events", + "statusEndpoint", "https://api.example.com/webhooks/status", + "webhook", "incoming/legacy-token", + "description", "incoming/token", + "outgoingWebhookUrl", "https://example.com/incoming/legacy-path-secret"), + "functions", List.of(Map.of("paramInfo", Map.of( + "headers", List.of(Map.of("NAME", "Authorization", "VALUE", "Bearer legacy-secret")), + "Cookie", "legacy-cookie", + "protocolRelativeEndpoint", + "//legacy-relative-user:legacy-relative-pass@example.com/public/path", + "endpoint", "https://user:pass@example.com/hook?subscription-key=legacy-key")))))); + when(snapshotDao.selectLegacyRedactionBatch(null, 100, 2)).thenReturn(List.of(legacy)); + when(snapshotDao.selectLegacyRedactionBatch("legacy-id", 100, 2)).thenReturn(List.of()); + when(snapshotDao.updateRedactedSnapshots(any(), eq(2))).thenReturn(1); + + long migrated = service.redactLegacySnapshots(); + + verify(snapshotDao).updateRedactedSnapshots( + argThat(snapshots -> snapshots.size() == 1 && "legacy-id".equals(snapshots.get(0).getId())), eq(2)); + assertEquals(1, migrated); + assertFalse(legacy.getSnapshotData().contains("legacy-secret")); + assertFalse(legacy.getSnapshotData().contains("legacy-cookie")); + assertFalse(legacy.getSnapshotData().contains("legacy-key")); + assertFalse(legacy.getSnapshotData().contains("user:pass")); + assertFalse(legacy.getSnapshotData().contains("legacy-path-secret")); + assertFalse(legacy.getSnapshotData().contains("legacy-relative-user")); + assertFalse(legacy.getSnapshotData().contains("legacy-relative-pass")); + assertFalse(legacy.getSnapshotData().contains("incoming/legacy-token")); + assertTrue(legacy.getSnapshotData().contains("futureField")); + assertTrue(legacy.getSnapshotData().contains("\"kept\":true")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/hooks/events/status")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/webhooks/events/status")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/hooks/events")); + assertTrue(legacy.getSnapshotData().contains("https://api.example.com/webhooks/status")); + assertTrue(legacy.getSnapshotData().contains("//__SNAPSHOT_SECRET_REDACTED__@example.com/public/path")); + assertTrue(legacy.getSnapshotData().contains("incoming/__SNAPSHOT_SECRET_REDACTED__")); + assertTrue(legacy.getSnapshotData().contains("incoming/token")); + assertTrue(legacy.getSnapshotData().contains("__SNAPSHOT_SECRET_REDACTED__")); + } + @Test void preserveCurrentSensitiveValuesKeepsCurrentSecretsWhenRestoringSnapshot() throws Exception { AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, @@ -116,6 +264,658 @@ class AgentSnapshotServiceImplTest { assertEquals("current-token", restored.getContextProviders().get(0).getHeaders().get("Authorization")); } + @Test + void redactSnapshotDataMasksStructuredHeadersAndUrlCredentials() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + method.setAccessible(true); + + Map params = new HashMap<>(); + params.put("headers", List.of( + Map.of("key", "Authorization", "value", "Bearer structured-secret"), + Map.of("name", "Cookie", "value", "cookie=structured-cookie"))); + params.put("callbackUrl", + "https://function-user:function-pass@example.com/hook?access_token=function-token#function-fragment"); + params.put("endpoint", + "https://example.com/signed?locale=zh-CN&X-Amz-Signature=endpoint-signature"); + params.put("transport", + "https://example.com/transport?mode=push&key=transport-key"); + params.put("mirrors", List.of( + "https://example.com/nested?locale=en&token=nested-token")); + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setFunctions(List.of(functionInfo("plugin", params))); + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://provider-user:provider-pass@example.com/context?token=provider-token#provider-fragment"); + provider.setHeaders(Map.of()); + data.setContextProviders(List.of(provider)); + + AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) method.invoke(service, data); + String json = JsonUtils.toJsonString(redacted); + + for (String secret : List.of("structured-secret", "structured-cookie", "function-user", "function-pass", + "function-token", "function-fragment", "provider-user", "provider-pass", "provider-token", + "provider-fragment", "endpoint-signature", "transport-key", "nested-token")) { + assertFalse(json.contains(secret), () -> "Sensitive value leaked: " + secret); + } + assertEquals("https://__SNAPSHOT_SECRET_REDACTED__@example.com/context" + + "?token=__SNAPSHOT_SECRET_REDACTED__#__SNAPSHOT_SECRET_REDACTED__", + redacted.getContextProviders().get(0).getUrl()); + assertEquals("https://example.com/signed?locale=zh-CN&X-Amz-Signature=__SNAPSHOT_SECRET_REDACTED__", + redacted.getFunctions().get(0).getParamInfo().get("endpoint")); + assertEquals("https://example.com/transport?mode=push&key=__SNAPSHOT_SECRET_REDACTED__", + redacted.getFunctions().get(0).getParamInfo().get("transport")); + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + void capabilityUrlPathsAreRedactedPreciselyAndIdempotently() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + method.setAccessible(true); + + Map cases = new LinkedHashMap<>(); + cases.put("https://hooks.slack.com/services/T111/B222/slack-secret", + "https://hooks.slack.com/services/T111/B222/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("https://discord.com/api/v10/webhooks/123456/discord-secret", + "https://discord.com/api/v10/webhooks/123456/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("https://api.telegram.org/bot123456:telegram-secret/sendMessage", + "https://api.telegram.org/bot123456:__SNAPSHOT_SECRET_REDACTED__/sendMessage"); + cases.put("https://hooks.zapier.com/hooks/catch/987/generic-secret", + "https://hooks.zapier.com/hooks/__SNAPSHOT_SECRET_REDACTED__"); + cases.put("/api/webhooks/relative-secret/continuation", + "/api/webhooks/__SNAPSHOT_SECRET_REDACTED__"); + + for (Map.Entry entry : cases.entrySet()) { + String redacted = (String) method.invoke(service, entry.getKey(), "endpoint"); + assertEquals(entry.getValue(), redacted); + assertEquals(redacted, method.invoke(service, redacted, "endpoint")); + } + + assertEquals("https://example.com/incoming/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://example.com/incoming/key-secret", "outgoingWebhookUrl")); + assertEquals("https://api.example.com/v1/users/123/preferences", + method.invoke(service, "https://api.example.com/v1/users/123/preferences", "endpoint")); + assertEquals("https://api.example.com/hooks/events/status", + method.invoke(service, "https://api.example.com/hooks/events/status", "endpoint")); + assertEquals("https://api.example.com/webhooks/events/status", + method.invoke(service, "https://api.example.com/webhooks/events/status", "endpoint")); + assertEquals("https://api.example.com/hooks/events", + method.invoke(service, "https://api.example.com/hooks/events", "endpoint")); + assertEquals("https://api.example.com/webhooks/status", + method.invoke(service, "https://api.example.com/webhooks/status", "endpoint")); + assertEquals("https://api.example.com/hooks/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://api.example.com/hooks/access-token-value", "endpoint")); + assertEquals("https://api.example.com/webhooks/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "https://api.example.com/webhooks/aB3dE5fG7hI9jK1mN3pQ5rS7", "endpoint")); + assertEquals("/api/v1/agents/42", + method.invoke(service, "/api/v1/agents/42", "endpoint")); + assertEquals("incoming/__SNAPSHOT_SECRET_REDACTED__", + method.invoke(service, "incoming/token", "webhook")); + assertEquals("incoming/token", + method.invoke(service, "incoming/token", "description")); + } + + @Test + @SuppressWarnings("unchecked") + void pathCapabilityRotationDoesNotChangeSnapshotStateButPublicIdentityDoes() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method changedMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + changedMethod.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B222/first-secret", + "endpoint", "/api/webhooks/first-relative-secret/continuation")))); + AgentSnapshotDataDTO rotated = new AgentSnapshotDataDTO(); + rotated.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B222/second-secret", + "endpoint", "/api/webhooks/second-relative-secret/continuation")))); + AgentSnapshotDataDTO differentPublicIdentity = new AgentSnapshotDataDTO(); + differentPublicIdentity.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookUrl", "https://hooks.slack.com/services/T111/B999/second-secret", + "endpoint", "/api/webhooks/second-relative-secret/continuation")))); + + assertTrue(((List) changedMethod.invoke(service, current, rotated)).isEmpty()); + assertEquals(currentStateToken(service, current), currentStateToken(service, rotated)); + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), + changedMethod.invoke(service, current, differentPublicIdentity)); + assertFalse(currentStateToken(service, current).equals(currentStateToken(service, differentPublicIdentity))); + } + + @Test + @SuppressWarnings("unchecked") + void nestedWebhookSemanticKeysPropagateAcrossRedactionComparisonAndRestore() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method changedMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + changedMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + Method containsMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("containsSensitiveMaterial", + Object.class); + containsMethod.setAccessible(true); + + List> nestedCases = List.of( + Map.of("webhookConfig", Map.of("value", + "https://capability.example.com/public/nested-secret-one")), + Map.of("deliveryWebhook", Map.of("options", Map.of("target", + "https://capability.example.com/public/nested-secret-two"))), + Map.of("config", Map.of("webhookUrl", + "https://capability.example.com/public/nested-secret-three"))); + for (Map params : nestedCases) { + AgentSnapshotDataDTO candidate = new AgentSnapshotDataDTO(); + candidate.setFunctions(List.of(functionInfo("plugin", params))); + AgentSnapshotDataDTO redacted = (AgentSnapshotDataDTO) redactMethod.invoke(service, candidate); + String json = JsonUtils.toJsonString(redacted); + for (String secret : List.of("nested-secret-one", "nested-secret-two", "nested-secret-three")) { + assertFalse(json.contains(secret), () -> "Nested webhook capability leaked: " + secret); + } + assertTrue(json.contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + AgentSnapshotDataDTO first = nestedWebhookSnapshot("first-current-secret"); + AgentSnapshotDataDTO rotated = nestedWebhookSnapshot("rotated-current-secret"); + assertTrue(((List) changedMethod.invoke(service, first, rotated)).isEmpty()); + assertEquals(currentStateToken(service, first), currentStateToken(service, rotated)); + + AgentSnapshotDataDTO target = (AgentSnapshotDataDTO) redactMethod.invoke(service, + nestedWebhookSnapshot("historical-secret")); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, rotated); + Map webhookConfig = (Map) restored.getFunctions().get(0) + .getParamInfo().get("webhookConfig"); + assertEquals("https://capability.example.com/public/rotated-current-secret", webhookConfig.get("value")); + assertTrue((Boolean) containsMethod.invoke(service, + target.getFunctions().get(0).getParamInfo())); + } + + @Test + void restoringPathCapabilitiesCopiesOnlyCurrentSecretAndRejectsUnsafeIdentity() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "https://hooks.slack.com/services/T111/B222/historical-secret", "webhookUrl"); + assertEquals("https://hooks.slack.com/services/T111/B222/current-secret", + preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B222/current-secret", "webhookUrl")); + String relativeTarget = (String) redactMethod.invoke(service, + "/api/webhooks/historical-secret/continuation", "endpoint"); + assertEquals("/api/webhooks/current-relative/callback", + preserveMethod.invoke(service, relativeTarget, + "/api/webhooks/current-relative/callback", "endpoint")); + String bareRelativeTarget = (String) redactMethod.invoke(service, + "incoming/historical-token", "webhook"); + assertEquals("incoming/current-token", + preserveMethod.invoke(service, bareRelativeTarget, + "incoming/current-token", "webhook")); + + InvocationTargetException mismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B999/current-secret", "webhookUrl")); + assertTrue(mismatch.getCause() instanceof RenException); + InvocationTargetException missing = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, null, "webhookUrl")); + assertTrue(missing.getCause() instanceof RenException); + InvocationTargetException placeholder = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + "https://hooks.slack.com/services/T111/B222/__SNAPSHOT_SECRET_REDACTED__", "webhookUrl")); + assertTrue(placeholder.getCause() instanceof RenException); + InvocationTargetException relativeMismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, relativeTarget, + "/different/webhooks/current-relative/callback", "endpoint")); + assertTrue(relativeMismatch.getCause() instanceof RenException); + InvocationTargetException bareRelativeMismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, bareRelativeTarget, + "outgoing/current-token", "webhook")); + assertTrue(bareRelativeMismatch.getCause() instanceof RenException); + } + + @Test + void duplicatePublicPathIdentitiesAreRejectedInsteadOfGuessingASecret() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("webhooks", List.of( + "https://hooks.slack.com/services/T111/B222/historical-secret"))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("webhooks", List.of( + "https://hooks.slack.com/services/T111/B222/current-one", + "https://hooks.slack.com/services/T111/B222/current-two"))))); + + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, current)); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + @SuppressWarnings("unchecked") + void sensitiveListItemsWithoutNamedIdsUseUniquePublicFingerprint() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "historical-secret")))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "current-secret")))))); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List> destinations = (List>) restored.getFunctions().get(0) + .getParamInfo().get("destinations"); + assertEquals("current-secret", destinations.get(0).get("api_key")); + + AgentSnapshotDataDTO ambiguousCurrent = new AgentSnapshotDataDTO(); + ambiguousCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("destinations", List.of( + Map.of("host", "alpha.example.com", "api_key", "first-current-secret"), + Map.of("host", "alpha.example.com", "api_key", "second-current-secret")))))); + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, ambiguousCurrent)); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + void contextProvidersMatchPathCapabilitiesByUniquePublicIdentity() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + ContextProviderDTO targetProvider = new ContextProviderDTO(); + targetProvider.setUrl("https://discord.com/api/webhooks/123456/historical-secret"); + targetProvider.setHeaders(Map.of()); + target.setContextProviders(List.of(targetProvider)); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + ContextProviderDTO currentProvider = new ContextProviderDTO(); + currentProvider.setUrl("https://discord.com/api/webhooks/123456/current-secret"); + currentProvider.setHeaders(Map.of()); + current.setContextProviders(List.of(currentProvider)); + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + assertEquals("https://discord.com/api/webhooks/123456/current-secret", + restored.getContextProviders().get(0).getUrl()); + + currentProvider.setUrl("https://discord.com/api/webhooks/999999/current-secret"); + AgentSnapshotDataDTO redactedTarget = target; + InvocationTargetException mismatch = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, redactedTarget, current)); + assertTrue(mismatch.getCause() instanceof RenException); + } + + @Test + void snapshotPayloadReaderIgnoresFutureFieldsWithoutChangingGlobalJsonValidation() { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + String payload = "{\"agentName\":\"future-safe\",\"futureField\":{\"enabled\":true}}"; + + AgentSnapshotDataDTO parsed = ReflectionTestUtils.invokeMethod(service, "parseSnapshotData", payload); + + assertNotNull(parsed); + assertEquals("future-safe", parsed.getAgentName()); + assertThrows(RuntimeException.class, () -> JsonUtils.parseObject(payload, AgentSnapshotDataDTO.class)); + } + + @Test + @SuppressWarnings("unchecked") + void preserveSensitiveUrlListsByUrlIdentityInsteadOfIndex() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("mirrors", List.of( + "https://a.example.com/hook?token=old-a&locale=en", + "https://b.example.com/hook?token=old-b&locale=en"))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("mirrors", List.of( + "https://b.example.com/hook?token=current-b&locale=de", + "https://a.example.com/hook?token=current-a&locale=de"))))); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List mirrors = (List) restored.getFunctions().get(0).getParamInfo().get("mirrors"); + + assertEquals(List.of( + "https://a.example.com/hook?token=current-a&locale=en", + "https://b.example.com/hook?token=current-b&locale=en"), mirrors); + } + + @Test + @SuppressWarnings("unchecked") + void urlComparisonIgnoresOnlySensitiveParameterValues() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=en&api_key=first&X-Amz-Signature=first-signature")))); + AgentSnapshotDataDTO secretOnlyChange = new AgentSnapshotDataDTO(); + secretOnlyChange.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=en&api_key=second&X-Amz-Signature=second-signature")))); + AgentSnapshotDataDTO publicParameterChange = new AgentSnapshotDataDTO(); + publicParameterChange.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://api.example.com/hook?locale=de&api_key=second&X-Amz-Signature=second-signature")))); + + assertTrue(((List) method.invoke(service, current, secretOnlyChange)).isEmpty()); + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), + method.invoke(service, current, publicParameterChange)); + } + + @Test + @SuppressWarnings("unchecked") + void preserveSensitiveHeaderListsByStableIdentityInsteadOfIndex() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Authorization", "value", "old-auth"), + Map.of("name", "Cookie", "value", "old-cookie")))))); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Cookie", "value", "current-cookie"), + Map.of("key", "Authorization", "value", "current-auth")))))); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + List> headers = (List>) restored.getFunctions().get(0) + .getParamInfo().get("headers"); + + assertEquals("Authorization", headers.get(0).get("key")); + assertEquals("current-auth", headers.get(0).get("value")); + assertEquals("Cookie", headers.get(1).get("name")); + assertEquals("current-cookie", headers.get(1).get("value")); + assertFalse(JsonUtils.toJsonString(restored).contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + @SuppressWarnings("unchecked") + void structuredSensitiveValuesRequireOneEquivalentDiscriminator() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveMap", Map.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveMap", + Map.class, Map.class); + preserveMethod.setAccessible(true); + + Map target = (Map) redactMethod.invoke(service, + Map.of("key", "Authorization", "value", "historical-secret")); + Map restored = (Map) preserveMethod.invoke(service, target, + Map.of("key", "authorization", "value", "current-secret")); + assertEquals("current-secret", restored.get("value")); + + InvocationTargetException mismatchedType = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, + Map.of("key", "Cookie", "value", "cookie-secret"))); + assertTrue(mismatchedType.getCause() instanceof RenException); + + Map ambiguousTarget = (Map) redactMethod.invoke(service, + Map.of("key", "Authorization", "name", "Cookie", "value", "historical-secret")); + InvocationTargetException ambiguous = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, ambiguousTarget, + Map.of("key", "Authorization", "name", "Cookie", "value", "current-secret"))); + assertTrue(ambiguous.getCause() instanceof RenException); + } + + @Test + void restoringRedactedUrlsUsesTargetLocationAndCurrentCredentials() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSnapshotData", + AgentSnapshotDataDTO.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + preserveMethod.setAccessible(true); + + AgentSnapshotDataDTO target = new AgentSnapshotDataDTO(); + target.setFunctions(List.of(functionInfo("plugin", Map.of("callbackUrl", + "https://old-user:old-pass@old.example.com/hook" + + "?api%5Fkey=old-token&locale=en#token=old-fragment§ion=target")))); + ContextProviderDTO targetProvider = new ContextProviderDTO(); + targetProvider.setUrl("https://old-user:old-pass@api.example.com/context" + + "?api_key=old-token&locale=en#sig=old-fragment§ion=target"); + targetProvider.setHeaders(Map.of()); + target.setContextProviders(List.of(targetProvider)); + target = (AgentSnapshotDataDTO) redactMethod.invoke(service, target); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of("callbackUrl", + "https://current-user:current-pass@old.example.com/hook" + + "?api_key=current-token&locale=de#token=current-fragment§ion=current")))); + ContextProviderDTO currentProvider = new ContextProviderDTO(); + currentProvider.setUrl( + "https://current-user:current-pass@api.example.com/context" + + "?api_key=current-token&locale=de#sig=current-fragment§ion=current"); + currentProvider.setHeaders(Map.of()); + current.setContextProviders(List.of(currentProvider)); + + AgentSnapshotDataDTO restored = (AgentSnapshotDataDTO) preserveMethod.invoke(service, target, current); + + assertEquals("https://current-user:current-pass@old.example.com/hook" + + "?api%5Fkey=current-token&locale=en#token=current-fragment§ion=target", + restored.getFunctions().get(0).getParamInfo().get("callbackUrl")); + assertEquals("https://current-user:current-pass@api.example.com/context" + + "?api_key=current-token&locale=en#sig=current-fragment§ion=target", + restored.getContextProviders().get(0).getUrl()); + assertFalse(JsonUtils.toJsonString(restored).contains("__SNAPSHOT_SECRET_REDACTED__")); + } + + @Test + void urlSecretRestoreRequiresTheSamePublicSchemeAuthorityAndPath() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "https://old-user:old-pass@example.com/callback?token=old-token#old-fragment", "callbackUrl"); + assertEquals("https://new-user:new-pass@example.com/callback?token=current-token#current-fragment", + preserveMethod.invoke(service, target, + "https://new-user:new-pass@example.com/callback?token=current-token#current-fragment", + "callbackUrl")); + + for (String mismatch : List.of( + "https://new-user:new-pass@other.example.com/callback?token=current-token#current-fragment", + "https://new-user:new-pass@example.com/other?token=current-token#current-fragment", + "http://new-user:new-pass@example.com/callback?token=current-token#current-fragment")) { + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, mismatch, "callbackUrl")); + assertTrue(error.getCause() instanceof RenException); + } + } + + @Test + void protocolRelativeUrlsRedactUserInfoAndRequireTheSamePublicBaseOnRestore() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method redactMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("redactSensitiveUrl", + String.class, String.class); + redactMethod.setAccessible(true); + Method preserveMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveUrl", + String.class, String.class, String.class); + preserveMethod.setAccessible(true); + + String target = (String) redactMethod.invoke(service, + "//old-user:old-pass@example.com/path?token=old-token", "endpoint"); + assertEquals("//__SNAPSHOT_SECRET_REDACTED__@example.com/path" + + "?token=__SNAPSHOT_SECRET_REDACTED__", target); + assertEquals("//current-user:current-pass@example.com/path?token=current-token", + preserveMethod.invoke(service, target, + "//current-user:current-pass@example.com/path?token=current-token", "endpoint")); + + for (String mismatch : List.of( + "//current-user:current-pass@other.example.com/path?token=current-token", + "//current-user:current-pass@example.com/other?token=current-token", + "https://current-user:current-pass@example.com/path?token=current-token")) { + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> preserveMethod.invoke(service, target, mismatch, "endpoint")); + assertTrue(error.getCause() instanceof RenException); + } + } + + @Test + @SuppressWarnings("unchecked") + void restoringOlderStructureDetectsIrreversibleSensitiveRemovals() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + Method validateMethod = AgentSnapshotServiceImpl.class.getDeclaredMethod( + "validateSensitiveRestoreIsReversible", AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + validateMethod.setAccessible(true); + + AgentSnapshotDataDTO noPluginsTarget = new AgentSnapshotDataDTO(); + noPluginsTarget.setFunctions(List.of()); + AgentSnapshotDataDTO currentWithPlugin = new AgentSnapshotDataDTO(); + currentWithPlugin.setFunctions(List.of(functionInfo("new-plugin", Map.of("api_key", "current-secret")))); + + AgentSnapshotDataDTO removedPlugin = (AgentSnapshotDataDTO) method.invoke(service, noPluginsTarget, + currentWithPlugin); + assertTrue(removedPlugin.getFunctions().isEmpty()); + InvocationTargetException removedPluginError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, currentWithPlugin, removedPlugin)); + assertTrue(removedPluginError.getCause() instanceof RenException); + + AgentSnapshotDataDTO headerTarget = new AgentSnapshotDataDTO(); + headerTarget.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Content-Type", "value", "application/json")))))); + AgentSnapshotDataDTO headerCurrent = new AgentSnapshotDataDTO(); + headerCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("key", "Authorization", "value", "current-secret"), + Map.of("key", "Content-Type", "value", "text/plain")))))); + + AgentSnapshotDataDTO removedHeader = (AgentSnapshotDataDTO) method.invoke(service, headerTarget, + headerCurrent); + List> headers = (List>) removedHeader.getFunctions().get(0) + .getParamInfo().get("headers"); + assertEquals(1, headers.size()); + assertEquals("Content-Type", headers.get(0).get("key")); + assertEquals("application/json", headers.get(0).get("value")); + InvocationTargetException removedHeaderError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, headerCurrent, removedHeader)); + assertTrue(removedHeaderError.getCause() instanceof RenException); + + AgentSnapshotDataDTO urlTarget = new AgentSnapshotDataDTO(); + urlTarget.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?locale=en")))); + AgentSnapshotDataDTO urlCurrent = new AgentSnapshotDataDTO(); + urlCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?api_key=current-secret&locale=de")))); + AgentSnapshotDataDTO removedUrlSecret = (AgentSnapshotDataDTO) method.invoke(service, urlTarget, urlCurrent); + assertEquals("https://example.com/hook?locale=en", + removedUrlSecret.getFunctions().get(0).getParamInfo().get("endpoint")); + InvocationTargetException removedUrlError = assertThrows(InvocationTargetException.class, + () -> validateMethod.invoke(service, urlCurrent, removedUrlSecret)); + assertTrue(removedUrlError.getCause() instanceof RenException); + } + + @Test + void ambiguousOrHistoricalRawSensitiveTargetsAreRejectedInsteadOfGuessed() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("preserveCurrentSensitiveValues", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO ambiguous = new AgentSnapshotDataDTO(); + ambiguous.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("value", "__SNAPSHOT_SECRET_REDACTED__")))))); + InvocationTargetException ambiguousError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, ambiguous, new AgentSnapshotDataDTO())); + assertTrue(ambiguousError.getCause() instanceof RenException); + + AgentSnapshotDataDTO conflictingIdentities = new AgentSnapshotDataDTO(); + conflictingIdentities.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Shared", "key", "Authorization", "value", + "__SNAPSHOT_SECRET_REDACTED__")))))); + AgentSnapshotDataDTO conflictingCurrent = new AgentSnapshotDataDTO(); + conflictingCurrent.setFunctions(List.of(functionInfo("plugin", Map.of("headers", List.of( + Map.of("name", "Shared", "key", "Cookie", "value", "cookie-secret"), + Map.of("name", "Different", "key", "Authorization", "value", "auth-secret")))))); + InvocationTargetException conflictingError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, conflictingIdentities, conflictingCurrent)); + assertTrue(conflictingError.getCause() instanceof RenException); + + AgentSnapshotDataDTO historicalRaw = new AgentSnapshotDataDTO(); + historicalRaw.setFunctions(List.of(functionInfo("plugin", Map.of("Authorization", "old-raw-secret")))); + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setFunctions(List.of(functionInfo("plugin", Map.of()))); + InvocationTargetException rawSecretError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, historicalRaw, current)); + assertTrue(rawSecretError.getCause() instanceof RenException); + + AgentSnapshotDataDTO historicalRawUrl = new AgentSnapshotDataDTO(); + historicalRawUrl.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?api_key=historical-secret&locale=en")))); + AgentSnapshotDataDTO currentUrlWithoutSecret = new AgentSnapshotDataDTO(); + currentUrlWithoutSecret.setFunctions(List.of(functionInfo("plugin", Map.of("endpoint", + "https://example.com/hook?locale=de")))); + InvocationTargetException rawUrlSecretError = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, historicalRawUrl, currentUrlWithoutSecret)); + assertTrue(rawUrlSecretError.getCause() instanceof RenException); + } + @Test @SuppressWarnings("unchecked") void getChangedFieldsFollowsCentralFieldOrder() throws Exception { @@ -171,6 +971,96 @@ class AgentSnapshotServiceImplTest { assertEquals(List.of(AgentSnapshotField.TAG_NAMES.getFieldName()), changedFields); } + @Test + @SuppressWarnings("unchecked") + void blankSummaryMemoryValuesAreNotSnapshotChanges() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setSummaryMemory(null); + AgentSnapshotDataDTO next = new AgentSnapshotDataDTO(); + next.setSummaryMemory(""); + + List changedFields = (List) method.invoke(service, current, next); + + assertFalse(changedFields.contains(AgentSnapshotField.SUMMARY_MEMORY.getFieldName())); + assertTrue(changedFields.isEmpty()); + } + + @Test + @SuppressWarnings("unchecked") + void nullTtsAdvancedValuesDifferFromExplicitZero() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setTtsVolume(null); + current.setTtsRate(null); + current.setTtsPitch(null); + AgentSnapshotDataDTO next = new AgentSnapshotDataDTO(); + next.setTtsVolume(0); + next.setTtsRate(0); + next.setTtsPitch(0); + + List changedFields = (List) method.invoke(service, current, next); + + assertEquals(List.of( + AgentSnapshotField.TTS_VOLUME.getFieldName(), + AgentSnapshotField.TTS_RATE.getFieldName(), + AgentSnapshotField.TTS_PITCH.getFieldName()), changedFields); + } + + @Test + @SuppressWarnings("unchecked") + void redactedSecretsDoNotCreateFalseSnapshotChangeButNonSensitiveValuesDo() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO stored = buildSnapshot( + "__SNAPSHOT_SECRET_REDACTED__", "__SNAPSHOT_SECRET_REDACTED__", "same-value"); + AgentSnapshotDataDTO current = buildSnapshot("current-key", "current-token", "same-value"); + + List changedFields = (List) method.invoke(service, stored, current); + + assertTrue(changedFields.isEmpty()); + + current.getFunctions().get(0).getParamInfo().put("description", "changed-value"); + changedFields = (List) method.invoke(service, stored, current); + + assertEquals(List.of(AgentSnapshotField.FUNCTIONS.getFieldName()), changedFields); + } + + @Test + @SuppressWarnings("unchecked") + void explicitTtsLanguageIsStoredAsSnapshotChangeEvenWhenItMatchesVoiceDefault() throws Exception { + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, null, null, null, null, null, null, + null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("getChangedFields", + AgentSnapshotDataDTO.class, AgentSnapshotDataDTO.class); + method.setAccessible(true); + + AgentSnapshotDataDTO current = new AgentSnapshotDataDTO(); + current.setTtsVoiceId("voice-id"); + current.setTtsLanguage(null); + AgentSnapshotDataDTO next = new AgentSnapshotDataDTO(); + next.setTtsVoiceId("voice-id"); + next.setTtsLanguage("普通话"); + + List changedFields = (List) method.invoke(service, current, next); + + assertEquals(List.of(AgentSnapshotField.TTS_LANGUAGE.getFieldName()), changedFields); + } + @Test void tagOnlySavePathCreatesSnapshot() throws Exception { String controller = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/controller/AgentController.java")); @@ -216,6 +1106,76 @@ class AgentSnapshotServiceImplTest { inOrder.verify(snapshotService).createSnapshot(agentId, "config"); } + @Test + void subsequentAgentSaveCapturesUnversionedCurrentStateBeforePersistingNewState() { + AgentDao agentDao = mock(AgentDao.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotService snapshotService = mock(AgentSnapshotService.class); + AgentServiceImpl service = new AgentServiceImpl(agentDao, null, null, null, null, null, null, null, + null, null, contextProviderService, null, correctWordFileService, snapshotService); + ReflectionTestUtils.setField(service, "baseDao", agentDao); + + String agentId = "agent-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentAgent = new AgentInfoVO(); + currentAgent.setId(agentId); + currentAgent.setAgentName("old-name"); + AgentUpdateDTO update = new AgentUpdateDTO(); + update.setAgentName("new-name"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentAgent); + when(snapshotService.getCurrentVersionNo(agentId)).thenReturn(4); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(agentDao.updateById(any())).thenReturn(1); + + service.updateAgentById(agentId, update); + + InOrder inOrder = inOrder(agentDao, snapshotService); + inOrder.verify(snapshotService).createSnapshot(agentId, "current"); + inOrder.verify(agentDao).updateById(argThat(agent -> "new-name".equals(agent.getAgentName()))); + inOrder.verify(snapshotService).createSnapshot(agentId, "config"); + } + + @Test + void createAgentPersistsDisplayDefaultsBeforeInitialSnapshot() { + AgentDao agentDao = mock(AgentDao.class); + TimbreService timbreService = mock(TimbreService.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentTemplateService templateService = mock(AgentTemplateService.class); + ModelProviderService providerService = mock(ModelProviderService.class); + AgentSnapshotService snapshotService = mock(AgentSnapshotService.class); + AgentServiceImpl service = new AgentServiceImpl(agentDao, null, timbreService, null, null, null, + pluginMappingService, null, templateService, providerService, null, null, null, snapshotService); + ReflectionTestUtils.setField(service, "baseDao", agentDao); + + AgentTemplateEntity template = new AgentTemplateEntity(); + template.setTtsModelId("TTS_EdgeTTS"); + template.setTtsVoiceId("TTS_EdgeTTS0001"); + template.setMemModelId("Memory_nomem"); + template.setSummaryMemory(null); + when(templateService.getDefaultTemplate()).thenReturn(template); + + when(timbreService.getDefaultLanguageById("TTS_EdgeTTS0001")).thenReturn("普通话"); + when(agentDao.insert(any())).thenReturn(1); + + AgentCreateDTO dto = new AgentCreateDTO(); + dto.setAgentName("test123"); + + String agentId = service.createAgent(dto); + + InOrder inOrder = inOrder(agentDao, pluginMappingService, snapshotService); + inOrder.verify(agentDao).insert(argThat(agent -> "test123".equals(agent.getAgentName()) + && "普通话".equals(agent.getTtsLanguage()) + && "".equals(agent.getSummaryMemory()) + && Integer.valueOf(0).equals(agent.getChatHistoryConf()))); + inOrder.verify(pluginMappingService).saveBatch(any()); + inOrder.verify(snapshotService).createSnapshot(agentId, "initial"); + } + @Test void agentSnapshotFieldAppliesRestorableAgentFields() { AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); @@ -237,7 +1197,8 @@ class AgentSnapshotServiceImplTest { @Test void restoreSnapshotRollsBackForAnyException() throws Exception { - Method method = AgentSnapshotServiceImpl.class.getMethod("restoreSnapshot", String.class, String.class); + Method method = AgentSnapshotServiceImpl.class.getMethod("restoreSnapshot", String.class, String.class, + String.class); Transactional transactional = method.getAnnotation(Transactional.class); @@ -245,6 +1206,338 @@ class AgentSnapshotServiceImplTest { assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); } + @Test + void restoreSnapshotRejectsStalePreviewTokenBeforeAnyMutation() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, null, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)) + .thenReturn(snapshotEntity(targetId, agentId, 2, snapshotData("target", "target-summary"))); + when(agentDao.selectAgentInfoById(agentId)) + .thenReturn(snapshotAgentInfo(agentId, 7L, "changed-after-preview", "live-summary")); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + RenException error = assertThrows(RenException.class, + () -> service.restoreSnapshot(agentId, targetId, "stale-token")); + + assertTrue(error.getMessage().contains("重新打开恢复预览")); + verify(snapshotDao, never()).selectLatestSnapshot(agentId); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(agentDao, never()).updateSnapshotFields(any()); + verify(pluginMappingService, never()).deleteByAgentId(any()); + } + + @Test + void restoreSnapshotBacksUpUnversionedMemoryThenStoresAppliedStateAsLatest() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentTagRelationDao tagRelationDao = mock(AgentTagRelationDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + AgentChatHistoryService chatHistoryService = mock(AgentChatHistoryService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, tagRelationDao, + pluginMappingService, contextProviderService, tagService, chatHistoryService, null, + correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "current-name", "live-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "target-name", "target-summary"); + AgentSnapshotDataDTO currentData = snapshotData("current-name", "live-summary"); + AgentSnapshotDataDTO latestData = snapshotData("current-name", "snapshot-summary"); + AgentSnapshotDataDTO targetData = snapshotData("target-name", "target-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 2, targetData); + AgentSnapshotEntity latest = snapshotEntity("latest-id", agentId, 5, latestData); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)).thenReturn(latest); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao, org.mockito.Mockito.times(2)).insertWithNextVersion(snapshotCaptor.capture()); + List inserted = snapshotCaptor.getAllValues(); + AgentSnapshotEntity backup = inserted.get(0); + AgentSnapshotEntity restored = inserted.get(1); + + assertEquals("current", backup.getSource()); + assertNull(backup.getRestoreFromSnapshotId()); + assertNull(backup.getRestoreFromVersionNo()); + assertEquals(List.of(AgentSnapshotField.SUMMARY_MEMORY.getFieldName()), + JsonUtils.parseObject(backup.getChangedFields(), new TypeReference>() { + })); + assertEquals("live-summary", + JsonUtils.parseObject(backup.getSnapshotData(), AgentSnapshotDataDTO.class).getSummaryMemory()); + + assertEquals("restore", restored.getSource()); + assertEquals(targetId, restored.getRestoreFromSnapshotId()); + assertEquals(2, restored.getRestoreFromVersionNo()); + assertEquals("target-name", + JsonUtils.parseObject(restored.getSnapshotData(), AgentSnapshotDataDTO.class).getAgentName()); + assertEquals("target-summary", + JsonUtils.parseObject(restored.getSnapshotData(), AgentSnapshotDataDTO.class).getSummaryMemory()); + + InOrder order = inOrder(snapshotDao, agentDao); + order.verify(snapshotDao).insertWithNextVersion(backup); + order.verify(agentDao).updateSnapshotFields(lockedAgent); + order.verify(snapshotDao).insertWithNextVersion(restored); + verify(snapshotDao).deleteOlderThanKeepLimit(agentId, 100); + } + + @Test + void restoreSnapshotSkipsBackupWhenLatestAlreadyRepresentsCurrentState() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "current-name", "current-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "target-name", "target-summary"); + AgentSnapshotDataDTO currentData = snapshotData("current-name", "current-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 2, + snapshotData("target-name", "target-summary")); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)).thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao).insertWithNextVersion(snapshotCaptor.capture()); + assertEquals("restore", snapshotCaptor.getValue().getSource()); + assertEquals(targetId, snapshotCaptor.getValue().getRestoreFromSnapshotId()); + verify(snapshotDao).deleteOlderThanKeepLimit(agentId, 100); + } + + @Test + void restoreSnapshotContinuesWhenRelationOnlyChangeReportsZeroUpdatedAgentRows() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentPluginMapping currentMapping = new AgentPluginMapping(); + currentMapping.setPluginId("plugin"); + currentMapping.setParamInfo("{\"description\":\"current\"}"); + currentInfo.setFunctions(List.of(currentMapping)); + AgentPluginMapping restoredMapping = new AgentPluginMapping(); + restoredMapping.setPluginId("plugin"); + restoredMapping.setParamInfo("{\"description\":\"target\"}"); + restoredInfo.setFunctions(List.of(restoredMapping)); + + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + currentData.setFunctions(List.of(functionInfo("plugin", Map.of("description", "current")))); + AgentSnapshotDataDTO targetData = snapshotData("same-name", "same-summary"); + targetData.setFunctions(List.of(functionInfo("plugin", Map.of("description", "target")))); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(snapshotEntity(targetId, agentId, 2, targetData)); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)) + .thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(0); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(pluginMappingService).deleteByAgentId(agentId); + verify(pluginMappingService).saveBatch(argThat(mappings -> { + AgentPluginMapping mapping = mappings.size() == 1 ? mappings.iterator().next() : null; + return mapping != null + && "plugin".equals(mapping.getPluginId()) + && mapping.getParamInfo().contains("target"); + })); + verify(snapshotDao).insertWithNextVersion(argThat(snapshot -> "restore".equals(snapshot.getSource()))); + } + + @Test + void restoreSnapshotExplicitlyPersistsNullableFieldsAsNull() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + lockedAgent.setUserId(7L); + lockedAgent.setTtsLanguage("普通话"); + lockedAgent.setTtsVolume(0); + lockedAgent.setTtsRate(0); + lockedAgent.setTtsPitch(0); + lockedAgent.setSlmModelId("slm-id"); + lockedAgent.setVllmModelId("vllm-id"); + lockedAgent.setIntentModelId("intent-id"); + lockedAgent.setSystemPrompt("old prompt"); + + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + currentInfo.setTtsLanguage("普通话"); + currentInfo.setTtsVolume(0); + currentInfo.setTtsRate(0); + currentInfo.setTtsPitch(0); + currentInfo.setSlmModelId("slm-id"); + currentInfo.setVllmModelId("vllm-id"); + currentInfo.setIntentModelId("intent-id"); + currentInfo.setSystemPrompt("old prompt"); + AgentInfoVO restoredInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + currentData.setTtsLanguage("普通话"); + currentData.setTtsVolume(0); + currentData.setTtsRate(0); + currentData.setTtsPitch(0); + currentData.setSlmModelId("slm-id"); + currentData.setVllmModelId("vllm-id"); + currentData.setIntentModelId("intent-id"); + currentData.setSystemPrompt("old prompt"); + AgentSnapshotDataDTO targetData = snapshotData("same-name", "same-summary"); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(snapshotEntity(targetId, agentId, 2, targetData)); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo, restoredInfo); + when(snapshotDao.selectLatestSnapshot(agentId)) + .thenReturn(snapshotEntity("latest-id", agentId, 5, currentData)); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + when(agentDao.updateSnapshotFields(any())).thenReturn(1); + when(snapshotDao.insertWithNextVersion(any())).thenReturn(1); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(agentDao).updateSnapshotFields(argThat(agent -> agent.getTtsLanguage() == null + && agent.getTtsVolume() == null + && agent.getTtsRate() == null + && agent.getTtsPitch() == null + && agent.getSlmModelId() == null + && agent.getVllmModelId() == null + && agent.getIntentModelId() == null + && agent.getSystemPrompt() == null + && Long.valueOf(7L).equals(agent.getUserId()))); + ArgumentCaptor snapshotCaptor = ArgumentCaptor.forClass(AgentSnapshotEntity.class); + verify(snapshotDao).insertWithNextVersion(snapshotCaptor.capture()); + AgentSnapshotDataDTO latestData = JsonUtils.parseObject(snapshotCaptor.getValue().getSnapshotData(), + AgentSnapshotDataDTO.class); + assertEquals("restore", snapshotCaptor.getValue().getSource()); + assertNull(latestData.getTtsLanguage()); + assertNull(latestData.getTtsVolume()); + assertNull(latestData.getTtsRate()); + assertNull(latestData.getTtsPitch()); + assertNull(latestData.getSlmModelId()); + assertNull(latestData.getVllmModelId()); + assertNull(latestData.getIntentModelId()); + assertNull(latestData.getSystemPrompt()); + verify(agentDao, org.mockito.Mockito.times(2)).selectAgentInfoById(agentId); + } + + @Test + void restoreSnapshotDoesNotMutateOrWriteHistoryWhenTargetMatchesCurrentState() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentTagDao tagDao = mock(AgentTagDao.class); + AgentPluginMappingService pluginMappingService = mock(AgentPluginMappingService.class); + AgentContextProviderService contextProviderService = mock(AgentContextProviderService.class); + AgentTagService tagService = mock(AgentTagService.class); + CorrectWordFileService correctWordFileService = mock(CorrectWordFileService.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, tagDao, null, + pluginMappingService, contextProviderService, tagService, null, null, correctWordFileService); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String targetId = "target-id"; + AgentEntity lockedAgent = new AgentEntity(); + lockedAgent.setId(agentId); + AgentInfoVO currentInfo = snapshotAgentInfo(agentId, 7L, "same-name", "same-summary"); + AgentSnapshotDataDTO currentData = snapshotData("same-name", "same-summary"); + AgentSnapshotEntity target = snapshotEntity(targetId, agentId, 3, currentData); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(lockedAgent); + when(snapshotDao.selectById(targetId)).thenReturn(target); + when(agentDao.selectAgentInfoById(agentId)).thenReturn(currentInfo); + when(contextProviderService.getByAgentId(agentId)).thenReturn(null); + when(correctWordFileService.getAgentCorrectWordFileIds(agentId)).thenReturn(List.of()); + when(tagDao.selectByAgentId(agentId)).thenReturn(List.of()); + + service.restoreSnapshot(agentId, targetId, currentStateToken(service, currentData)); + + verify(snapshotDao, never()).selectLatestSnapshot(agentId); + verify(snapshotDao, never()).insertWithNextVersion(any()); + verify(snapshotDao, never()).deleteOlderThanKeepLimit(any(), anyInt()); + verify(agentDao, never()).updateSnapshotFields(any()); + verify(pluginMappingService, never()).deleteByAgentId(any()); + verify(contextProviderService, never()).saveOrUpdateByAgentId(any()); + verify(correctWordFileService, never()).saveAgentCorrectWords(any(), any()); + verify(tagService, never()).saveAgentTags(any(), any(), any()); + } + @Test void deleteSnapshotRollsBackForAnyException() throws Exception { Method method = AgentSnapshotServiceImpl.class.getMethod("deleteSnapshot", String.class, String.class); @@ -255,6 +1548,37 @@ class AgentSnapshotServiceImplTest { assertTrue(List.of(transactional.rollbackFor()).contains(Exception.class)); } + @Test + void deleteSnapshotLocksAgentBeforeReadingAndDeletingSnapshot() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentDao agentDao = mock(AgentDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, agentDao, null, null, null, null, + null, null, null, null); + ReflectionTestUtils.setField(service, "baseDao", snapshotDao); + + String agentId = "agent-id"; + String snapshotId = "snapshot-id"; + AgentEntity agent = new AgentEntity(); + agent.setId(agentId); + AgentSnapshotEntity snapshot = new AgentSnapshotEntity(); + snapshot.setId(snapshotId); + snapshot.setAgentId(agentId); + snapshot.setVersionNo(2); + + when(agentDao.selectByIdForUpdate(agentId)).thenReturn(agent); + when(snapshotDao.selectById(snapshotId)).thenReturn(snapshot); + when(snapshotDao.selectMaxVersionNo(agentId)).thenReturn(3); + when(snapshotDao.deleteById(snapshotId)).thenReturn(1); + + service.deleteSnapshot(agentId, snapshotId); + + InOrder order = inOrder(agentDao, snapshotDao); + order.verify(agentDao).selectByIdForUpdate(agentId); + order.verify(snapshotDao).selectById(snapshotId); + order.verify(snapshotDao).selectMaxVersionNo(agentId); + order.verify(snapshotDao).deleteById(snapshotId); + } + @Test void snapshotInsertAllocatesVersionInSingleSqlStatement() throws Exception { String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); @@ -268,6 +1592,36 @@ class AgentSnapshotServiceImplTest { assertFalse(xml.contains("ai_agent_snapshot_sequence")); } + @Test + void restoreUpdateSqlWritesNullableSnapshotFieldsWithoutOwnershipColumns() throws Exception { + String xml = Files.readString(Path.of("src/main/resources/mapper/agent/AgentDao.xml")); + int updateStart = xml.indexOf(""); + int updateEnd = xml.indexOf("", updateStart); + assertTrue(updateStart >= 0); + assertTrue(updateEnd > updateStart); + String updateSql = normalizeWhitespace(xml.substring(updateStart, updateEnd)); + + for (AgentSnapshotField field : AgentSnapshotField.values()) { + if (!field.isRestorableAgentField()) { + continue; + } + String column = field.getFieldName().replaceAll("([a-z0-9])([A-Z])", "$1_$2") + .toLowerCase(Locale.ROOT); + assertTrue(updateSql.contains(column + " = #{agent." + field.getFieldName() + ","), + () -> "Missing explicit restore assignment for " + field.getFieldName()); + } + assertTrue(updateSql.contains("tts_language = #{agent.ttsLanguage,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("tts_volume = #{agent.ttsVolume,jdbcType=INTEGER}")); + assertTrue(updateSql.contains("slm_model_id = #{agent.slmModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("vllm_model_id = #{agent.vllmModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("intent_model_id = #{agent.intentModelId,jdbcType=VARCHAR}")); + assertTrue(updateSql.contains("system_prompt = #{agent.systemPrompt,jdbcType=LONGVARCHAR}")); + assertTrue(updateSql.contains("summary_memory = #{agent.summaryMemory,jdbcType=LONGVARCHAR}")); + assertFalse(updateSql.contains("user_id =")); + assertFalse(updateSql.contains("creator =")); + assertFalse(updateSql.contains("created_at =")); + } + @Test void snapshotMigrationIsMergedIntoSingleChangeLog() throws Exception { String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607071530.sql")); @@ -283,11 +1637,30 @@ class AgentSnapshotServiceImplTest { } @Test - void selectNextSnapshotLoadsRestoreTraceColumns() throws Exception { + void legacySnapshotRedactionHasItsOwnResumableSchemaVersionChangeLog() throws Exception { + String sql = Files.readString(Path.of("src/main/resources/db/changelog/202607101200.sql")); + String master = Files.readString(Path.of("src/main/resources/db/changelog/db.changelog-master.yaml")); + String mapper = Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml")); + + assertTrue(sql.contains("redaction_version")); + assertTrue(sql.contains("idx_snapshot_redaction_version_id")); + assertTrue(sql.contains("-- rollback")); + assertTrue(master.contains("202607101200.sql")); + assertTrue(mapper.contains("selectLegacyRedactionBatch")); + assertTrue(mapper.contains("redaction_version < #{targetRedactionVersion}")); + assertTrue(mapper.contains("")); + } + + @Test + void selectNextSnapshotLoadsRestoreTraceColumnsWithoutPreviousVersionQuery() throws Exception { String xml = normalizeWhitespace(Files.readString(Path.of("src/main/resources/mapper/agent/AgentSnapshotDao.xml"))); + String dao = Files.readString(Path.of("src/main/java/xiaozhi/modules/agent/dao/AgentSnapshotDao.java")); assertTrue(xml.contains("restore_from_snapshot_id AS restoreFromSnapshotId")); assertTrue(xml.contains("restore_from_version_no AS restoreFromVersionNo")); + assertTrue(xml.contains("version_no > #{versionNo}")); + assertFalse(xml.contains("selectPreviousSnapshot")); + assertFalse(dao.contains("selectPreviousSnapshot")); } @Test @@ -331,6 +1704,52 @@ class AgentSnapshotServiceImplTest { assertEquals(1, vo.getRestoreFromVersionNo()); } + @Test + void toVOKeepsStoredEventFieldsStableAcrossDeletedVersionGapAndMetadataChanges() throws Exception { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, null, + null, null, null); + Method method = AgentSnapshotServiceImpl.class.getDeclaredMethod("toVO", + AgentSnapshotEntity.class, boolean.class); + method.setAccessible(true); + + AgentSnapshotEntity current = new AgentSnapshotEntity(); + current.setId("snapshot-id"); + current.setAgentId("agent-id"); + current.setVersionNo(3); + current.setChangedFields(JsonUtils.toJsonString(List.of("ttsLanguage", "ttsVolume"))); + + AgentSnapshotVO vo = (AgentSnapshotVO) method.invoke(service, current, false); + + assertEquals(List.of("ttsLanguage", "ttsVolume"), vo.getChangedFields()); + verifyNoInteractions(snapshotDao); + } + + @Test + void pageUsesPersistedChangedFieldsWithoutPerRecordQueries() { + AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); + AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(snapshotDao, null, null, null, null, null, null, + null, null, null); + + AgentSnapshotEntity versionFive = new AgentSnapshotEntity(); + versionFive.setVersionNo(5); + versionFive.setChangedFields(JsonUtils.toJsonString(List.of("agentName"))); + AgentSnapshotEntity versionThree = new AgentSnapshotEntity(); + versionThree.setVersionNo(3); + versionThree.setChangedFields(JsonUtils.toJsonString(List.of("ttsLanguage"))); + Page result = new Page<>(1, 10); + result.setRecords(List.of(versionFive, versionThree)); + result.setTotal(2); + when(snapshotDao.selectPage(any(), any())).thenReturn(result); + + List records = service.page("agent-id", new AgentSnapshotPageDTO()).getList(); + + assertEquals(List.of("agentName"), records.get(0).getChangedFields()); + assertEquals(List.of("ttsLanguage"), records.get(1).getChangedFields()); + verify(snapshotDao).selectPage(any(), any()); + verifyNoMoreInteractions(snapshotDao); + } + @Test void pageDoesNotCreateInitialSnapshotWhenAgentHasNoHistory() { AgentSnapshotDao snapshotDao = mock(AgentSnapshotDao.class); @@ -392,7 +1811,7 @@ class AgentSnapshotServiceImplTest { } @Test - void restoreTagDoesNotReviveSoftDeletedSnapshotTagId() throws Exception { + void restoreTagRejectsSoftDeletedSnapshotTagWithoutMutatingGlobalTag() throws Exception { AgentTagDao tagDao = mock(AgentTagDao.class); AgentSnapshotServiceImpl service = new AgentSnapshotServiceImpl(null, null, tagDao, null, null, null, null, null, null, null); @@ -405,24 +1824,104 @@ class AgentSnapshotServiceImplTest { deletedTag.setTagName("archived"); deletedTag.setDeleted(1); when(tagDao.selectById("deleted-tag-id")).thenReturn(deletedTag); - when(tagDao.selectOne(any())).thenReturn(null); - AtomicReference insertedTag = new AtomicReference<>(); - when(tagDao.insert(any())).thenAnswer(invocation -> { - insertedTag.set(invocation.getArgument(0)); - return 1; - }); AgentSnapshotTagDTO snapshotTag = new AgentSnapshotTagDTO(); snapshotTag.setId("deleted-tag-id"); snapshotTag.setTagName("archived"); - String restoredTagId = (String) method.invoke(service, snapshotTag, new Date()); + InvocationTargetException error = assertThrows(InvocationTargetException.class, + () -> method.invoke(service, snapshotTag, new Date())); + assertTrue(error.getCause() instanceof RenException); + assertEquals("快照引用的标签已被删除,无法恢复,请先重新创建或选择标签", error.getCause().getMessage()); verify(tagDao, never()).updateById(any()); - assertNotEquals("deleted-tag-id", restoredTagId); - assertNotNull(insertedTag.get()); - assertEquals(restoredTagId, insertedTag.get().getId()); - assertEquals(0, insertedTag.get().getDeleted()); + verify(tagDao, never()).insert(any()); + } + + private AgentInfoVO snapshotAgentInfo(String agentId, Long userId, String agentName, String summaryMemory) { + AgentInfoVO info = new AgentInfoVO(); + info.setId(agentId); + info.setUserId(userId); + info.setAgentName(agentName); + info.setSummaryMemory(summaryMemory); + info.setChatHistoryConf(0); + return info; + } + + private AgentSnapshotDataDTO snapshotData(String agentName, String summaryMemory) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setAgentName(agentName); + data.setSummaryMemory(summaryMemory); + data.setChatHistoryConf(0); + return data; + } + + private AgentSnapshotDataDTO buildExtendedSensitiveSnapshot() { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId("rag"); + Map params = new LinkedHashMap<>(); + params.put("api_key", "secret-api-key"); + params.put("Cookie", "secret-cookie"); + params.put("Set-Cookie", "secret-set-cookie"); + params.put("Proxy-Authorization", "secret-proxy-authorization"); + params.put("X-Session-ID", "secret-session-id"); + params.put("X-Auth", "secret-auth-header"); + params.put("slackWebhookUrl", + "https://hooks.slack.com/services/T111/B222/secret-slack-path"); + params.put("max_tokens", 32); + function.setParamInfo(params); + + ContextProviderDTO provider = new ContextProviderDTO(); + provider.setUrl("https://discord.com/api/webhooks/123456/secret-discord-path"); + Map headers = new LinkedHashMap<>(); + headers.put("Authorization", "secret-authorization"); + headers.put("Cookie", "secret-context-cookie"); + headers.put("PHPSESSID", "secret-php-session"); + headers.put("X-Session", "secret-x-session"); + headers.put("Accept", "application/json"); + provider.setHeaders(headers); + + data.setFunctions(List.of(function)); + data.setContextProviders(List.of(provider)); + return data; + } + + private AgentUpdateDTO.FunctionInfo functionInfo(String pluginId, Map params) { + AgentUpdateDTO.FunctionInfo function = new AgentUpdateDTO.FunctionInfo(); + function.setPluginId(pluginId); + function.setParamInfo(params); + return function; + } + + private void assertExtendedSecretsAbsent(String json) { + for (String secret : List.of( + "secret-api-key", + "secret-cookie", + "secret-set-cookie", + "secret-proxy-authorization", + "secret-session-id", + "secret-auth-header", + "secret-slack-path", + "secret-discord-path", + "secret-authorization", + "secret-context-cookie", + "secret-php-session", + "secret-x-session")) { + assertFalse(json.contains(secret), () -> "Sensitive value leaked: " + secret); + } + } + + private AgentSnapshotEntity snapshotEntity(String snapshotId, String agentId, Integer versionNo, + AgentSnapshotDataDTO data) { + AgentSnapshotEntity entity = new AgentSnapshotEntity(); + entity.setId(snapshotId); + entity.setAgentId(agentId); + entity.setUserId(7L); + entity.setVersionNo(versionNo); + entity.setSnapshotData(JsonUtils.toJsonString(data)); + entity.setChangedFields("[]"); + return entity; } private AgentSnapshotDataDTO buildSnapshot(String apiKey, String authToken, String description) { @@ -444,6 +1943,18 @@ class AgentSnapshotServiceImplTest { return data; } + private AgentSnapshotDataDTO nestedWebhookSnapshot(String secret) { + AgentSnapshotDataDTO data = new AgentSnapshotDataDTO(); + data.setFunctions(List.of(functionInfo("plugin", Map.of( + "webhookConfig", Map.of( + "value", "https://capability.example.com/public/" + secret))))); + return data; + } + + private String currentStateToken(AgentSnapshotServiceImpl service, AgentSnapshotDataDTO data) { + return ReflectionTestUtils.invokeMethod(service, "buildCurrentStateToken", data); + } + private String normalizeWhitespace(String value) { return value.replaceAll("\\s+", " ").trim(); } diff --git a/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java b/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java new file mode 100644 index 00000000..3e5eaa81 --- /dev/null +++ b/main/manager-api/src/test/java/xiaozhi/modules/timbre/service/impl/TimbreServiceImplTest.java @@ -0,0 +1,57 @@ +package xiaozhi.modules.timbre.service.impl; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import org.junit.jupiter.api.Test; + +import xiaozhi.common.redis.RedisUtils; +import xiaozhi.modules.timbre.dao.TimbreDao; +import xiaozhi.modules.timbre.entity.TimbreEntity; +import xiaozhi.modules.voiceclone.dao.VoiceCloneDao; +import xiaozhi.modules.voiceclone.entity.VoiceCloneEntity; + +class TimbreServiceImplTest { + + @Test + void defaultLanguageUsesFirstValidRegularTimbreLanguageWithoutCloneQuery() { + TimbreDao timbreDao = mock(TimbreDao.class); + VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class); + TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class)); + TimbreEntity timbre = new TimbreEntity(); + timbre.setLanguages(",, ; 普通话;粤语"); + when(timbreDao.selectById("voice-id")).thenReturn(timbre); + + assertEquals("普通话", service.getDefaultLanguageById("voice-id")); + + verify(voiceCloneDao, never()).selectById("voice-id"); + } + + @Test + void defaultLanguageFallsBackToCloneTimbre() { + TimbreDao timbreDao = mock(TimbreDao.class); + VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class); + TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class)); + VoiceCloneEntity voiceClone = new VoiceCloneEntity(); + voiceClone.setLanguages("、, English,中文"); + when(voiceCloneDao.selectById("clone-id")).thenReturn(voiceClone); + + assertEquals("English", service.getDefaultLanguageById("clone-id")); + } + + @Test + void delimiterOnlyLanguageConfigurationReturnsNull() { + TimbreDao timbreDao = mock(TimbreDao.class); + VoiceCloneDao voiceCloneDao = mock(VoiceCloneDao.class); + TimbreServiceImpl service = new TimbreServiceImpl(timbreDao, voiceCloneDao, mock(RedisUtils.class)); + TimbreEntity timbre = new TimbreEntity(); + timbre.setLanguages(",,、;;;,,"); + when(timbreDao.selectById("voice-id")).thenReturn(timbre); + + assertNull(service.getDefaultLanguageById("voice-id")); + } +} diff --git a/main/manager-mobile/package.json b/main/manager-mobile/package.json index 9c4ed55f..80aaee8e 100644 --- a/main/manager-mobile/package.json +++ b/main/manager-mobile/package.json @@ -69,6 +69,7 @@ "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", "build:quickapp-webview-union": "uni build -p quickapp-webview-union", "type-check": "vue-tsc --noEmit", + "test:snapshot": "node --test src/pages/agent/components/agentSnapshotUtils.test.mjs src/pages/agent/components/agentSnapshotContracts.test.mjs", "openapi-ts-request": "openapi-ts", "prepare": "git init && husky", "lint": "eslint", diff --git a/main/manager-mobile/src/App.vue b/main/manager-mobile/src/App.vue index d31d32d7..a62d43fc 100644 --- a/main/manager-mobile/src/App.vue +++ b/main/manager-mobile/src/App.vue @@ -1,9 +1,9 @@ + + + + diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs new file mode 100644 index 00000000..a217a99a --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotContracts.test.mjs @@ -0,0 +1,103 @@ +/* eslint-disable test/no-import-node-test -- zero-dependency source contract gate */ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const panelSource = await readFile(new URL('./AgentSnapshotPanel.vue', import.meta.url), 'utf8') +const editSource = await readFile(new URL('../edit.vue', import.meta.url), 'utf8') + +function sourceBetween(source, startMarker, endMarker) { + const start = source.indexOf(startMarker) + const end = source.indexOf(endMarker, start + startMarker.length) + assert.notEqual(start, -1, `missing start marker: ${startMarker}`) + assert.notEqual(end, -1, `missing end marker: ${endMarker}`) + return source.slice(start, end) +} + +test('restore enters busy state before confirmations and requires a final destructive confirmation', () => { + const restore = sourceBetween( + panelSource, + 'async function confirmRestoreSnapshot()', + 'async function requestRestoreConfirmation', + ) + const busyIndex = restore.indexOf('restoring.value = true') + const unsavedConfirmIndex = restore.indexOf('title: t(\'agentSnapshot.unsavedChangesTitle\')') + const destructiveConfirmIndex = restore.indexOf('msg: t(\'agentSnapshot.restoreChatHistoryDestructiveWarning\')') + const postIndex = restore.indexOf('await restoreAgentSnapshot(') + + assert.ok(busyIndex >= 0 && busyIndex < unsavedConfirmIndex) + assert.ok(destructiveConfirmIndex > unsavedConfirmIndex && destructiveConfirmIndex < postIndex) + assert.match(restore, /if \(context\.willClearChatHistory\)/) + assert.match(restore.slice(destructiveConfirmIndex, postIndex), /isActiveRestoreAction\(context\)/) + const finalMutationGuardIndex = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex) + const postBoundaryIndex = restore.indexOf('restorePostActionSequence = context.actionSequence') + assert.ok(finalMutationGuardIndex > destructiveConfirmIndex) + assert.ok(finalMutationGuardIndex < postBoundaryIndex && postBoundaryIndex < postIndex) +}) +test('restore guards popup exits and stale action completion', () => { + assert.match(panelSource, /if \(!value && restoring\.value\) \{\s*return\s*\}/) + assert.equal((panelSource.match(/:close-on-click-modal="!restoring"/g) || []).length, 2) + assert.match(panelSource, /function closeDetail\(force = false\) \{\s*if \(restoring\.value && !force\)/) + assert.match(panelSource, /context\.agentId === props\.agentId/) + assert.match(panelSource, /context\.detailRequestSequence === detailRequestSequence/) + assert.match(panelSource, /if \(!isActiveRestoreAction\(context\)\) \{\s*return\s*\}\s*toast\.success/) + assert.match(panelSource, /mutationBusy\?: boolean/) + assert.match(panelSource, /&& !props\.mutationBusy[\s\S]*currentDetail\.value\?\.mode === 'restore'/) + assert.match(panelSource, /&& \(!props\.mutationBusy \|\| restorePostActionSequence === context\.actionSequence\)/) + assert.match(panelSource, /if \(restorePostInFlight\) \{\s*return\s*\}/) +}) + +test('post-restore detail and tag reload fail closed before save', () => { + const save = sourceBetween(editSource, 'async function saveAgent()', 'function loadPluginFunctions()') + const reload = sourceBetween( + editSource, + 'async function reloadAgentAfterSnapshotRestore', + 'function isActiveSnapshotReload', + ) + + assert.ok(save.indexOf('if (snapshotReloadBlocked.value)') < save.indexOf('await updateAgent(')) + assert.match(reload, /snapshotReloadBlocked\.value = true/) + assert.match(reload, /Promise\.all\(\[\s*getAgentDetail\(targetAgentId\),\s*getAgentTags\(targetAgentId\)/) + assert.ok(reload.indexOf('const [detail, tags] = await Promise.all') < reload.indexOf('applyPersistedAgentDetail(')) + assert.match(reload, /snapshotReloadFailed\.value = true/) + assert.match(editSource, /:disabled="saving \|\| ttsOptionsLoading \|\| snapshotReloadBlocked"/) + assert.match(editSource, /v-if="snapshotReloadFailed"[\s\S]*@click="retrySnapshotReload"/) +}) + +test('parent mutations guard history opening and propagate into every restore gate', () => { + const opener = sourceBetween(editSource, 'function openSnapshotPanel()', 'function isSameStringList') + const restore = sourceBetween( + panelSource, + 'async function confirmRestoreSnapshot()', + 'async function requestRestoreConfirmation', + ) + + assert.match(opener, /if \(saving\.value \|\| snapshotReloadBlocked\.value\) \{[\s\S]*return/) + assert.match(editSource, /:disabled="saving \|\| snapshotReloadBlocked"[\s\S]*@click="openSnapshotPanel"/) + assert.match(editSource, /:mutation-busy="saving \|\| snapshotReloadBlocked"/) + assert.match(restore, /if \(props\.mutationBusy\) \{[\s\S]*return/) + const postIndex = restore.indexOf('await restoreAgentSnapshot(') + const finalGate = restore.lastIndexOf('if (!isActiveRestoreAction(context))', postIndex) + assert.ok(finalGate >= 0 && finalGate < postIndex) +}) + +test('the latest snapshot hides restore and delete actions', () => { + const restoreGate = sourceBetween( + panelSource, + 'function canRestoreSnapshot(row: SnapshotRow)', + 'function canDeleteSnapshot(row: SnapshotRow)', + ) + const deleteGate = sourceBetween( + panelSource, + 'function canDeleteSnapshot(row: SnapshotRow)', + 'function buildDetailItems', + ) + + assert.match(restoreGate, /!!row\?\.id && !row\.isLatestSnapshot/) + assert.match(deleteGate, /!!row\?\.id && !row\.isLatestSnapshot/) + assert.match(panelSource, /v-if="canRestoreSnapshot\(snapshot\)"/) + assert.match( + panelSource, + /async function previewRestoreSnapshot\(row: SnapshotRow\) \{\s*if \(!canRestoreSnapshot\(row\)\) \{\s*return/, + ) +}) diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs new file mode 100644 index 00000000..c94a4687 --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.mjs @@ -0,0 +1,314 @@ +export const SNAPSHOT_SECRET_REDACTED = '__SNAPSHOT_SECRET_REDACTED__' + +/** @param {unknown} voices */ +export function hasUsableTtsVoiceMetadata(voices) { + return Array.isArray(voices) && voices.length > 0 +} + +/** + * @param {unknown} currentMemModelId + * @param {unknown} targetMemModelId + */ +export function willRestorePermanentlyDeleteChatHistory(currentMemModelId, targetMemModelId) { + return currentMemModelId !== 'Memory_nomem' && targetMemModelId === 'Memory_nomem' +} + +/** + * @param {Array>} voices + * @param {string} language + */ +export function filterTtsVoicesByLanguage(voices, language) { + if (!language) { + return voices + } + return voices.filter((voice) => { + if (typeof voice.languages !== 'string' || !voice.languages.trim()) { + return false + } + return voice.languages + .split(/[、;;,,]/) + .map(item => item.trim()) + .filter(Boolean) + .includes(language) + }) +} + +/** @param {unknown} value */ +export function normalizeSnapshotTtsNumber(value) { + if (value === undefined || value === null || String(value).trim() === '') { + return null + } + if (typeof value === 'number') { + return Math.trunc(value) + } + const text = String(value).trim() + return /^[+-]?\d+$/.test(text) ? Number.parseInt(text, 10) : text +} + +/** + * Normalize object-key order without changing array order. Context providers + * are consumed sequentially by the runtime, so their list order is semantic. + * @param {unknown} value + */ +export function normalizeSnapshotOrderedList(value) { + if (!Array.isArray(value)) { + return [] + } + return value + .filter(item => item !== undefined && item !== null) + .map(normalizeDisplayObject) +} + +/** + * @param {unknown} value + * @param {string} redactedLabel + * @param {string} [parentKey] + * @returns {unknown} + */ +export function redactSnapshotDisplayValue(value, redactedLabel, parentKey = '') { + if (value === SNAPSHOT_SECRET_REDACTED || isSensitiveKey(parentKey)) { + return redactedLabel + } + if (Array.isArray(value)) { + return value.map(item => redactSnapshotDisplayValue(item, redactedLabel, parentKey)) + } + if (isPlainObject(value)) { + const entryNameKey = Object.keys(value).find((key) => { + return ['key', 'name'].includes(key.toLowerCase()) + && typeof value[key] === 'string' + && isSensitiveKey(value[key]) + }) + const entryValueKey = Object.keys(value).find(key => key.toLowerCase() === 'value') + return Object.keys(value).sort().reduce((result, key) => { + const semanticKey = resolveUrlSemanticKey(parentKey, key) + if (entryNameKey && key === entryValueKey) { + result[key] = redactedLabel + } + else { + result[key] = redactSnapshotDisplayValue(value[key], redactedLabel, semanticKey) + } + return result + }, {}) + } + if (typeof value === 'string' && isSnapshotUrlValue(parentKey, value)) { + return redactUrl(value, redactedLabel, parentKey) + } + return value +} + +/** @param {unknown} value */ +export function stablePrettyStringify(value) { + return JSON.stringify(normalizeDisplayObject(value), null, 2) +} + +/** @param {string} language */ +export function toIntlLocale(language) { + return language.replace('_', '-') +} + +/** @param {string} key */ +export function isSensitiveKey(key) { + const normalized = String(key).toLowerCase().replace(/[^a-z0-9]/g, '') + return normalized === 'authorization' + || normalized.includes('authorization') + || normalized.includes('authentication') + || normalized === 'auth' + || normalized.endsWith('auth') + || normalized === 'cookie' + || normalized === 'cookie2' + || normalized === 'setcookie' + || normalized === 'setcookie2' + || normalized.endsWith('cookie') + || normalized === 'session' + || normalized.endsWith('session') + || normalized.includes('sessionid') + || normalized.includes('sessionkey') + || normalized.includes('sessiontoken') + || normalized.includes('sessioncookie') + || normalized.endsWith('sessid') + || normalized === 'token' + || normalized.endsWith('token') + || normalized.includes('apikey') + || normalized.includes('appkey') + || normalized.includes('accesskey') + || normalized.includes('subscriptionkey') + || normalized.includes('privatekey') + || normalized.includes('password') + || normalized.includes('passwd') + || normalized.includes('secret') + || normalized.includes('credential') +} + +/** + * @param {string} value + * @param {string} redactedLabel + * @param {string} parentKey + */ +function redactUrl(value, redactedLabel, parentKey) { + const withoutCredentials = value.replace( + /^((?:[a-z][a-z0-9+.-]*:)*\/\/)[^/?#\s]*@/i, + (match, prefix) => `${prefix}${redactedLabel}@`, + ) + const schemeMatch = /^((?:[a-z][a-z0-9+.-]*:)*\/\/)/i.exec(withoutCredentials) + let pathRedacted = withoutCredentials + if (schemeMatch) { + const scheme = schemeMatch[1] + const remainder = withoutCredentials.slice(scheme.length) + const suffixIndex = remainder.search(/[?#]/) + const authorityAndPath = suffixIndex < 0 ? remainder : remainder.slice(0, suffixIndex) + const suffix = suffixIndex < 0 ? '' : remainder.slice(suffixIndex) + const pathIndex = authorityAndPath.indexOf('/') + const authority = pathIndex < 0 ? authorityAndPath : authorityAndPath.slice(0, pathIndex) + const path = pathIndex < 0 ? '' : authorityAndPath.slice(pathIndex) + let host = authority.slice(authority.lastIndexOf('@') + 1) + if (host.startsWith('[')) { + const closingBracket = host.indexOf(']') + host = closingBracket > 0 ? host.slice(1, closingBracket) : host + } + else { + host = host.replace(/:\d+$/, '') + } + host = host.toLowerCase() + pathRedacted = `${scheme}${authority}${redactCapabilityPath(path, host, parentKey, redactedLabel)}${suffix}` + } + else { + const suffixIndex = withoutCredentials.search(/[?#]/) + const path = suffixIndex < 0 ? withoutCredentials : withoutCredentials.slice(0, suffixIndex) + const suffix = suffixIndex < 0 ? '' : withoutCredentials.slice(suffixIndex) + pathRedacted = `${redactCapabilityPath(path, '', parentKey, redactedLabel)}${suffix}` + } + const queryIndex = pathRedacted.search(/[?#]/) + if (queryIndex < 0) { + return pathRedacted + } + return `${pathRedacted.slice(0, queryIndex)}${pathRedacted[queryIndex]}${redactedLabel}` +} + +/** + * Capability URLs often carry credentials in path segments rather than in + * query parameters. Preserve public provider IDs where their formats are + * known, and fail closed after generic hook markers. + * @param {string} path + * @param {string} host + * @param {string} parentKey + * @param {string} redactedLabel + */ +function redactCapabilityPath(path, host, parentKey, redactedLabel) { + if (!path) { + return path + } + const segments = path.split('/') + const normalizedSegments = segments.map(segment => segment.toLowerCase()) + + if (host === 'hooks.slack.com' || host === 'hooks.slack-gov.com') { + const servicesIndex = normalizedSegments.indexOf('services') + const capabilityIndex = servicesIndex >= 0 ? servicesIndex + 3 : -1 + if (capabilityIndex > 0 && capabilityIndex < segments.length) { + segments[capabilityIndex] = redactedLabel + return segments.join('/') + } + } + + if (host === 'discord.com' + || host.endsWith('.discord.com') + || host === 'discordapp.com' + || host.endsWith('.discordapp.com')) { + const webhooksIndex = normalizedSegments.indexOf('webhooks') + const capabilityIndex = webhooksIndex >= 0 ? webhooksIndex + 2 : -1 + if (capabilityIndex > 0 && capabilityIndex < segments.length) { + segments[capabilityIndex] = redactedLabel + return segments.join('/') + } + } + + if (host === 'api.telegram.org') { + const botIndex = segments.findIndex(segment => /^bot[^:]+:.+$/i.test(segment)) + if (botIndex >= 0) { + segments[botIndex] = segments[botIndex].replace(/^bot([^:]+):.+$/i, `bot$1:${redactedLabel}`) + return segments.join('/') + } + } + + const markerIndex = normalizedSegments.findIndex(segment => /^(?:webhooks?|hooks?)$/.test(segment)) + if (markerIndex >= 0 && markerIndex + 1 < segments.length) { + return [...segments.slice(0, markerIndex + 1), redactedLabel].join('/') + } + + const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '') + if (normalizedKey.includes('webhook')) { + let lastSegmentIndex = -1 + for (let index = segments.length - 1; index >= 0; index -= 1) { + if (segments[index].length > 0) { + lastSegmentIndex = index + break + } + } + if (lastSegmentIndex >= 0) { + segments[lastSegmentIndex] = redactedLabel + return segments.join('/') + } + } + + return path +} + +/** + * Carry webhook capability semantics through wrapper objects such as + * `{ deliveryWebhook: { options: { target: "https://.../secret" } } }`. + * @param {string} parentKey + * @param {string} childKey + */ +function resolveUrlSemanticKey(parentKey, childKey) { + if (isWebhookSemanticKey(childKey)) { + return childKey + } + if (isWebhookSemanticKey(parentKey)) { + return childKey ? `${parentKey}.${childKey}` : parentKey + } + return childKey +} + +/** @param {string} value */ +function isWebhookSemanticKey(value) { + const normalized = String(value || '').toLowerCase().replace(/[^a-z0-9]/g, '') + return normalized.includes('webhook') + || normalized === 'hook' + || normalized === 'hooks' + || normalized.endsWith('hook') + || normalized.endsWith('hooks') +} + +/** + * @param {string} parentKey + * @param {string} value + */ +function isSnapshotUrlValue(parentKey, value) { + const normalizedKey = String(parentKey).toLowerCase().replace(/[^a-z0-9]/g, '') + return normalizedKey.includes('url') + || normalizedKey.endsWith('uri') + || normalizedKey.includes('endpoint') + || isWebhookSemanticKey(parentKey) + || /^(?:[a-z][a-z0-9+.-]*:)*\/\//i.test(value) +} + +/** @param {unknown} value */ +function normalizeDisplayObject(value) { + if (Array.isArray(value)) { + return value.map(normalizeDisplayObject) + } + if (isPlainObject(value)) { + return Object.keys(value).sort().reduce((result, key) => { + result[key] = normalizeDisplayObject(value[key]) + return result + }, {}) + } + return value +} + +/** + * @param {unknown} value + * @returns {value is Record} + */ +function isPlainObject(value) { + return value !== null && typeof value === 'object' && !Array.isArray(value) +} diff --git a/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs new file mode 100644 index 00000000..a75e25dc --- /dev/null +++ b/main/manager-mobile/src/pages/agent/components/agentSnapshotUtils.test.mjs @@ -0,0 +1,183 @@ +/* eslint-disable test/no-import-node-test -- this zero-dependency gate intentionally uses Node's built-in runner */ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + filterTtsVoicesByLanguage, + hasUsableTtsVoiceMetadata, + isSensitiveKey, + normalizeSnapshotOrderedList, + normalizeSnapshotTtsNumber, + redactSnapshotDisplayValue, + SNAPSHOT_SECRET_REDACTED, + stablePrettyStringify, + toIntlLocale, + willRestorePermanentlyDeleteChatHistory, +} from './agentSnapshotUtils.mjs' + +test('rejects empty TTS metadata while keeping language-less voices selectable without a filter', () => { + const voices = [{ id: 'voice-without-language', languages: '' }] + assert.equal(hasUsableTtsVoiceMetadata([]), false) + assert.equal(hasUsableTtsVoiceMetadata(voices), true) + assert.deepEqual(filterTtsVoicesByLanguage(voices, ''), voices) + assert.deepEqual(filterTtsVoicesByLanguage(voices, 'zh-CN'), []) +}) + +test('keeps inherited TTS values distinct from explicit zero', () => { + assert.equal(normalizeSnapshotTtsNumber(null), null) + assert.equal(normalizeSnapshotTtsNumber(''), null) + assert.equal(normalizeSnapshotTtsNumber(0), 0) + assert.equal(normalizeSnapshotTtsNumber('0'), 0) +}) + +test('warns only when restoring from a memory mode to no-memory mode', () => { + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_nomem'), true) + assert.equal(willRestorePermanentlyDeleteChatHistory(undefined, 'Memory_nomem'), true) + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_nomem', 'Memory_nomem'), false) + assert.equal(willRestorePermanentlyDeleteChatHistory('Memory_mem_local_short', 'Memory_mem0ai'), false) +}) + +test('keeps context-provider order while stabilizing object keys', () => { + const first = normalizeSnapshotOrderedList([ + { url: 'https://one.example', headers: { z: 'last', a: 'first' } }, + { url: 'https://two.example', headers: {} }, + ]) + const reversed = normalizeSnapshotOrderedList([ + { url: 'https://two.example', headers: {} }, + { headers: { a: 'first', z: 'last' }, url: 'https://one.example' }, + ]) + + assert.deepEqual(first[0], { + headers: { a: 'first', z: 'last' }, + url: 'https://one.example', + }) + assert.notDeepEqual(first, reversed) +}) + +test('redacts nested credentials, header entries, URL credentials and query values', () => { + const redacted = redactSnapshotDisplayValue({ + 'apiKey': 'api-secret', + 'Authentication': 'authentication-secret', + 'X-Auth': 'auth-secret', + 'PHPSESSID': 'php-session-secret', + 'Session-Key': 'session-key-secret', + 'headers': [ + { key: 'Authorization', value: 'Bearer secret' }, + { key: 'Proxy-Authorization', value: 'Basic secret' }, + { key: 'Cookie', value: 'sid=secret' }, + { key: 'Set-Cookie', value: 'session=secret' }, + { key: 'Content-Type', value: 'application/json' }, + ], + 'marker': SNAPSHOT_SECRET_REDACTED, + 'session_id': 'session-secret', + 'url': 'https://user:pass@example.com/context?access_token=secret#fragment', + 'endpoint': 'https://example.com/callback?signature=secret', + }, '[hidden]') + + assert.deepEqual(redacted, { + 'apiKey': '[hidden]', + 'Authentication': '[hidden]', + 'X-Auth': '[hidden]', + 'PHPSESSID': '[hidden]', + 'Session-Key': '[hidden]', + 'headers': [ + { key: 'Authorization', value: '[hidden]' }, + { key: 'Proxy-Authorization', value: '[hidden]' }, + { key: 'Cookie', value: '[hidden]' }, + { key: 'Set-Cookie', value: '[hidden]' }, + { key: 'Content-Type', value: 'application/json' }, + ], + 'marker': '[hidden]', + 'session_id': '[hidden]', + 'url': 'https://[hidden]@example.com/context?[hidden]', + 'endpoint': 'https://example.com/callback?[hidden]', + }) +}) + +test('redacts structured key/name entries without relying on property casing', () => { + const entries = [ + { KEY: 'Authentication', VALUE: 'key secret' }, + { Name: 'X-Auth', Value: 'name secret' }, + { key: 'public-label', NAME: 'Authorization', value: 'secret selected from either marker' }, + { NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' }, + ] + + assert.deepEqual(redactSnapshotDisplayValue(entries, '[hidden]'), [ + { KEY: 'Authentication', VALUE: '[hidden]' }, + { Name: 'X-Auth', Value: '[hidden]' }, + { key: 'public-label', NAME: 'Authorization', value: '[hidden]' }, + { NAME: 'Content-Type', VALUE: 'application/json; charset=utf-8' }, + ]) +}) + +test('redacts capability path values without hiding ordinary REST paths', () => { + const redacted = redactSnapshotDisplayValue({ + slack: 'https://hooks.slack.com/services/T111/B222/capability-value', + slackGov: 'https://hooks.slack-gov.com/services/T111/B222/gov-capability-value', + discord: 'https://discord.com/api/webhooks/123456/capability-value', + telegram: 'https://api.telegram.org/bot123456:capability-value/sendMessage', + genericHook: 'https://example.com/api/hook/capability/continuation', + genericHooks: 'https://example.com/api/hooks/capability/continuation', + genericWebhook: 'https://example.com/api/webhook/capability/continuation', + genericWebhooks: 'https://example.com/api/webhooks/capability/continuation', + relativeUrl: '/api/webhooks/capability/continuation', + callbackWebhookUrl: 'https://example.com/incoming/capability-value', + nested: { + deliveryWebhook: { + options: { + target: 'https://example.com/incoming/nested-capability-value', + relativeTarget: '/incoming/nested-relative-capability-value', + }, + }, + }, + protocolRelativeUrl: '//protocol-user:protocol-pass@example.com/context', + jdbcUrl: 'jdbc:mysql://jdbc-user:jdbc-pass@example.com/database', + ordinary: 'https://example.com/api/v1/agents/42', + }, '[hidden]') + + assert.deepEqual(redacted, { + slack: 'https://hooks.slack.com/services/T111/B222/[hidden]', + slackGov: 'https://hooks.slack-gov.com/services/T111/B222/[hidden]', + discord: 'https://discord.com/api/webhooks/123456/[hidden]', + telegram: 'https://api.telegram.org/bot123456:[hidden]/sendMessage', + genericHook: 'https://example.com/api/hook/[hidden]', + genericHooks: 'https://example.com/api/hooks/[hidden]', + genericWebhook: 'https://example.com/api/webhook/[hidden]', + genericWebhooks: 'https://example.com/api/webhooks/[hidden]', + relativeUrl: '/api/webhooks/[hidden]', + callbackWebhookUrl: 'https://example.com/incoming/[hidden]', + nested: { + deliveryWebhook: { + options: { + target: 'https://example.com/incoming/[hidden]', + relativeTarget: '/incoming/[hidden]', + }, + }, + }, + protocolRelativeUrl: '//[hidden]@example.com/context', + jdbcUrl: 'jdbc:mysql://[hidden]@example.com/database', + ordinary: 'https://example.com/api/v1/agents/42', + }) +}) + +test('matches the backend sensitive key variants', () => { + for (const key of [ + 'Authentication', + 'X-Auth', + 'Proxy-Authorization', + 'Cookie', + 'Set-Cookie', + 'PHPSESSID', + 'Session-Key', + 'session_token', + 'Ocp-Apim-Subscription-Key', + ]) { + assert.equal(isSensitiveKey(key), true, `${key} should be redacted`) + } + assert.equal(isSensitiveKey('Content-Type'), false) + assert.equal(isSensitiveKey('publicKey'), false) +}) + +test('formats objects stably and maps app locale names to Intl locales', () => { + assert.equal(stablePrettyStringify({ z: 1, a: { d: 2, c: 1 } }), '{\n "a": {\n "c": 1,\n "d": 2\n },\n "z": 1\n}') + assert.equal(toIntlLocale('pt_BR'), 'pt-BR') +}) diff --git a/main/manager-mobile/src/pages/agent/edit.vue b/main/manager-mobile/src/pages/agent/edit.vue index e33c0211..e3cf4e91 100644 --- a/main/manager-mobile/src/pages/agent/edit.vue +++ b/main/manager-mobile/src/pages/agent/edit.vue @@ -1,10 +1,12 @@ @@ -86,18 +84,18 @@ onMounted(() => { - {{ t('deviceConfig.configMethod') }} - + {{ t('deviceConfig.configMethod') }} + - {{ t('deviceConfig.configMethod') }} - - - {{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }} - + {{ t('deviceConfig.configMethod') }} + + + {{ configType === 'wifi' ? t('deviceConfig.wifiConfig') : t('deviceConfig.ultrasonicConfig') }} + @@ -105,8 +103,8 @@ onMounted(() => { - {{ t('deviceConfig.networkConfig') }} - + {{ t('deviceConfig.networkConfig') }} + diff --git a/main/manager-mobile/src/pages/device/index.vue b/main/manager-mobile/src/pages/device/index.vue index f1fdcc26..4c38199e 100644 --- a/main/manager-mobile/src/pages/device/index.vue +++ b/main/manager-mobile/src/pages/device/index.vue @@ -1,7 +1,7 @@