securityScanCache = new ConcurrentHashMap<>();
private record CachedScanOutcome(String contentHash, SkillValidationResult result) {}
@Autowired
public SkillPackageResolver(SkillFrontmatterParser frontmatterParser,
SkillManifestParser manifestParser,
SkillDirectoryScanner directoryScanner,
SkillSecurityService securityService,
SkillDependencyChecker dependencyChecker,
ObjectMapper objectMapper,
SkillWorkspaceManager workspaceManager,
SkillMapper skillMapper,
@Lazy WikiSkillWrapperToolFactory wikiWrapperFactory,
@Lazy AcpSkillWrapperToolFactory acpWrapperFactory,
@Lazy ToolRegistry toolRegistry) {
this.frontmatterParser = frontmatterParser;
this.manifestParser = manifestParser;
this.directoryScanner = directoryScanner;
this.securityService = securityService;
this.dependencyChecker = dependencyChecker;
this.objectMapper = objectMapper;
this.workspaceManager = workspaceManager;
this.skillMapper = skillMapper;
this.wikiWrapperFactory = wikiWrapperFactory;
this.acpWrapperFactory = acpWrapperFactory;
this.toolRegistry = toolRegistry;
}
/**
* 解析技能实体为运行时技能包(完整流程)
*/
public ResolvedSkill resolve(SkillEntity entity) {
String configuredDir = extractSkillDirString(entity);
Path skillDir = configuredDir != null ? Paths.get(configuredDir) : null;
ResolvedSkill resolved;
// 三级解析:explicit → convention → database
if (skillDir != null && Files.exists(skillDir) && Files.isDirectory(skillDir)) {
// 1. 显式配置的 skillDir
resolved = resolveFromDirectory(entity, skillDir, configuredDir, "directory");
} else {
// 2. 约定路径 {workspace-root}/{skillName}/
Path conventionPath = workspaceManager.resolveConventionPath(entity.getName());
if (Files.exists(conventionPath) && Files.isDirectory(conventionPath)) {
resolved = resolveFromDirectory(entity, conventionPath, conventionPath.toString(), "convention");
} else {
// 3. 数据库 skillContent
resolved = resolveFromDatabase(entity, configuredDir);
}
}
// 2. 安全扫描
applySecurity(resolved);
// 3. 依赖检查
applyDependencyCheck(resolved);
// 4. RFC-090 §14.1 — manifest + features 矩阵
applyManifestAndFeatures(resolved);
// 5. 综合判定 runtimeAvailable
resolveRuntimeAvailability(resolved);
// 6. RFC-042 §2.3 + RFC-090 §14.6 — persist scan outcome and
// project manifest_json back to the row so legacy columns
// stay in sync.
persistScanOutcome(entity, resolved);
return resolved;
}
/**
* Write back the latest scan status / findings JSON / timestamp when
* they differ from what's already on the row. Keeps the DB in sync
* without re-writing on every idempotent refresh.
*
* Diff-based so a fresh resolve loop across N enabled skills is
* effectively free when nothing has changed on disk. Errors here are
* non-fatal — the scan result is already attached to {@code resolved},
* so the UI will still see it for this request.
*/
private void persistScanOutcome(SkillEntity entity, ResolvedSkill resolved) {
if (entity == null || entity.getId() == null) return;
String newStatus = deriveScanStatus(resolved);
String newJson = serializeFindings(resolved.getSecurityFindings());
String newManifestJson = serializeManifest(resolved.getManifest());
boolean statusChanged = !Objects.equals(entity.getSecurityScanStatus(), newStatus);
boolean findingsChanged = !Objects.equals(entity.getSecurityScanResult(), newJson);
boolean manifestChanged = !Objects.equals(entity.getManifestJson(), newManifestJson);
// RFC-090 §14.6 — column projection from manifest (SoT).
// Snapshot pre-projection values so we know which legacy columns
// need a row-level update. Skipped when the manifest is null.
//
// Icon is a special case: the UI lets users pick a custom icon
// (emoji / pixelarticons / URL) per skill. If we unconditionally
// re-project the manifest icon every resolve, the user's pick
// gets silently reverted as soon as the runtime cache refreshes.
// So we only seed the icon from manifest when the row's icon is
// empty — meaning user-set icons stick, and clearing the icon
// ("no icon" in the picker) explicitly opts back into the
// manifest default on the next resolve.
String newSkillType = entity.getSkillType();
String newIcon = entity.getIcon();
String newVersion = entity.getVersion();
String newAuthor = entity.getAuthor();
if (resolved.getManifest() != null) {
SkillManifest m = resolved.getManifest();
if (m.getType() != null && !m.getType().isBlank()) newSkillType = m.getType();
boolean rowIconBlank = entity.getIcon() == null || entity.getIcon().isBlank();
if (rowIconBlank && m.getIcon() != null && !m.getIcon().isBlank()) {
newIcon = m.getIcon();
}
if (m.getVersion() != null && !m.getVersion().isBlank()) newVersion = m.getVersion();
if (m.getAuthor() != null && !m.getAuthor().isBlank()) newAuthor = m.getAuthor();
}
boolean projectionChanged = !Objects.equals(entity.getSkillType(), newSkillType)
|| !Objects.equals(entity.getIcon(), newIcon)
|| !Objects.equals(entity.getVersion(), newVersion)
|| !Objects.equals(entity.getAuthor(), newAuthor);
if (!statusChanged && !findingsChanged && !manifestChanged && !projectionChanged) {
return;
}
try {
// Whitelist via LambdaUpdateWrapper (issue #45): SkillEntity has
// several @TableField(updateStrategy = FieldStrategy.ALWAYS)
// columns (skill_content, config_json, source_code, name_zh,
// name_en, security_scan_result, manifest_json). Calling updateById
// with a partial entity would tell MyBatis Plus to write NULL to
// every ALWAYS column not set on the partial — wiping the imported
// skill content on every scan write-back.
LocalDateTime now = LocalDateTime.now();
LambdaUpdateWrapper wrapper = new LambdaUpdateWrapper()
.eq(SkillEntity::getId, entity.getId());
if (statusChanged) wrapper.set(SkillEntity::getSecurityScanStatus, newStatus);
if (findingsChanged) wrapper.set(SkillEntity::getSecurityScanResult, newJson);
if (manifestChanged) wrapper.set(SkillEntity::getManifestJson, newManifestJson);
if (projectionChanged) {
wrapper.set(SkillEntity::getSkillType, newSkillType)
.set(SkillEntity::getIcon, newIcon)
.set(SkillEntity::getVersion, newVersion)
.set(SkillEntity::getAuthor, newAuthor);
}
// Always update scan time when something changed so we have a
// freshness indicator for the admin UI.
if (statusChanged || findingsChanged) wrapper.set(SkillEntity::getSecurityScanTime, now);
skillMapper.update(null, wrapper);
// Keep the in-memory entity coherent with the DB so the next
// resolve in the same tick doesn't redundantly write again.
if (statusChanged) entity.setSecurityScanStatus(newStatus);
if (findingsChanged) entity.setSecurityScanResult(newJson);
if (statusChanged || findingsChanged) entity.setSecurityScanTime(now);
if (manifestChanged) entity.setManifestJson(newManifestJson);
if (projectionChanged) {
entity.setSkillType(newSkillType);
entity.setIcon(newIcon);
entity.setVersion(newVersion);
entity.setAuthor(newAuthor);
}
} catch (Exception e) {
log.warn("Failed to persist scan outcome for skill '{}': {}", entity.getName(), e.getMessage());
}
}
private String serializeManifest(SkillManifest manifest) {
if (manifest == null) return null;
try {
return objectMapper.writeValueAsString(manifest);
} catch (Exception e) {
log.debug("Failed to serialize manifest: {}", e.getMessage());
return null;
}
}
/**
* Collapse the resolver's rich security state back into the {@code
* PASSED / FAILED / null} tri-state used on the row.
*/
private String deriveScanStatus(ResolvedSkill resolved) {
if (resolved.isSecurityBlocked()) return "FAILED";
List findings = resolved.getSecurityFindings();
if (findings != null && !findings.isEmpty()) return "PASSED"; // scanned and found non-blocking issues
// No block, no findings — treat as scanned-clean (still PASSED so
// listEnabledSkills() doesn't treat it as never-scanned).
return "PASSED";
}
private String serializeFindings(List findings) {
if (findings == null || findings.isEmpty()) return null;
try {
return objectMapper.writeValueAsString(findings);
} catch (Exception e) {
log.debug("Failed to serialize findings: {}", e.getMessage());
return null;
}
}
// ==================== 阶段 1:内容解析 ====================
private ResolvedSkill resolveFromDirectory(SkillEntity entity, Path skillDir, String configuredDir, String source) {
Path skillMd = skillDir.resolve("SKILL.md");
String content = "";
String description = entity.getDescription();
if (Files.exists(skillMd)) {
try {
content = Files.readString(skillMd);
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
} catch (Exception e) {
log.warn("Failed to read SKILL.md from {}: {}", skillMd, e.getMessage());
}
}
Map references = directoryScanner.buildDirectoryTree(skillDir.resolve("references"));
Map scripts = directoryScanner.buildDirectoryTree(skillDir.resolve("scripts"));
return ResolvedSkill.builder()
.id(entity.getId())
.name(entity.getName())
.description(description)
.content(content)
.source(source)
.skillDir(skillDir)
.configuredSkillDir(configuredDir)
.runtimeAvailable(true) // 暂定,后续安全/依赖检查可能改写
.resolutionError(null)
.references(references)
.scripts(scripts)
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}
private ResolvedSkill resolveFromDatabase(SkillEntity entity, String configuredDir) {
String content = entity.getSkillContent() != null ? entity.getSkillContent() : "";
String description = entity.getDescription();
if (!content.isBlank()) {
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
if (!parsed.getDescription().isBlank()) {
description = parsed.getDescription();
}
}
boolean hasContent = !content.isBlank();
String error = null;
if (configuredDir != null) {
error = "Configured skillDir not found: " + configuredDir;
if (hasContent) {
error += " (fallback to database skillContent)";
}
} else if (!hasContent) {
error = "No skillDir configured and no skillContent available";
}
return ResolvedSkill.builder()
.id(entity.getId())
.name(entity.getName())
.description(description)
.content(content)
.source("database")
.skillDir(null)
.configuredSkillDir(configuredDir)
.runtimeAvailable(hasContent || (description != null && !description.isBlank()))
.resolutionError(error)
.references(Map.of())
.scripts(Map.of())
.enabled(Boolean.TRUE.equals(entity.getEnabled()))
.icon(entity.getIcon())
.builtin(Boolean.TRUE.equals(entity.getBuiltin()))
.workspaceId(entity.getWorkspaceId())
.createTime(entity.getCreateTime())
.build();
}
// ==================== 阶段 2:安全扫描 ====================
private void applySecurity(ResolvedSkill resolved) {
// Builtin skills come from classpath/jar — they're version-controlled
// upstream and the resolver flow already maps blocked findings to
// `trustedBuiltin` (warn but don't block). Running the full scanner
// on every refresh is pure overhead; with 30+ shipped skills this
// dominates the refresh budget. Mark them trusted up-front and
// skip — the persisted scan_result on mate_skill (written by the
// initial resolve) keeps the UI's audit trail intact.
if (resolved.isBuiltin()) {
resolved.setSecurityBlocked(false);
resolved.setSecuritySummary("Trusted builtin (runtime scan skipped)");
resolved.setSecurityWarnings(List.of());
return;
}
try {
// Content-hash cache: refreshes on unchanged SKILL.md content
// skip the (potentially expensive) scanner. Hash drifts → fall
// through to a fresh scan and replace the cached entry.
String contentHash = sha256(resolved.getContent());
CachedScanOutcome cached = resolved.getId() != null
? securityScanCache.get(resolved.getId())
: null;
SkillValidationResult result;
if (cached != null && cached.contentHash().equals(contentHash)) {
result = cached.result();
} else {
result = securityService.validate(resolved);
if (resolved.getId() != null) {
securityScanCache.put(resolved.getId(), new CachedScanOutcome(contentHash, result));
}
}
boolean trustedBuiltin = resolved.isBuiltin() && result.isBlocked();
resolved.setSecurityBlocked(result.isBlocked() && !trustedBuiltin);
resolved.setSecuritySeverity(result.getMaxSeverity() != null ? result.getMaxSeverity().name() : null);
if (trustedBuiltin) {
resolved.setSecuritySummary("Builtin skill trusted: " + result.getSummary());
} else {
resolved.setSecuritySummary(result.getSummary());
}
resolved.setSecurityWarnings(result.getWarnings());
// 转换 findings 为 JSON 友好格式
if (result.getFindings() != null && !result.getFindings().isEmpty()) {
List secFindings = result.getFindings().stream()
.map(f -> ResolvedSkill.SecurityFinding.builder()
.ruleId(f.getRuleId())
.severity(f.getSeverity() != null ? f.getSeverity().name() : null)
.category(f.getCategory())
.title(f.getTitle())
.description(f.getDescription())
.filePath(f.getFilePath())
.lineNumber(f.getLineNumber())
.snippet(f.getSnippet())
.remediation(f.getRemediation())
.build())
.collect(Collectors.toList());
resolved.setSecurityFindings(secFindings);
}
if (trustedBuiltin) {
log.warn("Builtin skill '{}' bypassed security block: {}", resolved.getName(), result.getSummary());
} else if (result.isBlocked()) {
log.warn("Skill '{}' blocked by security scan: {}", resolved.getName(), result.getSummary());
} else if (result.getFindings() != null && !result.getFindings().isEmpty()) {
log.info("Skill '{}' security scan: {} finding(s)", resolved.getName(), result.getFindings().size());
}
} catch (Exception e) {
log.error("Security scan failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setSecurityWarnings(List.of("Security scan error: " + e.getMessage()));
}
}
// ==================== 阶段 3:依赖检查 ====================
private void applyDependencyCheck(ResolvedSkill resolved) {
try {
// 解析 frontmatter 获取依赖声明
String content = resolved.getContent();
if (content == null || content.isBlank()) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillFrontmatterParser.ParsedSkillMd parsed = frontmatterParser.parse(content);
SkillFrontmatterParser.SkillDependencies deps = parsed.getDependencies();
List platforms = parsed.getPlatforms();
if ((deps == null || deps.isEmpty()) && (platforms == null || platforms.isEmpty())) {
resolved.setDependencyReady(true);
resolved.setDependencySummary("No dependencies declared");
return;
}
SkillDependencyChecker.DependencyCheckResult result =
dependencyChecker.check(deps, platforms, resolved.getName());
resolved.setDependencyReady(result.isSatisfied());
resolved.setMissingDependencies(result.getMissing());
resolved.setDependencySummary(result.getSummary());
if (!result.isSatisfied()) {
log.info("Skill '{}' dependencies not satisfied: {}", resolved.getName(), result.getSummary());
}
} catch (Exception e) {
log.error("Dependency check failed for skill '{}': {}", resolved.getName(), e.getMessage());
resolved.setDependencyReady(true); // 检查失败不阻断
resolved.setDependencySummary("Dependency check error: " + e.getMessage());
}
}
// ==================== 阶段 3.5:manifest + features (RFC-090 §14.1 / §14.6) ====================
/**
* Parse the SKILL.md frontmatter into a typed manifest and evaluate
* the {@code features[]} matrix.
*
* Backward compat: when the manifest declares no {@code features[]},
* we synthesize a single feature {@code "default"} that inherits the
* top-level requires + platforms — giving legacy skills the same status
* (READY iff all top-level requirements are satisfied) without code
* changes upstream.
*/
private void applyManifestAndFeatures(ResolvedSkill resolved) {
try {
String content = resolved.getContent();
SkillManifest manifest = manifestParser.parse(content);
if (manifest == null) {
// No frontmatter at all: leave manifest null and let
// legacy callers continue using dependencyReady. Also
// make sure no wrapper tools linger from a prior shape.
deregisterSkillWrappers(resolved.getId());
return;
}
resolved.setManifest(manifest);
// RFC-090 §14.4 — for knowledge skills, resolve bind_kb
// and (re)register skill-scoped wrapper tools. This must
// happen *before* the feature evaluation below so the
// wrapper names are part of allowedTools when
// getEffectiveAllowedTools() runs.
applyKnowledgeWrappers(resolved, manifest);
// RFC-090 Phase 7b — type=acp gets its own wrapper that
// delegates to the configured ACP endpoint. Parallel
// structure to knowledge wrappers; tracked under the same
// registeredWrappers map so deregistration covers both.
applyAcpWrappers(resolved, manifest);
// Build requirement lookup for feature checks.
Map reqByKey = new LinkedHashMap<>();
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
if (r.getKey() != null) reqByKey.put(r.getKey(), r);
}
List features = manifest.getFeatures();
List effectiveFeatures;
if (features == null || features.isEmpty()) {
// Synthesized "default" feature carrying top-level requires
// + platforms so legacy skills get the same status path.
List defaultRequires = new ArrayList<>();
for (SkillManifest.RequirementDef r : manifest.getRequires()) {
if (r.getKey() != null && !r.isOptional()) defaultRequires.add(r.getKey());
}
effectiveFeatures = List.of(SkillManifest.FeatureDef.builder()
.id("default")
.label(manifest.getName() != null ? manifest.getName() : "default")
.requires(defaultRequires)
.platforms(manifest.getPlatforms())
.build());
// Write the synthesized feature back so the UI's Features
// tab and the LLM's prompt enhancement both see one row
// instead of "no features declared". Functionally
// equivalent to the no-features-declared case, but
// observable in the manifest_json projection.
manifest.setFeatures(effectiveFeatures);
} else {
effectiveFeatures = features;
}
Map statuses = new LinkedHashMap<>();
Set active = new LinkedHashSet<>();
for (SkillManifest.FeatureDef f : effectiveFeatures) {
if (f.getId() == null || f.getId().isBlank()) continue;
SkillDependencyChecker.FeatureCheckResult res = dependencyChecker.checkFeature(f, reqByKey);
statuses.put(f.getId(), res.getStatus());
if ("READY".equals(res.getStatus())) active.add(f.getId());
}
resolved.setFeatureStatuses(statuses);
resolved.setActiveFeatures(active);
} catch (Exception e) {
log.warn("Manifest/feature evaluation failed for skill '{}': {}",
resolved.getName(), e.getMessage());
}
}
/**
* RFC-090 §14.4 — register / refresh / deregister wrapper tools
* for a single knowledge skill.
*
* Behaviour:
*
* - {@code type != knowledge} or skill disabled → deregister
* any prior wrappers, leave {@code allowedTools} alone.
* - {@code bind_kb} unresolvable → deregister + emit warning;
* feature evaluation will then mark the skill SETUP_NEEDED.
* - Otherwise: deregister prior set, register fresh, append
* wrapper names to {@code manifest.allowedTools} so
* {@code getEffectiveAllowedTools()} surfaces them.
*
*
* Wrappers are registered as plugin tools with an availability
* supplier that returns true while the entity stays enabled. That
* way a toggle-off doesn't require an explicit re-resolve to hide
* the tools — the supplier is evaluated each time the agent tool
* set is built.
*/
private void applyKnowledgeWrappers(ResolvedSkill resolved, SkillManifest manifest) {
boolean isKnowledge = "knowledge".equalsIgnoreCase(manifest.getType())
&& manifest.getKnowledge() != null
&& manifest.getKnowledge().getBindKb() != null
&& !manifest.getKnowledge().getBindKb().isBlank();
if (!isKnowledge || !resolved.isEnabled()) {
deregisterSkillWrappers(resolved.getId());
return;
}
Long kbId = manifest.getKnowledge().getBoundKbId();
if (kbId == null) {
kbId = wikiWrapperFactory.resolveKbId(manifest.getKnowledge().getBindKb());
if (kbId == null) {
String slug = manifest.getKnowledge().getBindKb();
log.warn("Skill '{}' has type=knowledge but bind_kb '{}' did not resolve to a KB",
resolved.getName(), slug);
deregisterSkillWrappers(resolved.getId());
markBindingFailure(resolved,
"kb:" + slug,
"Knowledge skill bind_kb '" + slug + "' did not resolve to any Wiki KB");
return;
}
manifest.getKnowledge().setBoundKbId(kbId);
}
// Fresh build to keep wrapper state in lockstep with the
// current kbId — if the user repointed bind_kb, the old
// wrappers must go.
deregisterSkillWrappers(resolved.getId());
java.util.List wrappers = wikiWrapperFactory.buildWrappers(manifest, kbId);
if (wrappers.isEmpty()) {
return;
}
java.util.Set registered = new java.util.LinkedHashSet<>();
Long entityId = resolved.getId();
for (ToolCallback cb : wrappers) {
String name = cb.getToolDefinition().name();
registered.add(name);
// Availability supplier: skill must still resolve to an
// enabled row. Worst case: a disable racing with a tool
// call simply returns "tool unavailable" for one turn.
toolRegistry.registerPluginTool(cb, () ->
entityId != null && resolved.isEnabled());
}
if (entityId != null) {
registeredWrappers.put(entityId, registered);
}
// Make wrapper names visible to ResolvedSkill.getEffectiveAllowedTools.
// We *append* rather than replace so a knowledge skill can also
// declare extra allowed-tools alongside the auto-generated KB
// surface (Q9 in §10.2 答 ✅).
java.util.List mergedAllowed = new java.util.ArrayList<>(
manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools());
for (String wrapperName : wikiWrapperFactory.wrapperNames(manifest)) {
if (!mergedAllowed.contains(wrapperName)) mergedAllowed.add(wrapperName);
}
manifest.setAllowedTools(mergedAllowed);
}
/**
* RFC-090 review #2 — when a knowledge / acp skill's external
* binding (bind_kb / endpoint) cannot be resolved, downgrade the
* resolved view so {@code passesActiveGate} fails. Without this,
* the synthesized default feature still evaluates READY (because
* the unresolved binding isn't expressed as a manifest requirement
* the dependency checker can fail), the skill enters the active
* set, and the LLM advertises tools it can never actually use.
*
* Triple-belt-and-braces:
*
* - {@code missingDependencies} populated for the UI.
* - {@code dependencyReady=false} so legacy gate path also fails.
* - {@code runtimeAvailable=false} so the manifest-aware path
* in {@code passesActiveGate} fails too — even though the
* feature would otherwise be READY.
*
* The later {@code resolveRuntimeAvailability} step won't undo
* these because it only flips {@code runtimeAvailable=false}, it
* never flips it back to true.
*/
private void markBindingFailure(ResolvedSkill resolved, String missingKey, String summary) {
resolved.setMissingDependencies(java.util.List.of(missingKey));
resolved.setDependencyReady(false);
resolved.setDependencySummary(summary);
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError(summary);
}
}
/**
* RFC-090 review #3 — explicit deregistration entry point. Called
* from {@code SkillService.toggleSkill (disable)} / uninstall /
* hardDelete so wrapper tools don't outlive the skill's lifecycle.
*
* Without this, the {@code availabilityCheck} supplier closes
* over the {@link ResolvedSkill} captured at registration time
* and keeps returning {@code enabled=true} forever. Stale wrapper
* advertisements survive a disable/uninstall.
*/
public void deregisterSkillWrappers(Long skillId) {
if (skillId == null) return;
// Evict the security-scan cache entry too — a re-installed skill
// with the same id but different content needs a fresh scan, and
// a permanently-deleted skill should free the slot.
securityScanCache.remove(skillId);
java.util.Set previous = registeredWrappers.remove(skillId);
if (previous == null || previous.isEmpty()) return;
for (String name : previous) {
try {
toolRegistry.unregisterPluginTool(name);
} catch (Exception e) {
log.debug("unregister wrapper {} failed: {}", name, e.getMessage());
}
}
log.info("Deregistered {} wrapper tool(s) for skill id={}", previous.size(), skillId);
}
/** Hex-encoded SHA-256 of the input string (UTF-8). Empty/null → "". */
private static String sha256(String input) {
if (input == null || input.isEmpty()) return "";
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
// SHA-256 is mandatory in the JRE — should never throw.
// Fall back to identity so the cache is just always-miss.
return input;
}
}
/**
* RFC-090 Phase 7b — register / refresh / deregister wrapper tools
* for a single ACP skill. Mirrors {@link #applyKnowledgeWrappers}.
*
* Endpoint resolution semantics match the knowledge path:
*
* - {@code type != acp} or skill disabled → deregister.
* - {@code endpoint} unresolvable → deregister + add a missing-
* dependency hint so the UI shows SETUP_NEEDED.
* - Otherwise: register one wrapper, append name to
* {@code manifest.allowedTools} so the LLM advertisement
* picks it up.
*
*/
private void applyAcpWrappers(ResolvedSkill resolved, SkillManifest manifest) {
boolean isAcp = "acp".equalsIgnoreCase(manifest.getType())
&& manifest.getAcp() != null
&& manifest.getAcp().getEndpoint() != null
&& !manifest.getAcp().getEndpoint().isBlank();
if (!isAcp || !resolved.isEnabled()) {
// applyKnowledgeWrappers already cleared registrations for
// type=knowledge; we don't double-clear when this branch
// also runs for the same skill — type is single-valued so
// only one branch ever holds wrappers at a time.
return;
}
Long endpointId = manifest.getAcp().getResolvedEndpointId();
if (endpointId == null) {
endpointId = acpWrapperFactory.resolveEndpointId(manifest.getAcp().getEndpoint());
if (endpointId == null) {
String slug = manifest.getAcp().getEndpoint();
log.warn("Skill '{}' type=acp but endpoint '{}' did not resolve",
resolved.getName(), slug);
deregisterSkillWrappers(resolved.getId());
markBindingFailure(resolved,
"acp:" + slug,
"ACP skill endpoint '" + slug + "' did not resolve to any registered ACP endpoint");
return;
}
manifest.getAcp().setResolvedEndpointId(endpointId);
}
// Fresh build: drop any prior knowledge-wrapper registration
// (impossible in practice since type is single-valued, but
// makes the lifecycle safe across edits where the user flips
// type from knowledge to acp).
deregisterSkillWrappers(resolved.getId());
java.util.List wrappers = acpWrapperFactory.buildWrappers(manifest);
if (wrappers.isEmpty()) return;
java.util.Set registered = new java.util.LinkedHashSet<>();
Long entityId = resolved.getId();
for (ToolCallback cb : wrappers) {
String name = cb.getToolDefinition().name();
registered.add(name);
toolRegistry.registerPluginTool(cb, () ->
entityId != null && resolved.isEnabled());
}
if (entityId != null) {
registeredWrappers.put(entityId, registered);
}
// Append wrapper names to allowedTools so getEffectiveAllowedTools
// surfaces them like any other manifest-declared tool.
java.util.List mergedAllowed = new java.util.ArrayList<>(
manifest.getAllowedTools() == null ? java.util.List.of() : manifest.getAllowedTools());
for (String wrapperName : acpWrapperFactory.wrapperNames(manifest)) {
if (!mergedAllowed.contains(wrapperName)) mergedAllowed.add(wrapperName);
}
manifest.setAllowedTools(mergedAllowed);
}
// ==================== 阶段 4:综合判定 ====================
private void resolveRuntimeAvailability(ResolvedSkill resolved) {
// 安全阻断 → 不可用
if (resolved.isSecurityBlocked()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Security blocked: " + resolved.getSecuritySummary());
}
return;
}
// 依赖不满足 → 不可用
if (!resolved.isDependencyReady()) {
resolved.setRuntimeAvailable(false);
if (resolved.getResolutionError() == null) {
resolved.setResolutionError("Dependencies missing: " + resolved.getDependencySummary());
}
}
// 其他情况保留原有 runtimeAvailable 判定
}
// ==================== 工具方法 ====================
private String extractSkillDirString(SkillEntity entity) {
String configJson = entity.getConfigJson();
if (configJson == null || configJson.isBlank()) {
return null;
}
try {
@SuppressWarnings("unchecked")
Map config = objectMapper.readValue(configJson, Map.class);
String pathStr = null;
if (config.containsKey("skillDir")) {
pathStr = config.get("skillDir").toString();
} else if (config.containsKey("path")) {
pathStr = config.get("path").toString();
} else if (config.containsKey("directory")) {
pathStr = config.get("directory").toString();
}
if (pathStr != null && !pathStr.isBlank()) {
return pathStr;
}
} catch (Exception e) {
log.debug("Failed to parse configJson for skill {}: {}", entity.getName(), e.getMessage());
}
return null;
}
}