feat(authoring): runtime bindings, execution adapters and docker isolation

Layer (b1) of the authoring platform (#934), the first half of the split
runtime layer:

- **Runtime bindings**: agent type + config + tool allowlist per draft,
  persisted as JSONB and structurally validated at save time, managed via
  GET/PUT /drafts/{id}/runtime. MCP server declarations are validated
  structurally (name/transport/endpoint format); the SSRF policy, stdio
  gate and envRef allowlist that guard them arrive with the MCP+security
  layer.
- **CONFIG and BEHAVIOR validation layers**: validation.yaml spec parsing
  (defensive YAML), assertion syntax checks; script tasks execute through
  the local-script adapter — inline (cleared environment, isolated
  workspace, interpreter allowlist, path confinement) or docker (locked
  container: no network, dropped capabilities, read-only root, resource
  caps, tmpfs; workspace permissions and uid:gid mapping so container
  files stay readable and deletable on hardened hosts).
- The orchestrator keeps every piece of the upstream lifecycle hardening:
  one FutureTask per run, wall-clock watchdog, settle-first cancellation.

Verified: spec-parser and assertion-evaluator suites (20), docker
isolation against real containers (4, auto-skipped without a daemon),
the upstream orchestrator lifecycle suite against the extended
constructor (5), and the execution flow integration test (script-only
three-layer validation with inline execution).

Signed-off-by: zjncs <18910855655@163.com>
This commit is contained in:
zjncs 2026-10-22 13:37:10 +08:00
parent 250336d3e9
commit 4aab1ec48d
40 changed files with 2900 additions and 28 deletions

View file

@ -1,12 +1,12 @@
package com.iflytek.skillhub.config;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* Feature configuration for the skill authoring and validation platform
* ({@code skillhub.authoring.*}). This layer covers the draft authoring and
* structure-validation core; runtime-binding configuration (script execution
* backends, LLM endpoints, MCP servers) arrives with the runtime layer.
* ({@code skillhub.authoring.*}).
*/
@ConfigurationProperties(prefix = "skillhub.authoring")
public class AuthoringProperties {
@ -23,6 +23,8 @@ public class AuthoringProperties {
/** Active runs older than this are swept to TIMED_OUT by the maintenance task. */
private int staleRunMinutes = 30;
private final LocalScript localScript = new LocalScript();
public String getWorkspaceRoot() {
return workspaceRoot;
}
@ -54,4 +56,94 @@ public class AuthoringProperties {
public void setStaleRunMinutes(int staleRunMinutes) {
this.staleRunMinutes = staleRunMinutes;
}
public LocalScript getLocalScript() {
return localScript;
}
/** Local script execution runtime. */
public static class LocalScript {
private boolean enabled = true;
/** Where scripts run: INLINE on the server host (dev), DOCKER in a locked-down container (production). */
private ScriptExecutionMode executionMode = ScriptExecutionMode.INLINE;
private final Docker docker = new Docker();
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public ScriptExecutionMode getExecutionMode() {
return executionMode;
}
public void setExecutionMode(ScriptExecutionMode executionMode) {
this.executionMode = executionMode;
}
public Docker getDocker() {
return docker;
}
/** Container isolation profile for the docker execution mode. */
public static class Docker {
private String image = "alpine:3.20";
private String memory = "256m";
private String cpus = "1.0";
private int pidsLimit = 128;
private String tmpfsSize = "64m";
public String getImage() {
return image;
}
public void setImage(String image) {
this.image = image;
}
public String getMemory() {
return memory;
}
public void setMemory(String memory) {
this.memory = memory;
}
public String getCpus() {
return cpus;
}
public void setCpus(String cpus) {
this.cpus = cpus;
}
public int getPidsLimit() {
return pidsLimit;
}
public void setPidsLimit(int pidsLimit) {
this.pidsLimit = pidsLimit;
}
public String getTmpfsSize() {
return tmpfsSize;
}
public void setTmpfsSize(String tmpfsSize) {
this.tmpfsSize = tmpfsSize;
}
}
}
/** Script execution backend selection. */
public enum ScriptExecutionMode {
INLINE,
DOCKER
}
}

View file

@ -2,6 +2,7 @@ package com.iflytek.skillhub.controller.authoring;
import com.iflytek.skillhub.controller.BaseApiController;
import com.iflytek.skillhub.domain.authoring.SkillDraft;
import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService;
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
import com.iflytek.skillhub.domain.authoring.service.ValidationRunService;
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
@ -10,6 +11,8 @@ import com.iflytek.skillhub.dto.CreateDraftRequest;
import com.iflytek.skillhub.dto.DraftFileContentResponse;
import com.iflytek.skillhub.dto.DraftFileResponse;
import com.iflytek.skillhub.dto.DraftResponse;
import com.iflytek.skillhub.dto.RuntimeBindingRequest;
import com.iflytek.skillhub.dto.RuntimeBindingResponse;
import com.iflytek.skillhub.dto.SaveDraftFileRequest;
import com.iflytek.skillhub.dto.SaveDraftFileResponse;
import com.iflytek.skillhub.dto.ValidationEventResponse;
@ -21,6 +24,7 @@ import jakarta.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
@ -34,26 +38,30 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* REST endpoints for skill draft authoring and structure validation. Drafts
* own a SKILL.md scaffold and arbitrary resource files; validation runs
* execute the STRUCTURE layer and report findings with fix suggestions.
* Runtime binding, behavior execution and the publish integration arrive with
* the later layers.
* REST endpoints for skill draft authoring and validation. Drafts own a
* SKILL.md scaffold and arbitrary resource files; runtime bindings configure
* the agent and tool allowlist; validation runs execute the structure, config
* and behavior layers and report findings with fix suggestions. The MCP probe
* and the binding security policy arrive with the MCP+security layer, and the
* fix-apply loop and publish integration with the next layer.
*/
@RestController
@RequestMapping({"/api/v1/authoring", "/api/web/authoring"})
public class SkillAuthoringController extends BaseApiController {
private final SkillDraftService draftService;
private final RuntimeBindingService bindingService;
private final ValidationRunService runService;
private final ValidationRunOrchestrator orchestrator;
public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory,
SkillDraftService draftService,
RuntimeBindingService bindingService,
ValidationRunService runService,
ValidationRunOrchestrator orchestrator) {
super(responseFactory);
this.draftService = draftService;
this.bindingService = bindingService;
this.runService = runService;
this.orchestrator = orchestrator;
}
@ -155,6 +163,42 @@ public class SkillAuthoringController extends BaseApiController {
return ok("response.success.deleted", null);
}
// ---------------------------------------------------------------- runtime binding
@Operation(operationId = "getDraftRuntimeBinding", summary = "Get the draft's runtime binding")
@GetMapping("/drafts/{draftId}/runtime")
public ApiResponse<RuntimeBindingResponse> getRuntime(@PathVariable Long draftId,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "platformRoles", required = false)
Set<String> platformRoles) {
draftService.getOwnedDraft(draftId, userId, platformRoles);
return ok("response.success", bindingService.findBinding(draftId)
.map(this::toRuntimeResponse)
.orElseGet(() -> new RuntimeBindingResponse(null, Map.of(), List.of(), List.of(), null)));
}
@Operation(operationId = "saveDraftRuntimeBinding", summary = "Configure the agent runtime, tools, and MCP servers")
@PutMapping("/drafts/{draftId}/runtime")
public ApiResponse<RuntimeBindingResponse> saveRuntime(@PathVariable Long draftId,
@Valid @RequestBody RuntimeBindingRequest request,
@RequestAttribute("userId") String userId,
@RequestAttribute(value = "platformRoles", required = false)
Set<String> platformRoles) {
return ok("response.success.updated", toRuntimeResponse(bindingService.saveBinding(
draftId, userId, request.agentType(), request.config(),
request.toolAllowlist(), request.mcpServers(), platformRoles)));
}
private RuntimeBindingResponse toRuntimeResponse(
com.iflytek.skillhub.domain.authoring.RuntimeBinding binding) {
return new RuntimeBindingResponse(
binding.getAgentType().identifier(),
binding.getConfig() == null ? Map.of() : binding.getConfig(),
binding.getToolAllowlist() == null ? List.of() : binding.getToolAllowlist(),
binding.getMcpServers() == null ? List.of() : binding.getMcpServers(),
binding.getUpdatedAt());
}
// ---------------------------------------------------------------- validation runs
@Operation(operationId = "startValidationRun", summary = "Start a validation run for the draft's current revision")

View file

@ -0,0 +1,18 @@
package com.iflytek.skillhub.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.List;
import java.util.Map;
/**
* Request body for the per-draft runtime binding: which agent runtime executes
* behavior tasks, its adapter configuration, the tool allowlist, and MCP server
* declarations. Credentials must be referenced by name, never inlined.
*/
public record RuntimeBindingRequest(
@NotBlank(message = "{error.badRequest}") String agentType,
Map<String, Object> config,
List<String> toolAllowlist,
List<Map<String, Object>> mcpServers
) {
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.dto;
import java.time.Instant;
import java.util.List;
import java.util.Map;
/** Runtime binding as seen by the authoring UI. */
public record RuntimeBindingResponse(
String agentType,
Map<String, Object> config,
List<String> toolAllowlist,
List<Map<String, Object>> mcpServers,
Instant updatedAt
) {
}

View file

@ -1,9 +1,22 @@
package com.iflytek.skillhub.service.authoring;
import com.iflytek.skillhub.config.AuthoringProperties;
import com.iflytek.skillhub.domain.authoring.RuntimeBinding;
import com.iflytek.skillhub.domain.authoring.runtime.AssertionEvaluator;
import com.iflytek.skillhub.domain.authoring.runtime.AssertionFailure;
import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink;
import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext;
import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter;
import com.iflytek.skillhub.domain.authoring.runtime.TaskResult;
import com.iflytek.skillhub.domain.authoring.service.DraftStructureValidator;
import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService;
import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingValidator;
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
import com.iflytek.skillhub.domain.authoring.service.ValidationRunService;
import com.iflytek.skillhub.domain.authoring.spec.TaskType;
import com.iflytek.skillhub.domain.authoring.spec.ValidationSpec;
import com.iflytek.skillhub.domain.authoring.spec.ValidationSpecParser;
import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec;
import com.iflytek.skillhub.domain.authoring.validation.FindingDraft;
import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity;
import com.iflytek.skillhub.domain.authoring.validation.ValidationEvent;
@ -13,62 +26,88 @@ import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer;
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus;
import com.iflytek.skillhub.domain.shared.exception.DomainConflictException;
import com.iflytek.skillhub.domain.skill.validation.PackageEntry;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executor;
import java.util.concurrent.FutureTask;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.RejectedExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
/**
* Executes validation runs for skill drafts. This layer runs the STRUCTURE
* phase: package layout, SKILL.md frontmatter and file limits, reported as
* structured findings with fix suggestions. The CONFIG and BEHAVIOR phases
* (runtime binding, MCP probing, script/LLM task execution) arrive with the
* runtime layer.
* Executes validation runs for skill drafts. The STRUCTURE layer validates package
* layout and SKILL.md frontmatter; the CONFIG layer parses validation.yaml, checks
* the runtime binding and probes declared MCP servers; the BEHAVIOR layer executes
* the spec's tasks through the bound runtime adapter and evaluates assertions.
*
* <p>The MCP probe of declared servers and the OpenAI-compatible prompt
* adapter arrive with the MCP+security layer; this layer executes script
* tasks through the local-script (inline or docker) adapter.
*
* <p>Run lifecycle hardening follows the merged upstream review: one FutureTask per
* run (duplicate submits are ignored), a hard wall-clock watchdog on the timer,
* cancellation settles first and then interrupts the worker, and the terminal
* event is appended exactly once.
*/
@Service
public class ValidationRunOrchestrator {
private static final Logger log = LoggerFactory.getLogger(ValidationRunOrchestrator.class);
private static final String SPEC_FILE = ValidationSpec.FILE_PATH;
private static final int MAX_EVENT_TEXT = 4_000;
private final ValidationRunService runService;
private final SkillDraftService draftService;
private final RuntimeBindingService bindingService;
private final DraftStructureValidator structureValidator;
private final RuntimeBindingValidator bindingValidator;
private final ValidationSpecParser specParser = new ValidationSpecParser();
private final AssertionEvaluator assertionEvaluator = new AssertionEvaluator();
private final List<SkillRuntimeAdapter> adapters;
private final ValidationEventBroadcaster broadcaster;
private final AuthoringProperties properties;
private final Executor executor;
private final ScheduledExecutorService timer;
private final AuthoringProperties properties;
private final Map<Long, FutureTask<Void>> tasks = new ConcurrentHashMap<>();
public ValidationRunOrchestrator(ValidationRunService runService,
SkillDraftService draftService,
RuntimeBindingService bindingService,
DraftStructureValidator structureValidator,
RuntimeBindingValidator bindingValidator,
List<SkillRuntimeAdapter> adapters,
ValidationEventBroadcaster broadcaster,
AuthoringProperties properties,
@Qualifier("authoringValidationExecutor") Executor executor,
@Qualifier("authoringValidationTimer") ScheduledExecutorService timer,
AuthoringProperties properties) {
@Qualifier("authoringValidationTimer") ScheduledExecutorService timer) {
this.runService = runService;
this.draftService = draftService;
this.bindingService = bindingService;
this.structureValidator = structureValidator;
this.bindingValidator = bindingValidator;
this.adapters = List.copyOf(adapters);
this.broadcaster = broadcaster;
this.properties = properties;
this.executor = executor;
this.timer = timer;
this.properties = properties;
}
/** Enqueues one QUEUED run for execution. */
/** Enqueues one QUEUED run for execution; duplicate submits for the same run are ignored. */
public void submit(Long runId) {
FutureTask<Void> task = new FutureTask<>(() -> { executeRun(runId); return null; }) {
@Override protected void done() { tasks.remove(runId, this); }
@ -168,6 +207,7 @@ public class ValidationRunOrchestrator {
"draftName", snapshot.name(),
"draftRevision", run.getDraftRevision()));
Path workspace = workspaceFor(runId);
try {
// ---- STRUCTURE layer
emit(runId, ValidationEventType.PHASE_STARTED, "STRUCTURE", Map.of());
@ -179,20 +219,191 @@ public class ValidationRunOrchestrator {
"errors", context.errorCount(ValidationLayer.STRUCTURE),
"warnings", context.warningCount(ValidationLayer.STRUCTURE)));
// ---- CONFIG layer
emit(runId, ValidationEventType.PHASE_STARTED, "CONFIG", Map.of());
ValidationSpec spec = runConfigLayer(context, run, snapshot.entries());
emit(runId, ValidationEventType.PHASE_FINISHED, "CONFIG", Map.of(
"errors", context.errorCount(ValidationLayer.CONFIG),
"warnings", context.warningCount(ValidationLayer.CONFIG)));
// ---- BEHAVIOR layer
emit(runId, ValidationEventType.PHASE_STARTED, "BEHAVIOR", Map.of());
boolean blocked = context.errorCount(ValidationLayer.STRUCTURE) > 0
|| context.errorCount(ValidationLayer.CONFIG) > 0;
if (blocked) {
emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR",
Map.of("skipped", true, "reason", "structure or configuration errors"));
} else {
materializeWorkspace(workspace, snapshot.entries());
BehaviorOutcome outcome = runBehaviorLayer(context, run, spec, workspace);
if (outcome.interrupted()) {
return; // already settled (CANCELLED / TIMED_OUT) with terminal event
}
emit(runId, ValidationEventType.PHASE_FINISHED, "BEHAVIOR", Map.of(
"skipped", false,
"tasksRun", context.tasksRun,
"tasksFailed", context.tasksFailed));
}
settle(runId,
context.errorCount == 0 ? ValidationRunStatus.SUCCEEDED : ValidationRunStatus.FAILED,
context.errorCount, context.warningCount, summary(context));
context.errorCount, context.warningCount, summary(context, blocked));
} catch (DomainConflictException exception) {
throw exception;
} catch (Exception exception) {
log.error("Validation pipeline for run {} failed", runId, exception);
settle(runId, ValidationRunStatus.FAILED, context.errorCount + 1, context.warningCount,
Map.of("internalError", truncate(String.valueOf(exception.getMessage()), 500)));
} finally {
deleteRecursively(workspace);
}
}
// ---------------------------------------------------------------- events
// ---------------------------------------------------------------- config layer
private ValidationSpec runConfigLayer(RunContext context, ValidationRun run,
List<PackageEntry> entries) {
String specContent = readEntryText(entries, SPEC_FILE);
ValidationSpec spec = ValidationSpec.empty();
if (specContent == null) {
context.recordFinding(FindingDraft.warning(ValidationLayer.CONFIG, "SPEC_MISSING",
SPEC_FILE, "validation.yaml is absent; behavior layer runs zero tasks"));
} else {
ValidationSpecParser.ParseOutcome outcome = specParser.parse(specContent);
outcome.errors().forEach(error -> context.recordFinding(FindingDraft.error(
ValidationLayer.CONFIG, error.ruleCode(), SPEC_FILE, error.message(), null)));
if (outcome.isValid()) {
spec = outcome.spec();
}
}
// The binding is checked (and its MCP servers probed) even when
// validation.yaml is absent: a binding that cannot work must fail the
// run here, not surface for the first time at submit.
RuntimeBinding binding = bindingService.findBinding(run.getDraftId()).orElse(null);
if (binding == null) {
if (!spec.safeTasks().isEmpty()) {
context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "BINDING_MISSING",
"No runtime binding configured, but validation.yaml declares "
+ spec.safeTasks().size() + " task(s)"));
}
return spec;
}
bindingValidator.validate(
binding.getAgentType().identifier(),
binding.getConfig(),
binding.getToolAllowlist(),
binding.getMcpServers())
.forEach(context::recordFinding);
SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier());
if (adapter == null) {
context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "ADAPTER_NOT_FOUND",
"No runtime adapter is registered for agent type '"
+ binding.getAgentType().identifier() + "'"));
} else if (!adapter.enabled()) {
context.recordFinding(FindingDraft.error(ValidationLayer.CONFIG, "RUNTIME_DISABLED",
"Runtime '" + binding.getAgentType().identifier()
+ "' is disabled in server configuration"));
}
for (ValidationTaskSpec task : spec.safeTasks()) {
assertionEvaluator.validateSyntax(task.safeAssertions(), task.type() == TaskType.PROMPT)
.forEach(problem -> context.recordFinding(FindingDraft.error(
ValidationLayer.CONFIG, "ASSERTION_INVALID", SPEC_FILE,
"task '" + task.name() + "': " + problem, null)));
}
return spec;
}
// ---------------------------------------------------------------- behavior layer
private BehaviorOutcome runBehaviorLayer(RunContext context, ValidationRun run,
ValidationSpec spec, Path workspace) {
RuntimeBinding binding = bindingService.findBinding(run.getDraftId()).orElse(null);
if (binding == null || spec.safeTasks().isEmpty()) {
return BehaviorOutcome.COMPLETED;
}
SkillRuntimeAdapter adapter = findAdapter(binding.getAgentType().identifier());
if (adapter == null || !adapter.enabled()) {
return BehaviorOutcome.COMPLETED; // already reported by the config layer
}
long deadline = System.currentTimeMillis() + properties.getRunTimeoutMs();
Map<String, Object> adapterConfig = binding.getConfig() == null
? Map.of() : binding.getConfig();
List<String> toolAllowlist = binding.getToolAllowlist() == null
? List.of() : binding.getToolAllowlist();
RuntimeExecutionContext executionContext = new RuntimeExecutionContext(
run.getId(), workspace, adapterConfig, toolAllowlist,
binding.getMcpServers() == null ? List.of() : binding.getMcpServers(),
// cancellation settles first and interrupts the worker, so the
// cooperative signal is the interrupt status of this thread
() -> Thread.currentThread().isInterrupted());
for (ValidationTaskSpec task : spec.safeTasks()) {
if (Thread.currentThread().isInterrupted()) {
return BehaviorOutcome.interrupted("cancelled");
}
if (System.currentTimeMillis() > deadline) {
settle(run.getId(), ValidationRunStatus.TIMED_OUT,
context.errorCount, context.warningCount, summary(context, false));
return BehaviorOutcome.interrupted("timeout");
}
executeTask(context, adapter, executionContext, task, workspace);
}
return BehaviorOutcome.COMPLETED;
}
private void executeTask(RunContext context, SkillRuntimeAdapter adapter,
RuntimeExecutionContext executionContext, ValidationTaskSpec task,
Path workspace) {
if (!adapter.supports(task.type())) {
context.tasksFailed++;
context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ADAPTER_TASK_UNSUPPORTED",
SPEC_FILE, "runtime '" + adapter.agentType() + "' cannot execute "
+ task.type() + " task '" + task.name() + "'", null));
return;
}
if (task.type() == TaskType.SCRIPT && !scriptExists(workspace, task.script())) {
context.tasksFailed++;
context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_SCRIPT_MISSING",
task.script(), "script file not found in the skill: " + task.script(), null));
return;
}
context.tasksRun++;
TaskResult result;
try {
result = adapter.execute(executionContext, task, context.sink());
} catch (Exception exception) {
context.tasksFailed++;
context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "TASK_FAILED",
SPEC_FILE, "task '" + task.name() + "' failed: "
+ truncate(String.valueOf(exception.getMessage()), 500), null));
return;
}
List<AssertionFailure> failures =
assertionEvaluator.evaluate(task.safeAssertions(), result);
if (failures.isEmpty()) {
emit(context.runId, ValidationEventType.LOG, "BEHAVIOR", Map.of(
"task", task.name(), "stream", "summary",
"line", "task '" + task.name() + "' passed "
+ task.safeAssertions().size() + " assertion(s)"));
return;
}
context.tasksFailed++;
for (AssertionFailure failure : failures) {
context.recordFinding(FindingDraft.error(ValidationLayer.BEHAVIOR, "ASSERTION_FAILED",
SPEC_FILE, "task '" + task.name() + "': " + failure.describe(), null));
}
}
// ---------------------------------------------------------------- events & terminal handling
/** Emits one event, persists it, and pushes it to live SSE subscribers. */
private void emit(Long runId, ValidationEventType type, String phase, Map<String, Object> payload) {
ValidationEvent event = runService.appendEvent(runId, type, phase, payload);
broadcaster.publish(runId, event);
@ -208,15 +419,75 @@ public class ValidationRunOrchestrator {
// ---------------------------------------------------------------- small helpers
private Map<String, Object> summary(RunContext context) {
private boolean scriptExists(Path workspace, String script) {
try {
Path resolved = workspace.resolve(script).normalize();
return resolved.startsWith(workspace.normalize()) && Files.isRegularFile(resolved);
} catch (Exception exception) {
return false;
}
}
private SkillRuntimeAdapter findAdapter(String agentType) {
return adapters.stream()
.filter(adapter -> adapter.agentType().equals(agentType))
.findFirst()
.orElse(null);
}
private Map<String, Object> summary(RunContext context, boolean behaviorSkipped) {
Map<String, Object> summary = new java.util.LinkedHashMap<>();
summary.put("structureErrors", context.errorCount(ValidationLayer.STRUCTURE));
summary.put("structureWarnings", context.warningCount(ValidationLayer.STRUCTURE));
summary.put("configErrors", context.errorCount(ValidationLayer.CONFIG));
summary.put("configWarnings", context.warningCount(ValidationLayer.CONFIG));
summary.put("behaviorTasksRun", context.tasksRun);
summary.put("behaviorTasksFailed", context.tasksFailed);
summary.put("behaviorSkipped", behaviorSkipped);
summary.put("errorCount", context.errorCount);
summary.put("warningCount", context.warningCount);
return summary;
}
private Path workspaceFor(Long runId) {
return Path.of(properties.getWorkspaceRoot(), "run-" + runId);
}
private void materializeWorkspace(Path workspace, List<PackageEntry> entries)
throws IOException {
Files.createDirectories(workspace);
for (PackageEntry entry : entries) {
Path target = workspace.resolve(entry.path()).normalize();
if (!target.startsWith(workspace.normalize())) {
throw new IOException("package entry escapes the workspace: " + entry.path());
}
Files.createDirectories(target.getParent());
Files.write(target, entry.content());
}
}
private String readEntryText(List<PackageEntry> entries, String path) {
return entries.stream()
.filter(entry -> entry.path().equals(path))
.findFirst()
.map(entry -> new String(entry.content(), StandardCharsets.UTF_8))
.orElse(null);
}
private void deleteRecursively(Path root) {
try (var stream = Files.walk(root)) {
stream.sorted(Comparator.reverseOrder()).forEach(p -> {
try {
Files.deleteIfExists(p);
} catch (IOException exception) {
log.warn("Could not delete workspace file {}", p, exception);
}
});
} catch (IOException exception) {
log.warn("Could not clean up workspace {}", root, exception);
}
}
private static String truncate(String value, int limit) {
if (value == null) {
return "";
@ -224,6 +495,15 @@ public class ValidationRunOrchestrator {
return value.length() <= limit ? value : value.substring(0, limit) + "…";
}
private record BehaviorOutcome(boolean interrupted, String reason) {
static BehaviorOutcome COMPLETED = new BehaviorOutcome(false, null);
static BehaviorOutcome interrupted(String reason) {
return new BehaviorOutcome(true, reason);
}
}
/** Per-run finding bookkeeping; each finding is persisted and emitted as an event. */
private final class RunContext {
@ -231,6 +511,8 @@ public class ValidationRunOrchestrator {
private final List<FindingDraft> findings = new ArrayList<>();
private int errorCount;
private int warningCount;
private int tasksRun;
private int tasksFailed;
RunContext(Long runId) {
this.runId = runId;
@ -267,5 +549,37 @@ public class ValidationRunOrchestrator {
"message", truncate(draft.message(), MAX_EVENT_TEXT),
"hasSuggestion", draft.suggestion() != null));
}
/** Serialized sink so per-run event sequence numbers cannot race. */
RuntimeEventSink sink() {
return new RuntimeEventSink() {
@Override
public synchronized void agentMessage(String taskName, String content) {
emit(runId, ValidationEventType.AGENT_MESSAGE, "BEHAVIOR", Map.of(
"task", taskName, "content", truncate(content, MAX_EVENT_TEXT)));
}
@Override
public synchronized void toolCall(String taskName, String tool, String argumentsJson) {
emit(runId, ValidationEventType.TOOL_CALL, "BEHAVIOR", Map.of(
"task", taskName, "tool", tool,
"arguments", truncate(argumentsJson, MAX_EVENT_TEXT)));
}
@Override
public synchronized void toolResult(String taskName, String tool, String summaryJson) {
emit(runId, ValidationEventType.TOOL_RESULT, "BEHAVIOR", Map.of(
"task", taskName, "tool", tool,
"summary", truncate(summaryJson, MAX_EVENT_TEXT)));
}
@Override
public synchronized void log(String taskName, String stream, String line) {
emit(runId, ValidationEventType.LOG, "BEHAVIOR", Map.of(
"task", taskName, "stream", stream,
"line", truncate(line, 2_000)));
}
};
}
}
}

View file

@ -0,0 +1,230 @@
package com.iflytek.skillhub.service.authoring.adapter;
import com.iflytek.skillhub.config.AuthoringProperties;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
/**
* Production backend: the script runs inside a throwaway container with a hard
* isolation profile — no network, memory/CPU/pids caps, read-only root filesystem,
* all capabilities dropped, no-new-privileges, no log persistence. Only the
* validation workspace is mounted (read-write) so scripts can produce artifacts;
* every other path the container sees is immutable or ephemeral tmpfs.
*
* <p>Network isolation is deliberately not configurable: a validation run must
* never be able to reach other services. The image must provide the configured
* interpreter (the default alpine ships {@code sh}; python/node skills need a
* matching image).
*/
@Component
public class DockerScriptCommandBuilder implements ScriptCommandBuilder {
/** Mount point of the validation workspace inside the container. */
public static final String CONTAINER_WORKSPACE = "/workspace";
private static final Logger log = LoggerFactory.getLogger(DockerScriptCommandBuilder.class);
private static final Duration PROBE_TIMEOUT = Duration.ofSeconds(5);
private static final Duration CLEANUP_TIMEOUT = Duration.ofSeconds(5);
private final AuthoringProperties properties;
private volatile Boolean daemonReachable;
public DockerScriptCommandBuilder(AuthoringProperties properties) {
this.properties = properties;
}
@Override
public String backend() {
return "docker";
}
@Override
public boolean available() {
Boolean cached = daemonReachable;
if (cached == null) {
cached = probeDaemon();
daemonReachable = cached;
}
return cached;
}
/** Re-probes the daemon on the next {@link #available()} call. */
void resetProbe() {
daemonReachable = null;
}
private boolean probeDaemon() {
try {
Process process = new ProcessBuilder("docker", "info", "--format", "{{.ServerVersion}}")
.start();
if (!process.waitFor(PROBE_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
} catch (IOException | InterruptedException exception) {
return false;
}
}
@Override
public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath,
List<String> args, Map<String, String> scrubbedEnvironment,
String runTag) {
AuthoringProperties.LocalScript.Docker docker = properties.getLocalScript().getDocker();
String hostWorkspace = realPath(workspace);
ensureContainerReadable(workspace);
List<String> command = new ArrayList<>();
command.add("docker");
command.add("run");
command.add("--rm");
command.add("--name");
command.add(containerName(runTag));
hostUser().ifPresent(user -> {
command.add("--user");
command.add(user);
});
command.add("--network");
command.add("none");
command.add("--memory");
command.add(docker.getMemory());
command.add("--cpus");
command.add(docker.getCpus());
command.add("--pids-limit");
command.add(String.valueOf(docker.getPidsLimit()));
command.add("--read-only");
command.add("--tmpfs");
command.add("/tmp:rw,size=" + docker.getTmpfsSize());
command.add("--cap-drop");
command.add("ALL");
command.add("--security-opt");
command.add("no-new-privileges");
command.add("--log-driver");
command.add("none");
for (Map.Entry<String, String> entry : scrubbedEnvironment.entrySet()) {
command.add("-e");
command.add(entry.getKey() + "=" + containerEnvValue(entry.getKey(), entry.getValue()));
}
command.add("-v");
command.add(hostWorkspace + ":" + CONTAINER_WORKSPACE);
command.add("-w");
command.add(CONTAINER_WORKSPACE);
command.add(docker.getImage());
command.add(interpreter);
command.add(containerScriptPath(workspace, scriptPath));
command.addAll(args);
// the docker CLI needs its own environment to reach the daemon; the script
// only sees the -e variables above, inside the container
return ScriptExecutionPlan.inherited(List.copyOf(command));
}
@Override
public void cleanup(String runTag) {
try {
Process process = new ProcessBuilder("docker", "rm", "-f", containerName(runTag)).start();
if (!process.waitFor(CLEANUP_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS)) {
process.destroyForcibly();
}
} catch (IOException | InterruptedException exception) {
log.warn("Could not force-remove validation container {}: {}",
containerName(runTag), exception.getMessage());
}
}
/** Cached "uid:gid" of the host JVM user, so container-created files stay deletable. */
private static final java.util.Optional<String> HOST_USER = resolveHostUser();
private static java.util.Optional<String> resolveHostUser() {
try {
String uid = readCommandOutput("id", "-u").trim();
String gid = readCommandOutput("id", "-g").trim();
if (uid.matches("\\d+") && gid.matches("\\d+")) {
return java.util.Optional.of(uid + ":" + gid);
}
} catch (Exception exception) {
// non-POSIX host (e.g. Windows without id): run as the image default
}
return java.util.Optional.empty();
}
private static String readCommandOutput(String... command) throws Exception {
Process process = new ProcessBuilder(command).start();
String output;
try (var reader = process.inputReader()) {
output = reader.lines().findFirst().orElse("");
}
process.waitFor();
return output;
}
private static java.util.Optional<String> hostUser() {
return HOST_USER;
}
/**
* The container drops every capability (including DAC_OVERRIDE), so the
* workspace files must be world-readable (and directories traversable)
* on the host or the in-container interpreter cannot open them. Fresh JVM
* temp directories are owner-only on Linux, which would break script
* execution there; macOS Docker Desktop masks this by remapping
* ownership inside the VM.
*/
private static void ensureContainerReadable(Path root) {
try (var stream = Files.walk(root)) {
stream.forEach(path -> {
try {
if (Files.isDirectory(path)) {
// traversable and writable so the script can create
// artifacts inside the mounted workspace
path.toFile().setExecutable(true, false);
path.toFile().setWritable(true, false);
}
path.toFile().setReadable(true, false);
} catch (Exception exception) {
// best effort: a locked file surfaces as a task failure
// with the interpreter's own error message
}
});
} catch (IOException exception) {
// the mount itself will fail visibly if the tree is unreadable
}
}
/** Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]+. */
static String containerName(String runTag) {
String sanitized = runTag == null ? "unknown" : runTag.replaceAll("[^a-zA-Z0-9_.-]", "-");
if (sanitized.isEmpty() || !Character.isLetterOrDigit(sanitized.charAt(0))) {
sanitized = "r" + sanitized;
}
return "skillhub-validation-" + sanitized;
}
private String containerScriptPath(Path workspace, Path scriptPath) {
return CONTAINER_WORKSPACE + "/" + workspace.relativize(scriptPath);
}
/** HOME points at the workspace; inside the container that is the mount point. */
private String containerEnvValue(String key, String value) {
return "HOME".equals(key) ? CONTAINER_WORKSPACE : value;
}
/** Resolves symlinks (e.g. macOS /var/folders → /private/var/folders) for bind mounts. */
private String realPath(Path workspace) {
try {
return workspace.toRealPath().toString();
} catch (IOException exception) {
return workspace.toAbsolutePath().toString();
}
}
}

View file

@ -0,0 +1,38 @@
package com.iflytek.skillhub.service.authoring.adapter;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Default backend: the script runs as a plain subprocess of the server process
* with a scrubbed environment. Suitable for local development and CI where the
* drafts are the developer's own; production deployments should switch to the
* docker backend.
*/
@Component
public class InlineScriptCommandBuilder implements ScriptCommandBuilder {
@Override
public String backend() {
return "inline";
}
@Override
public boolean available() {
return true;
}
@Override
public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath,
List<String> args, Map<String, String> scrubbedEnvironment,
String runTag) {
List<String> command = new ArrayList<>();
command.add(interpreter);
command.add(scriptPath.toString());
command.addAll(args);
return new ScriptExecutionPlan(List.copyOf(command), Map.copyOf(scrubbedEnvironment));
}
}

View file

@ -0,0 +1,237 @@
package com.iflytek.skillhub.service.authoring.adapter;
import com.iflytek.skillhub.config.AuthoringProperties;
import com.iflytek.skillhub.domain.authoring.runtime.RuntimeEventSink;
import com.iflytek.skillhub.domain.authoring.runtime.RuntimeExecutionContext;
import com.iflytek.skillhub.domain.authoring.runtime.SkillRuntimeAdapter;
import com.iflytek.skillhub.domain.authoring.runtime.TaskResult;
import com.iflytek.skillhub.domain.authoring.spec.TaskType;
import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;
import org.springframework.stereotype.Component;
/**
* Behavior-layer adapter that executes the skill's own scripts in an isolated working
* directory with a scrubbed environment. Only whitelisted interpreters may run and only
* environment variables named in the binding's {@code envAllowlist} pass through.
*
* <p>Where the process actually runs is delegated to a {@link ScriptCommandBuilder}:
* the inline backend spawns the interpreter on the server host, the docker backend
* wraps it in a locked-down container. Everything else (streaming, buffering, timeout,
* cancel) is backend-independent.
*/
@Component
public class LocalScriptRuntimeAdapter implements SkillRuntimeAdapter {
private static final int MAX_STREAM_BUFFER = 256 * 1024;
private static final long POLL_SLICE_MS = 200;
private final AuthoringProperties properties;
private final Map<String, ScriptCommandBuilder> commandBuilders;
public LocalScriptRuntimeAdapter(AuthoringProperties properties,
List<ScriptCommandBuilder> commandBuilders) {
this.properties = properties;
this.commandBuilders = commandBuilders.stream()
.collect(Collectors.toUnmodifiableMap(ScriptCommandBuilder::backend,
Function.identity()));
}
@Override
public String agentType() {
return "local-script";
}
@Override
public boolean supports(TaskType taskType) {
return taskType == TaskType.SCRIPT;
}
@Override
public boolean enabled() {
return properties.getLocalScript().isEnabled() && commandBuilder().available();
}
@Override
public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task,
RuntimeEventSink sink) throws Exception {
ScriptCommandBuilder builder = commandBuilder();
if (!builder.available()) {
throw new IllegalStateException(
"script execution backend '" + builder.backend() + "' is not available"
+ " (is the docker daemon reachable?)");
}
String interpreter = resolveInterpreter(context);
Path scriptPath = resolveInsideWorkingDirectory(context.workingDirectory(), task.script());
String runTag = context.runId() + "-" + task.name();
Map<String, String> scrubbedEnvironment = scrubEnvironment(context);
ScriptCommandBuilder.ScriptExecutionPlan plan = builder.plan(
context.workingDirectory(), interpreter, scriptPath,
task.safeArgs(), scrubbedEnvironment, runTag);
sink.toolCall(task.name(), interpreter + " [" + builder.backend() + "]",
String.join(" ", task.safeArgs()));
ProcessBuilder processBuilder = new ProcessBuilder(plan.command());
processBuilder.directory(context.workingDirectory().toFile());
processBuilder.redirectErrorStream(false);
if (plan.environment() != null) {
processBuilder.environment().clear();
processBuilder.environment().putAll(plan.environment());
}
Process process = processBuilder.start();
BoundedBuffer stdout = new BoundedBuffer(MAX_STREAM_BUFFER);
BoundedBuffer stderr = new BoundedBuffer(MAX_STREAM_BUFFER);
Thread stdoutReader = streamReader(process.getInputStream(), "stdout", task.name(), stdout, sink);
Thread stderrReader = streamReader(process.getErrorStream(), "stderr", task.name(), stderr, sink);
stdoutReader.start();
stderrReader.start();
Integer exitCode = await(process, task, context, builder, runTag);
stdoutReader.join(TimeUnit.SECONDS.toMillis(5));
stderrReader.join(TimeUnit.SECONDS.toMillis(5));
sink.toolResult(task.name(), interpreter,
"{\"backend\":\"" + builder.backend() + "\",\"exitCode\":" + exitCode
+ ",\"stdoutBytes\":" + stdout.size()
+ ",\"stderrBytes\":" + stderr.size() + "}");
return TaskResult.ofScript(exitCode, stdout.content(), stderr.content(), 1,
new WorkingDirectoryArtifacts(context.workingDirectory()));
}
private ScriptCommandBuilder commandBuilder() {
String mode = properties.getLocalScript().getExecutionMode()
== AuthoringProperties.ScriptExecutionMode.DOCKER ? "docker" : "inline";
ScriptCommandBuilder builder = commandBuilders.get(mode);
if (builder == null) {
throw new IllegalStateException(
"no script command builder registered for execution mode: " + mode);
}
return builder;
}
private String resolveInterpreter(RuntimeExecutionContext context) {
String configured = context.configString("interpreter");
if (configured != null && !configured.isBlank()) {
return configured;
}
return "sh";
}
private Map<String, String> scrubEnvironment(RuntimeExecutionContext context) {
Map<String, String> environment = new java.util.LinkedHashMap<>();
environment.put("PATH", System.getenv().getOrDefault("PATH", "/usr/bin:/bin"));
environment.put("HOME", context.workingDirectory().toString());
environment.put("LANG", "C.UTF-8");
Object allowlist = context.safeAdapterConfig().get("envAllowlist");
if (allowlist instanceof List<?> entries) {
for (Object entry : entries) {
if (entry != null) {
String value = System.getenv(entry.toString());
if (value != null) {
environment.put(entry.toString(), value);
}
}
}
}
return environment;
}
/**
* Waits for process exit while honoring the task timeout and the cooperative cancel
* signal. Returns the exit code, or {@code null} when the process had to be killed;
* abnormal exits also trigger the builder's cleanup (the docker backend must
* force-remove the container because killing the CLI client does not stop it).
*/
private Integer await(Process process, ValidationTaskSpec task, RuntimeExecutionContext context,
ScriptCommandBuilder builder, String runTag) throws InterruptedException {
long deadline = System.nanoTime() + Duration.ofMillis(task.timeoutMs()).toNanos();
while (true) {
if (process.waitFor(POLL_SLICE_MS, TimeUnit.MILLISECONDS)) {
return process.exitValue();
}
if (context.cancellation() != null && context.cancellation().isCancelRequested()) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
builder.cleanup(runTag);
return null;
}
if (System.nanoTime() > deadline) {
process.destroyForcibly();
process.waitFor(5, TimeUnit.SECONDS);
builder.cleanup(runTag);
throw new IllegalStateException(
"task '" + task.name() + "' exceeded its timeout of " + task.timeoutMs() + " ms");
}
}
}
private Thread streamReader(java.io.InputStream stream, String streamName, String taskName,
BoundedBuffer buffer, RuntimeEventSink sink) {
Thread thread = new Thread(() -> {
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(stream, StandardCharsets.UTF_8))) {
String line;
while ((line = reader.readLine()) != null) {
buffer.append(line);
sink.log(taskName, streamName, line);
}
} catch (IOException ignored) {
// stream closed when the process dies; buffered content is kept
}
}, "validation-stream-" + streamName);
thread.setDaemon(true);
return thread;
}
static Path resolveInsideWorkingDirectory(Path workingDirectory, String relativePath) {
Path resolved = workingDirectory.resolve(relativePath).normalize();
if (!resolved.startsWith(workingDirectory.normalize())) {
throw new IllegalArgumentException(
"path escapes the working directory: " + relativePath);
}
return resolved;
}
/** Cap-aware single-stream buffer: keeps the head of the output for findings. */
private static final class BoundedBuffer {
private final StringBuilder builder = new StringBuilder();
private final int limit;
BoundedBuffer(int limit) {
this.limit = limit;
}
synchronized void append(String line) {
if (builder.length() >= limit) {
return;
}
if (builder.length() > 0) {
builder.append('\n');
}
builder.append(line, 0, Math.min(line.length(), limit - builder.length()));
}
synchronized String content() {
return builder.toString();
}
synchronized int size() {
return builder.length();
}
}
}

View file

@ -0,0 +1,53 @@
package com.iflytek.skillhub.service.authoring.adapter;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* Builds the process command for one behavior-task script execution. Two backends
* ship: {@code inline} runs the script as a subprocess on the server host (local
* development only); {@code docker} runs it inside a locked-down container (no
* network, memory/CPU/pids caps, read-only root filesystem) for production.
*/
public interface ScriptCommandBuilder {
/** Backend identifier matching {@code skillhub.authoring.local-script.execution-mode}. */
String backend();
/** Whether the backend can execute right now (for docker: daemon reachable). */
boolean available();
/**
* Builds the execution plan for one task. {@code scrubbedEnvironment} carries
* exactly the variables the script may see; backends decide how to deliver
* them (inline: process environment; docker: {@code -e} flags, with the
* workspace path remapped to its mount point inside the container).
*
* @param runTag stable per-task tag (run id + task name), used for container
* naming so abnormal exits can be force-removed
*/
ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptPath,
List<String> args, Map<String, String> scrubbedEnvironment,
String runTag);
/**
* Best-effort cleanup after the wrapping process was killed (timeout or
* cancel). Killing the {@code docker run} client does not stop the container,
* so the docker backend force-removes it here.
*/
default void cleanup(String runTag) {
}
/**
* Command line plus environment. A null environment means the spawned client
* (e.g. the docker CLI) needs the parent's environment intact; the isolation
* then lives in the container flags instead.
*/
record ScriptExecutionPlan(List<String> command, Map<String, String> environment) {
public static ScriptExecutionPlan inherited(List<String> command) {
return new ScriptExecutionPlan(command, null);
}
}
}

View file

@ -0,0 +1,30 @@
package com.iflytek.skillhub.service.authoring.adapter;
import com.iflytek.skillhub.domain.authoring.runtime.TaskResult;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
/**
* Reads artifact files from a run's isolated working directory, rejecting paths that
* would escape it. Shared by all runtime adapters.
*/
record WorkingDirectoryArtifacts(Path workingDirectory) implements TaskResult.ArtifactResolver {
@Override
public Optional<byte[]> read(String relativePath) {
try {
Path resolved = workingDirectory.resolve(relativePath).normalize();
if (!resolved.startsWith(workingDirectory.normalize())) {
return Optional.empty();
}
if (!Files.isRegularFile(resolved)) {
return Optional.empty();
}
return Optional.of(Files.readAllBytes(resolved));
} catch (IllegalArgumentException | IOException exception) {
return Optional.empty();
}
}
}

View file

@ -142,6 +142,21 @@ skillhub:
run-timeout-ms: ${SKILLHUB_AUTHORING_RUN_TIMEOUT_MS:900000}
# Active runs older than this are swept to TIMED_OUT (crash recovery)
stale-run-minutes: ${SKILLHUB_AUTHORING_STALE_RUN_MINUTES:30}
maintenance-interval-ms: ${SKILLHUB_AUTHORING_MAINTENANCE_INTERVAL_MS:30000}
local-script:
enabled: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_ENABLED:true}
# docker is the production default: scripts run in a locked-down container —
# no network, memory/cpu/pids caps, read-only rootfs, all capabilities dropped.
# inline runs subprocesses on the server host and is only allowed with a
# local/dev/test profile (the startup guard fails fast otherwise).
execution-mode: ${SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE:docker}
docker:
# must provide the interpreters your skills use (alpine ships sh only)
image: ${SKILLHUB_AUTHORING_DOCKER_IMAGE:alpine:3.20}
memory: ${SKILLHUB_AUTHORING_DOCKER_MEMORY:256m}
cpus: ${SKILLHUB_AUTHORING_DOCKER_CPUS:1.0}
pids-limit: ${SKILLHUB_AUTHORING_DOCKER_PIDS_LIMIT:128}
tmpfs-size: ${SKILLHUB_AUTHORING_DOCKER_TMPFS_SIZE:64m}
github-sync:
discovery-scheduler-interval-ms: ${SKILLHUB_GITHUB_SYNC_DISCOVERY_SCHEDULER_INTERVAL_MS:60000}
candidate-scheduler-interval-ms: ${SKILLHUB_GITHUB_SYNC_CANDIDATE_SCHEDULER_INTERVAL_MS:60000}

View file

@ -392,3 +392,8 @@ error.githubSync.deepDiscovery.exclusionInvalid=Exclusions must be valid GitHub
error.githubSync.deepDiscovery.configurationConflict=Official discovery configuration has changed. Reload the latest configuration before saving
error.githubSync.deepDiscovery.queryInvalid=Invalid deep discovery query parameters.
# authoring runtime bindings
error.authoring.binding.notFound=No runtime binding configured for draft {0}
error.authoring.binding.agentType.unknown=Unknown agent runtime type: {0}
error.authoring.binding.invalid=Invalid runtime binding: {0}
error.authoring.binding.unserializable=Runtime binding could not be serialized

View file

@ -335,3 +335,8 @@ error.githubSync.deepDiscovery.exclusionInvalid=Исключения должн
error.githubSync.deepDiscovery.configurationConflict=Конфигурация официального поиска изменена. Загрузите актуальную конфигурацию перед сохранением
error.githubSync.deepDiscovery.queryInvalid=Недопустимые параметры запроса глубокого обнаружения.
# authoring runtime bindings
error.authoring.binding.notFound=Для черновика {0} не настроена привязка среды выполнения
error.authoring.binding.agentType.unknown=Неизвестный тип среды выполнения агента: {0}
error.authoring.binding.invalid=Недопустимая привязка среды выполнения: {0}
error.authoring.binding.unserializable=Привязка среды выполнения не может быть сериализована

View file

@ -394,3 +394,8 @@ error.githubSync.deepDiscovery.exclusionInvalid=排除项须为有效的GitHub
error.githubSync.deepDiscovery.configurationConflict=官方探索配置已被修改,请重新读取最新配置后再保存
error.githubSync.deepDiscovery.queryInvalid=深度探索查询参数无效。
# authoring runtime bindings
error.authoring.binding.notFound=草稿 {0} 尚未配置运行时绑定
error.authoring.binding.agentType.unknown=未知的 Agent 运行时类型:{0}
error.authoring.binding.invalid=运行时绑定不合法:{0}
error.authoring.binding.unserializable=运行时绑定无法序列化

View file

@ -213,9 +213,13 @@ class AuthoringDraftFlowIntegrationTest {
assertThat(failed.getStatus()).isEqualTo(ValidationRunStatus.FAILED);
List<ValidationFinding> findings = runService.listFindings(broken.getId());
assertThat(findings).hasSize(1);
ValidationFinding finding = findings.get(0);
assertThat(finding.getRuleCode()).isEqualTo("FRONTMATTER_FIELD_MISSING");
// the config layer (added with the execution layer) also warns that
// validation.yaml is absent — the structure ERROR is what fails the run
ValidationFinding finding = findings.stream()
.filter(f -> "FRONTMATTER_FIELD_MISSING".equals(f.getRuleCode()))
.findFirst().orElseThrow();
assertThat(finding.getSeverity()).isEqualTo(
com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.ERROR);
assertThat(finding.getSuggestion()).isNotNull();
assertThat(finding.getSuggestion().safePatches()).hasSize(1);
assertThat(runService.listEvents(broken.getId(), null))
@ -233,7 +237,10 @@ class AuthoringDraftFlowIntegrationTest {
assertThat(succeeded.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED);
assertThat(succeeded.getErrorCount()).isZero();
assertThat(runService.listFindings(repaired.getId())).isEmpty();
// warnings (SPEC_MISSING for the absent validation.yaml) do not block
assertThat(runService.listFindings(repaired.getId()))
.extracting(ValidationFinding::getSeverity)
.doesNotContain(com.iflytek.skillhub.domain.authoring.validation.FindingSeverity.ERROR);
assertThat(draftService.getDraft(draft.getId()).getValidatedRevision())
.isEqualTo(succeeded.getDraftRevision());
}

View file

@ -0,0 +1,178 @@
package com.iflytek.skillhub.authoring;
import com.iflytek.skillhub.TestRedisConfig;
import com.iflytek.skillhub.domain.authoring.SkillDraft;
import com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService;
import com.iflytek.skillhub.domain.authoring.service.SkillDraftService;
import com.iflytek.skillhub.domain.authoring.service.ValidationRunService;
import com.iflytek.skillhub.domain.authoring.validation.ValidationRun;
import com.iflytek.skillhub.domain.authoring.validation.ValidationRunStatus;
import com.iflytek.skillhub.domain.namespace.Namespace;
import com.iflytek.skillhub.domain.namespace.NamespaceMember;
import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRepository;
import com.iflytek.skillhub.domain.namespace.NamespaceRole;
import com.iflytek.skillhub.domain.user.UserAccount;
import com.iflytek.skillhub.domain.user.UserAccountRepository;
import com.iflytek.skillhub.service.authoring.ValidationRunOrchestrator;
import com.iflytek.skillhub.storage.ObjectStorageService;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.fail;
/**
* Execution layer of the authoring platform: three-layer validation with a
* local-script binding (structure + config + behavior, the script subprocess
* executed inline under the test profile). The MCP probe and the binding
* security policy arrive with the MCP+security layer.
*/
@SpringBootTest
@ActiveProfiles("test")
@Import({TestRedisConfig.class, AuthoringDraftFlowIntegrationTest.InMemoryStorageConfig.class})
@Testcontainers
class AuthoringExecutionFlowIntegrationTest {
@Container
private static final PostgreSQLContainer<?> POSTGRES =
new PostgreSQLContainer<>("postgres:16-alpine");
@DynamicPropertySource
static void configurePostgres(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
registry.add("spring.datasource.username", POSTGRES::getUsername);
registry.add("spring.datasource.password", POSTGRES::getPassword);
registry.add("spring.datasource.driver-class-name", () -> "org.postgresql.Driver");
registry.add("spring.jpa.database-platform", () -> "org.hibernate.dialect.PostgreSQLDialect");
registry.add("spring.flyway.enabled", () -> true);
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate");
}
private static final String USER = "author-user-2";
private static final String NS_SLUG = "authoring-test-ns";
@Autowired private SkillDraftService draftService;
@Autowired private RuntimeBindingService bindingService;
@Autowired private ValidationRunService runService;
@Autowired private ValidationRunOrchestrator orchestrator;
@Autowired private NamespaceRepository namespaceRepository;
@Autowired private NamespaceMemberRepository namespaceMemberRepository;
@Autowired private UserAccountRepository userAccountRepository;
@BeforeEach
void setUpNamespace() {
if (userAccountRepository.findById(USER).isEmpty()) {
userAccountRepository.save(new UserAccount(USER, "Author User", "author@example.com", null));
}
if (namespaceRepository.findBySlug(NS_SLUG).isEmpty()) {
Namespace namespace = new Namespace(NS_SLUG, "Authoring Test", USER);
namespace.setStatus(com.iflytek.skillhub.domain.namespace.NamespaceStatus.ACTIVE);
Namespace saved = namespaceRepository.save(namespace);
if (namespaceMemberRepository.findByNamespaceIdAndUserId(saved.getId(), USER).isEmpty()) {
namespaceMemberRepository.save(new NamespaceMember(saved.getId(), USER, NamespaceRole.MEMBER));
}
}
}
@Test
void threeLayerValidationExecutesScriptTasks() {
SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "runtime-flow",
"summarizes documents", null);
draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd().getBytes(StandardCharsets.UTF_8),
"text/markdown", null, null);
draftService.saveFile(draft.getId(), USER, "scripts/check.sh",
"echo 'smoke OK'\n".getBytes(StandardCharsets.UTF_8), null, null, null);
draftService.saveFile(draft.getId(), USER, "validation.yaml",
validationYaml().getBytes(StandardCharsets.UTF_8), null, null, null);
// ---- bind the local-script runtime
bindingService.saveBinding(draft.getId(), USER, "local-script",
Map.of("interpreter", "sh"), null, null, null);
// ---- validate: all three layers run, the script task executes inline
ValidationRun run = runService.startRun(draft.getId(), USER, null);
orchestrator.submit(run.getId());
ValidationRun finished = awaitTerminal(run.getId());
assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED);
assertThat(finished.getErrorCount()).isZero();
assertThat(runService.listFindings(run.getId())).isEmpty();
assertThat(runService.listEvents(run.getId(), null))
.extracting(event -> event.getEventType().name())
.contains("RUN_STARTED", "TOOL_CALL", "LOG", "RUN_FINISHED");
assertThat(runService.listEvents(run.getId(), null).stream()
.map(event -> event.getPayload() == null ? "" : event.getPayload().toString())
.filter(payload -> payload.contains("smoke OK"))
.count()).isPositive();
SkillDraft validated = draftService.getDraft(draft.getId());
assertThat(validated.isCurrentRevisionValidated()).isTrue();
}
private String validSkillMd() {
return "---\nname: runtime-flow\ndescription: summarizes documents\n---\n\n# runtime-flow\n";
}
private String validationYaml() {
return """
version: 1
tasks:
- name: smoke
description: script prints the greeting
type: script
script: scripts/check.sh
args: []
timeoutMs: 10000
assertions:
- type: exit_code
equals: 0
- type: stdout_contains
value: smoke OK
""";
}
private static void assertBindingRejected(Runnable save, String reasonFragment) {
try {
save.run();
fail("binding save should have been rejected: " + reasonFragment);
} catch (com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException exception) {
assertThat(String.valueOf(exception.messageArgs()[0])).contains(reasonFragment);
}
}
private ValidationRun awaitTerminal(Long runId) {
long deadline = System.currentTimeMillis() + 60_000;
while (System.currentTimeMillis() < deadline) {
if (runService.getRun(runId).getStatus().isTerminal()) {
return runService.getRun(runId);
}
try {
Thread.sleep(200);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
fail("interrupted while waiting for the validation run to finish");
}
}
fail("validation run did not reach a terminal state in time");
return null;
}
}

View file

@ -30,6 +30,10 @@ class ValidationRunOrchestratorTest {
private final SkillDraftService drafts = mock(SkillDraftService.class);
private final DraftStructureValidator validator = mock(DraftStructureValidator.class);
private final ValidationEventBroadcaster broadcaster = mock(ValidationEventBroadcaster.class);
private final com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService bindings =
mock(com.iflytek.skillhub.domain.authoring.service.RuntimeBindingService.class);
private final com.iflytek.skillhub.domain.authoring.service.RuntimeBindingValidator bindingValidator =
mock(com.iflytek.skillhub.domain.authoring.service.RuntimeBindingValidator.class);
@Test
void duplicateClaimCannotEmitAnActiveRunAsFinished() {
@ -135,6 +139,7 @@ class ValidationRunOrchestratorTest {
ScheduledExecutorService timer, long timeout) {
AuthoringProperties properties = new AuthoringProperties();
properties.setRunTimeoutMs(timeout);
return new ValidationRunOrchestrator(runs, drafts, validator, broadcaster, executor, timer, properties);
return new ValidationRunOrchestrator(runs, drafts, bindings, validator, bindingValidator,
java.util.List.of(), broadcaster, properties, executor, timer);
}
}

View file

@ -49,8 +49,11 @@ skillhub:
security:
scanner:
enabled: false
authoring:
local-script:
# tests exercise the inline script runtime directly; no docker needed
execution-mode: inline
logging:
level:
root: ERROR
com.iflytek.skillhub: ERROR

View file

@ -0,0 +1,33 @@
package com.iflytek.skillhub.domain.authoring;
/**
* Runtime types a draft can bind for behavior validation. The registry of supported
* adapters lives in the application layer; the domain only knows the stable identifiers.
*/
public enum AgentRuntimeType {
/** Executes the skill's own scripts in an isolated working directory. */
LOCAL_SCRIPT("local-script"),
/** Sends prompts to an OpenAI-compatible chat completion endpoint with SKILL.md as context. */
OPENAI_COMPATIBLE("openai-compatible");
private final String identifier;
AgentRuntimeType(String identifier) {
this.identifier = identifier;
}
public String identifier() {
return identifier;
}
public static AgentRuntimeType fromIdentifier(String identifier) {
for (AgentRuntimeType type : values()) {
if (type.identifier.equals(identifier)) {
return type;
}
}
throw new IllegalArgumentException("Unknown agent runtime type: " + identifier);
}
}

View file

@ -0,0 +1,134 @@
package com.iflytek.skillhub.domain.authoring;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import java.time.Clock;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
/**
* Per-draft runtime configuration used by behavior validation. Holds the agent type,
* adapter-specific configuration, tool allowlist, and MCP server declarations.
*
* <p>Plain secrets must never be stored here: credentials are referenced by name and
* resolved from server-side configuration at validation time. The JSON columns are
* mapped as structured collections (not Strings) so the stored documents stay real
* JSON objects and Hibernate's read path is symmetric with its write path.
*/
@Entity
@Table(name = "runtime_binding",
uniqueConstraints = @jakarta.persistence.UniqueConstraint(
name = "uq_runtime_binding_draft", columnNames = "draft_id"))
public class RuntimeBinding {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "draft_id", nullable = false)
private Long draftId;
@Enumerated(EnumType.STRING)
@Column(name = "agent_type", nullable = false, length = 32)
private AgentRuntimeType agentType;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "config")
private Map<String, Object> config;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "tool_allowlist")
private List<String> toolAllowlist;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "mcp_servers")
private List<Map<String, Object>> mcpServers;
@Column(name = "updated_by", length = 128)
private String updatedBy;
@Column(name = "updated_at", nullable = false)
private Instant updatedAt;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
protected RuntimeBinding() {
}
public RuntimeBinding(Long draftId, AgentRuntimeType agentType) {
this.draftId = draftId;
this.agentType = agentType;
}
@PrePersist
protected void onCreate() {
Instant now = Instant.now(Clock.systemUTC());
createdAt = now;
updatedAt = now;
}
@PreUpdate
protected void onUpdate() {
updatedAt = Instant.now(Clock.systemUTC());
}
public void update(AgentRuntimeType agentType, Map<String, Object> config,
List<String> toolAllowlist, List<Map<String, Object>> mcpServers,
String updatedBy) {
this.agentType = agentType;
this.config = config;
this.toolAllowlist = toolAllowlist;
this.mcpServers = mcpServers;
this.updatedBy = updatedBy;
}
// Getters
public Long getId() {
return id;
}
public Long getDraftId() {
return draftId;
}
public AgentRuntimeType getAgentType() {
return agentType;
}
public Map<String, Object> getConfig() {
return config;
}
public List<String> getToolAllowlist() {
return toolAllowlist;
}
public List<Map<String, Object>> getMcpServers() {
return mcpServers;
}
public String getUpdatedBy() {
return updatedBy;
}
public Instant getUpdatedAt() {
return updatedAt;
}
public Instant getCreatedAt() {
return createdAt;
}
}

View file

@ -0,0 +1,15 @@
package com.iflytek.skillhub.domain.authoring;
import java.util.Optional;
/**
* Domain repository contract for per-draft runtime bindings.
*/
public interface RuntimeBindingRepository {
RuntimeBinding save(RuntimeBinding binding);
Optional<RuntimeBinding> findByDraftId(Long draftId);
void deleteByDraftId(Long draftId);
}

View file

@ -0,0 +1,221 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import java.util.regex.PatternSyntaxException;
/**
* Evaluates rule-based assertions against a task result. Deterministic checks only:
* exit codes, regex/substring matching, JSON pointer equality, artifact presence and
* hashes, and tool call counts. Semantic quality checks are intentionally not
* approximated here — they belong to model-assisted review, which stays outside the
* minimal closed loop.
*/
public class AssertionEvaluator {
/** Assertion types valid for SCRIPT tasks. */
public static final List<String> SCRIPT_ASSERTION_TYPES = List.of(
"exit_code", "stdout_matches", "stdout_contains", "stdout_json",
"artifact_exists", "tool_call_count");
/** Assertion types valid for PROMPT tasks. */
public static final List<String> PROMPT_ASSERTION_TYPES = List.of(
"response_matches", "response_contains", "response_json", "tool_call_count");
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
/**
* Validates assertion types and required parameters without a result. Used by the
* configuration layer so malformed assertions fail fast, before any execution.
*/
public List<String> validateSyntax(List<AssertionSpec> assertions, boolean promptTask) {
List<String> problems = new ArrayList<>();
List<String> allowed = promptTask ? PROMPT_ASSERTION_TYPES : SCRIPT_ASSERTION_TYPES;
for (AssertionSpec assertion : assertions) {
String type = assertion.type();
if (type == null || type.isBlank()) {
problems.add("assertion without a type");
continue;
}
if (!allowed.contains(type)) {
problems.add("assertion type '" + type + "' is not valid for "
+ (promptTask ? "prompt" : "script") + " tasks (allowed: "
+ String.join(", ", allowed) + ")");
continue;
}
switch (type) {
case "exit_code" -> {
if (assertion.paramInt("equals") == null) {
problems.add("exit_code assertion requires an integer 'equals'");
}
}
case "stdout_matches", "response_matches" -> {
String pattern = assertion.paramString("pattern");
if (pattern == null || pattern.isBlank()) {
problems.add(type + " assertion requires a 'pattern'");
} else if (!compiles(pattern)) {
problems.add(type + " assertion has an invalid regex: " + pattern);
}
}
case "stdout_contains", "response_contains" -> {
if (assertion.paramString("value") == null) {
problems.add(type + " assertion requires a 'value'");
}
}
case "stdout_json", "response_json" -> {
if (assertion.paramString("pointer") == null) {
problems.add(type + " assertion requires a 'pointer'");
}
if (assertion.paramString("equals") == null && assertion.paramInt("equals") == null) {
problems.add(type + " assertion requires an 'equals' value");
}
}
case "artifact_exists" -> {
if (assertion.paramString("path") == null || assertion.paramString("path").isBlank()) {
problems.add("artifact_exists assertion requires a 'path'");
}
}
case "tool_call_count" -> {
Integer min = assertion.paramInt("min");
Integer max = assertion.paramInt("max");
if (min == null && max == null) {
problems.add("tool_call_count assertion requires 'min' and/or 'max'");
}
}
default -> problems.add("unknown assertion type: " + type);
}
}
return problems;
}
/** Evaluates all assertions against the task result; empty list means all passed. */
public List<AssertionFailure> evaluate(List<AssertionSpec> assertions, TaskResult result) {
List<AssertionFailure> failures = new ArrayList<>();
for (AssertionSpec assertion : assertions) {
String reason = evaluateOne(assertion, result);
if (reason != null) {
failures.add(new AssertionFailure(assertion, reason));
}
}
return failures;
}
private String evaluateOne(AssertionSpec assertion, TaskResult result) {
String type = assertion.type();
return switch (type == null ? "" : type) {
case "exit_code" -> {
Integer expected = assertion.paramInt("equals");
if (result.exitCode() == null) {
yield "task did not run to completion, no exit code";
}
if (expected == null || result.exitCode() != expected) {
yield "expected exit code " + expected + " but got " + result.exitCode();
}
yield null;
}
case "stdout_matches" -> matchPattern(assertion.paramString("pattern"), result.stdout(), "stdout");
case "stdout_contains" -> containsValue(assertion.paramString("value"), result.stdout(), "stdout");
case "response_matches" -> matchPattern(assertion.paramString("pattern"), result.response(), "response");
case "response_contains" -> containsValue(assertion.paramString("value"), result.response(), "response");
case "stdout_json" -> jsonPointer(assertion, result.stdout(), "stdout");
case "response_json" -> jsonPointer(assertion, result.response(), "response");
case "artifact_exists" -> artifactExists(assertion, result);
case "tool_call_count" -> toolCallCount(assertion, result);
default -> "unknown assertion type: " + type;
};
}
private String matchPattern(String patternText, String actual, String field) {
if (patternText == null) {
return "pattern is missing";
}
if (actual == null) {
return field + " is empty";
}
if (!Pattern.compile(patternText).matcher(actual).find()) {
return field + " does not match pattern: " + patternText;
}
return null;
}
private String containsValue(String value, String actual, String field) {
if (value == null) {
return "value is missing";
}
if (actual == null || !actual.contains(value)) {
return field + " does not contain: " + value;
}
return null;
}
private String jsonPointer(AssertionSpec assertion, String actual, String field) {
String pointer = assertion.paramString("pointer");
String expected = assertion.paramString("equals");
if (pointer == null || expected == null) {
return "pointer or equals is missing";
}
if (actual == null || actual.isBlank()) {
return field + " is empty";
}
JsonNode root;
try {
root = OBJECT_MAPPER.readTree(actual);
} catch (Exception exception) {
return field + " is not valid JSON";
}
JsonNode node = root.at(pointer);
if (node.isMissingNode()) {
return field + " has no value at pointer " + pointer;
}
String actualText = node.isTextual() ? node.asText() : node.toString();
if (!expected.equals(actualText)) {
return "value at " + pointer + " is " + actualText + ", expected " + expected;
}
return null;
}
private String artifactExists(AssertionSpec assertion, TaskResult result) {
String path = assertion.paramString("path");
if (path == null) {
return "path is missing";
}
byte[] content = result.artifacts().read(path).orElse(null);
if (content == null) {
return "artifact not found: " + path;
}
String expectedSha = assertion.paramString("sha256");
if (expectedSha != null && !expectedSha.isBlank()) {
String actualSha = TaskResult.sha256Hex(content);
if (!expectedSha.toLowerCase().equals(actualSha)) {
return "artifact " + path + " sha256 is " + actualSha + ", expected " + expectedSha;
}
}
return null;
}
private String toolCallCount(AssertionSpec assertion, TaskResult result) {
Integer min = assertion.paramInt("min");
Integer max = assertion.paramInt("max");
int actual = result.toolCallCount();
if (min != null && actual < min) {
return "tool call count " + actual + " is below minimum " + min;
}
if (max != null && actual > max) {
return "tool call count " + actual + " exceeds maximum " + max;
}
return null;
}
private boolean compiles(String pattern) {
try {
Pattern.compile(pattern);
return true;
} catch (PatternSyntaxException exception) {
return false;
}
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec;
/**
* One assertion that did not hold, with a human-readable reason.
*/
public record AssertionFailure(AssertionSpec assertion, String reason) {
public String describe() {
return "assertion '" + assertion.type() + "' failed: " + reason;
}
}

View file

@ -0,0 +1,21 @@
package com.iflytek.skillhub.domain.authoring.runtime;
/**
* Callbacks a runtime adapter uses to stream execution progress into the persisted
* run event log. The orchestrator assigns sequence numbers and broadcasts to SSE
* subscribers; adapters only report what happened.
*/
public interface RuntimeEventSink {
/** An agent-level message (model response, task narration). */
void agentMessage(String taskName, String content);
/** A tool invocation initiated by the runtime (script execution, HTTP tool call). */
void toolCall(String taskName, String tool, String argumentsJson);
/** The outcome of a tool invocation. */
void toolResult(String taskName, String tool, String summaryJson);
/** A raw log line from the runtime (stdout/stderr of a script, transport log). */
void log(String taskName, String stream, String line);
}

View file

@ -0,0 +1,50 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
/**
* Everything a runtime adapter needs to execute one validation run: the isolated
* working directory containing the draft files, the adapter-specific configuration,
* the tool allowlist, the declared MCP servers, and the cancellation signal.
*
* @param runId validation run identifier, for logging
* @param workingDirectory isolated directory holding the draft's files (read-write)
* @param adapterConfig adapter-specific configuration map from the runtime binding
* @param toolAllowlist allowed tool identifiers; empty means unrestricted
* @param mcpServers MCP server declarations from the runtime binding (may be empty)
* @param cancellation cooperative cancel signal
*/
public record RuntimeExecutionContext(
Long runId,
Path workingDirectory,
Map<String, Object> adapterConfig,
List<String> toolAllowlist,
List<Map<String, Object>> mcpServers,
TaskCancellation cancellation
) {
/** Convenience constructor for runtimes that do not consume MCP servers. */
public RuntimeExecutionContext(Long runId, Path workingDirectory, Map<String, Object> adapterConfig,
List<String> toolAllowlist, TaskCancellation cancellation) {
this(runId, workingDirectory, adapterConfig, toolAllowlist, List.of(), cancellation);
}
public Map<String, Object> safeAdapterConfig() {
return adapterConfig == null ? Map.of() : adapterConfig;
}
public List<String> safeToolAllowlist() {
return toolAllowlist == null ? List.of() : List.copyOf(toolAllowlist);
}
public List<Map<String, Object>> safeMcpServers() {
return mcpServers == null ? List.of() : List.copyOf(mcpServers);
}
public String configString(String key) {
Object value = safeAdapterConfig().get(key);
return value == null ? null : value.toString();
}
}

View file

@ -0,0 +1,46 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import com.iflytek.skillhub.domain.authoring.spec.TaskType;
import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec;
/**
* SPI implemented by every agent runtime the authoring platform can bind to a draft.
*
* <p>Implementations live in the application layer (process execution, HTTP clients)
* and are selected by the runtime binding's agent type. The contract is deliberately
* minimal: execute one task, stream progress through the sink, return or throw.
* Assertion evaluation is handled by the orchestrator via {@link TaskResult}, not by
* adapters.
*/
public interface SkillRuntimeAdapter {
/** The agent type identifier this adapter serves (see {@code AgentRuntimeType}). */
String agentType();
/** Whether the adapter can execute the given task kind. */
boolean supports(TaskType taskType);
/**
* Whether the adapter is currently usable. Disabled adapters (missing server-side
* configuration, feature flags) are reported as RUNTIME_DISABLED findings instead of
* failed tasks when the orchestrator selects them.
*/
default boolean enabled() {
return true;
}
/**
* Executes one validation task. Implementations must:
* <ul>
* <li>honor {@code context.cancellation()} while running;</li>
* <li>enforce the task timeout themselves;</li>
* <li>report progress through {@code sink};</li>
* <li>never modify draft state — the working directory is a disposable copy.</li>
* </ul>
*
* @return the raw task outcome for assertion evaluation
* @throws InterruptedException when cancelled mid-execution
*/
TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task,
RuntimeEventSink sink) throws Exception;
}

View file

@ -0,0 +1,12 @@
package com.iflytek.skillhub.domain.authoring.runtime;
/**
* Cooperative cancellation signal handed to runtime adapters. Adapters should poll
* {@link #isCancelRequested()} while executing (at least between output chunks) and
* abort as quickly as practical when it flips.
*/
@FunctionalInterface
public interface TaskCancellation {
boolean isCancelRequested();
}

View file

@ -0,0 +1,56 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;
import java.util.Optional;
/**
* The raw outcome of one behavior validation task, consumed by the assertion
* evaluator. Script tasks populate exit code and streams; prompt tasks populate
* the agent response. The artifact resolver reads files produced inside the
* working directory (used by artifact_exists assertions).
*/
public record TaskResult(
Integer exitCode,
String stdout,
String stderr,
String response,
int toolCallCount,
ArtifactResolver artifacts
) {
/** Reads a file produced by the task, relative to the working directory. */
public interface ArtifactResolver {
Optional<byte[]> read(String relativePath);
}
public static TaskResult ofScript(int exitCode, String stdout, String stderr,
int toolCallCount, ArtifactResolver artifacts) {
return new TaskResult(exitCode, stdout, stderr, null, toolCallCount, artifacts);
}
public static TaskResult ofPrompt(String response, int toolCallCount, ArtifactResolver artifacts) {
return new TaskResult(null, null, null, response, toolCallCount, artifacts);
}
public String sha256OfArtifact(String relativePath) {
return artifacts.read(relativePath)
.map(TaskResult::sha256Hex)
.orElse(null);
}
public static String sha256Hex(byte[] content) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(content));
} catch (NoSuchAlgorithmException exception) {
throw new IllegalStateException("SHA-256 unavailable", exception);
}
}
public static String sha256Hex(String content) {
return sha256Hex(content.getBytes(StandardCharsets.UTF_8));
}
}

View file

@ -0,0 +1,5 @@
/**
* Runtime adapter SPI and behavior-validation support types shared by the domain and
* application layers.
*/
package com.iflytek.skillhub.domain.authoring.runtime;

View file

@ -0,0 +1,101 @@
package com.iflytek.skillhub.domain.authoring.service;
import com.iflytek.skillhub.domain.authoring.AgentRuntimeType;
import com.iflytek.skillhub.domain.authoring.RuntimeBinding;
import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository;
import com.iflytek.skillhub.domain.authoring.SkillDraft;
import com.iflytek.skillhub.domain.authoring.SkillDraftRepository;
import com.iflytek.skillhub.domain.authoring.validation.FindingDraft;
import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity;
import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException;
import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException;
import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Domain service for a draft's runtime binding: persists the agent type, adapter
* configuration, tool allowlist, and MCP server declarations. Validation findings are
* the responsibility of {@link RuntimeBindingValidator}; this service only rejects
* structurally invalid input at save time.
*/
@Service
public class RuntimeBindingService {
private static final String SUPER_ADMIN = "SUPER_ADMIN";
private final RuntimeBindingRepository bindingRepository;
private final SkillDraftRepository draftRepository;
private final RuntimeBindingValidator bindingValidator;
public RuntimeBindingService(RuntimeBindingRepository bindingRepository,
SkillDraftRepository draftRepository,
RuntimeBindingValidator bindingValidator) {
this.bindingRepository = bindingRepository;
this.draftRepository = draftRepository;
this.bindingValidator = bindingValidator;
}
/**
* Saves the runtime binding. Structurally invalid input (unknown agent type,
* embedded credentials) is rejected immediately; the full validation report is
* produced again as findings whenever a run starts.
*/
@Transactional
public RuntimeBinding saveBinding(Long draftId, String userId, String agentType,
Map<String, Object> adapterConfig,
List<String> toolAllowlist,
List<Map<String, Object>> mcpServers,
Set<String> platformRoles) {
SkillDraft draft = draftRepository.findById(draftId)
.orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId));
assertOwner(draft, userId, platformRoles);
AgentRuntimeType type;
try {
type = AgentRuntimeType.fromIdentifier(agentType == null ? "" : agentType);
} catch (IllegalArgumentException exception) {
throw new DomainBadRequestException("error.authoring.binding.agentType.unknown", agentType);
}
List<FindingDraft> findings = bindingValidator.validate(
type.identifier(), adapterConfig, toolAllowlist, mcpServers);
boolean hasErrors = findings.stream()
.anyMatch(finding -> finding.severity() == FindingSeverity.ERROR);
if (hasErrors) {
String first = findings.stream()
.filter(finding -> finding.severity() == FindingSeverity.ERROR)
.findFirst().map(FindingDraft::message).orElse("invalid binding");
throw new DomainBadRequestException("error.authoring.binding.invalid", first);
}
RuntimeBinding binding = bindingRepository.findByDraftId(draftId)
.orElseGet(() -> new RuntimeBinding(draftId, type));
binding.update(type, adapterConfig, toolAllowlist, mcpServers, userId);
return bindingRepository.save(binding);
}
@Transactional(readOnly = true)
public RuntimeBinding getBinding(Long draftId) {
return bindingRepository.findByDraftId(draftId)
.orElseThrow(() -> new DomainNotFoundException(
"error.authoring.binding.notFound", draftId));
}
/** Optional accessor for callers that treat "not configured yet" as normal state. */
@Transactional(readOnly = true)
public java.util.Optional<RuntimeBinding> findBinding(Long draftId) {
return bindingRepository.findByDraftId(draftId);
}
private void assertOwner(SkillDraft draft, String userId, Set<String> platformRoles) {
if (draft.getOwnerId().equals(userId)
|| (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) {
return;
}
throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId());
}
}

View file

@ -0,0 +1,214 @@
package com.iflytek.skillhub.domain.authoring.service;
import com.iflytek.skillhub.domain.authoring.validation.FindingDraft;
import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer;
import java.net.URI;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import org.springframework.stereotype.Service;
/**
* The CONFIG validation layer: checks the runtime binding (agent type, adapter
* configuration, tool allowlist, MCP server declarations) plus the syntactic validity
* of validation task assertions. Rejects embedded plaintext credentials — bindings may
* only reference server-side credential names. Outbound endpoints, the stdio
* transport, and environment references are additionally gated by the
* Structural checks only in this layer; the SSRF/stdio/envRef policy that
* guards external endpoints arrives with the MCP+security layer.
*/
@Service
public class RuntimeBindingValidator {
private static final Pattern TOOL_NAME = Pattern.compile("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$");
private static final Pattern MCP_NAME = Pattern.compile("^[a-z0-9][a-z0-9-]{0,63}$");
private static final Pattern CREDENTIAL_KEY = Pattern.compile(
"(?i).*(secret|password|passwd|api[_-]?key|token|credential).*");
private static final Pattern ENV_REF_NAME = Pattern.compile("^[A-Z][A-Z0-9_]{0,63}$");
private static final List<String> TRANSPORTS = List.of("http", "sse", "stdio");
private static final List<String> INTERPRETERS = List.of("sh", "bash", "python3", "node");
public RuntimeBindingValidator() {
}
/**
* @param agentType agent type identifier (may be invalid — reported as finding)
* @param adapterConfig adapter-specific configuration map
* @param toolAllowlist tool identifiers (may be null = unrestricted)
* @param mcpServers MCP server declarations (may be null)
*/
public List<FindingDraft> validate(String agentType, Map<String, Object> adapterConfig,
List<String> toolAllowlist, List<Map<String, Object>> mcpServers) {
List<FindingDraft> findings = new ArrayList<>();
String resolvedAgentType = agentType == null ? "" : agentType;
switch (resolvedAgentType) {
case "local-script" -> validateLocalScriptConfig(adapterConfig, findings);
case "openai-compatible" -> validateOpenAiCompatibleConfig(adapterConfig, findings);
default -> findings.add(FindingDraft.error(ValidationLayer.CONFIG, "AGENT_TYPE_UNKNOWN",
"Unknown agent runtime type: " + resolvedAgentType
+ " (supported: local-script, openai-compatible)"));
}
if (toolAllowlist != null) {
for (String tool : toolAllowlist) {
if (tool == null || !TOOL_NAME.matcher(tool).matches()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "TOOL_ALLOWLIST_INVALID",
"Invalid tool allowlist entry: " + tool));
}
}
}
if (mcpServers != null) {
for (Map<String, Object> server : mcpServers) {
validateMcpServer(server, findings);
}
}
scanForEmbeddedSecrets(agentType, adapterConfig, toolAllowlist, mcpServers, findings);
return findings;
}
private void validateLocalScriptConfig(Map<String, Object> config, List<FindingDraft> findings) {
if (config == null) {
return;
}
Object interpreter = config.get("interpreter");
if (interpreter != null && !INTERPRETERS.contains(interpreter.toString())) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "INTERPRETER_UNSUPPORTED",
"Unsupported interpreter: " + interpreter + " (supported: "
+ String.join(", ", INTERPRETERS) + ")"));
}
Object envAllowlist = config.get("envAllowlist");
if (envAllowlist instanceof List<?> entries) {
for (Object entry : entries) {
if (entry == null || !ENV_REF_NAME.matcher(entry.toString()).matches()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID",
"Invalid environment variable name: " + entry));
}
}
} else if (envAllowlist != null) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "ENV_ALLOWLIST_INVALID",
"envAllowlist must be a list of variable names"));
}
}
private void validateOpenAiCompatibleConfig(Map<String, Object> config, List<FindingDraft> findings) {
if (config == null) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_CONFIG_MISSING",
"openai-compatible runtime requires a config object with endpoint and model"));
return;
}
Object endpoint = config.get("endpoint");
if (endpoint == null || endpoint.toString().isBlank()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_MISSING",
"openai-compatible runtime requires an 'endpoint' URL"));
} else if (!isHttpUrl(endpoint.toString())) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_INVALID",
"endpoint must be an http(s) URL: " + endpoint));
}
Object model = config.get("model");
if (model == null || model.toString().isBlank()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_MODEL_MISSING",
"openai-compatible runtime requires a 'model' name"));
}
Object temperature = config.get("temperature");
if (temperature instanceof Number number
&& (number.doubleValue() < 0 || number.doubleValue() > 2)) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_TEMPERATURE_INVALID",
"temperature must be between 0 and 2"));
}
// the API key is resolved from server configuration, never from the binding
if (config.containsKey("apiKey") || config.containsKey("api_key")) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED",
"apiKey must not be stored in the binding; configure it server-side"));
}
}
private void validateMcpServer(Map<String, Object> server, List<FindingDraft> findings) {
Object name = server.get("name");
if (name == null || !MCP_NAME.matcher(name.toString()).matches()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_NAME_INVALID",
"MCP server name must be lowercase alphanumerics/hyphens: " + name));
}
Object transport = server.get("transport");
if (transport == null || !TRANSPORTS.contains(transport.toString())) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TRANSPORT_INVALID",
"MCP server " + name + " has unknown transport: " + transport
+ " (supported: " + String.join(", ", TRANSPORTS) + ")"));
} else if ("http".equals(transport.toString()) || "sse".equals(transport.toString())) {
Object endpoint = server.get("endpoint");
if (endpoint == null || !isHttpUrl(endpoint.toString())) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENDPOINT_INVALID",
"MCP server " + name + " requires an http(s) endpoint"));
}
} else {
// stdio servers spawn host-side processes; the enable switch and the
// docker isolation that gate them arrive with the security layer
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_STDIO_DISABLED",
"MCP server " + name + " uses the stdio transport, which is not"
+ " available yet; use http/sse instead"));
Object command = server.get("command");
if (command == null || command.toString().isBlank()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_COMMAND_MISSING",
"MCP server " + name + " (stdio) requires a command"));
}
}
Object toolFilters = server.get("toolFilters");
if (toolFilters instanceof List<?> filters) {
for (Object filter : filters) {
if (filter == null || !TOOL_NAME.matcher(filter.toString()).matches()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_INVALID",
"MCP server " + name + " has an invalid tool filter: " + filter));
}
}
}
Object envRefs = server.get("envRefs");
if (envRefs instanceof List<?> refs) {
for (Object ref : refs) {
if (ref == null || !ENV_REF_NAME.matcher(ref.toString()).matches()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENV_REF_INVALID",
"MCP server " + name + " has an invalid env reference: " + ref));
}
}
}
}
/** Rejects secret-looking keys anywhere in the binding payload. */
private void scanForEmbeddedSecrets(String agentType, Map<String, Object> adapterConfig,
List<String> toolAllowlist,
List<Map<String, Object>> mcpServers,
List<FindingDraft> findings) {
if (adapterConfig != null) {
scanMap(adapterConfig, findings);
}
if (mcpServers != null) {
for (Map<String, Object> server : mcpServers) {
scanMap(server, findings);
}
}
}
private void scanMap(Map<String, Object> map, List<FindingDraft> findings) {
for (Map.Entry<String, Object> entry : map.entrySet()) {
if (CREDENTIAL_KEY.matcher(entry.getKey()).matches()
&& entry.getValue() != null && !entry.getValue().toString().isBlank()) {
findings.add(FindingDraft.error(ValidationLayer.CONFIG, "CREDENTIAL_EMBEDDED",
"Binding must not embed credentials in field '" + entry.getKey()
+ "'; reference server-side credentials instead"));
}
}
}
private boolean isHttpUrl(String value) {
try {
URI uri = URI.create(value);
String scheme = uri.getScheme();
return ("http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme))
&& uri.getHost() != null;
} catch (IllegalArgumentException exception) {
return false;
}
}
}

View file

@ -0,0 +1,36 @@
package com.iflytek.skillhub.domain.authoring.spec;
import java.util.Map;
/**
* One assertion inside a validation task. Assertions are deliberately loosely typed:
* {@code type} selects the check and {@code params} carries its typed parameters,
* which the evaluator validates and coerces. Unknown types or missing parameters are
* reported as configuration-layer findings before behavior execution starts.
*/
public record AssertionSpec(String type, Map<String, Object> params) {
public Map<String, Object> safeParams() {
return params == null ? Map.of() : params;
}
public String paramString(String key) {
Object value = safeParams().get(key);
return value == null ? null : value.toString();
}
public Integer paramInt(String key) {
Object value = safeParams().get(key);
if (value instanceof Number number) {
return number.intValue();
}
if (value instanceof String text && !text.isBlank()) {
try {
return Integer.parseInt(text.trim());
} catch (NumberFormatException ignored) {
return null;
}
}
return null;
}
}

View file

@ -0,0 +1,13 @@
package com.iflytek.skillhub.domain.authoring.spec;
/**
* Task kinds supported by behavior validation.
*/
public enum TaskType {
/** Executes a script shipped with the skill in the isolated working directory. */
SCRIPT,
/** Sends a prompt to the bound agent runtime with the skill as context. */
PROMPT
}

View file

@ -0,0 +1,28 @@
package com.iflytek.skillhub.domain.authoring.spec;
import java.util.List;
import java.util.Optional;
/**
* Parsed content of a draft's {@code validation.yaml}: the ordered list of behavior
* validation tasks executed by the BEHAVIOR layer. A draft without the file simply
* runs zero behavior tasks.
*/
public record ValidationSpec(int version, List<ValidationTaskSpec> tasks) {
public static final String FILE_PATH = "validation.yaml";
public static final int CURRENT_VERSION = 1;
public static final int DEFAULT_TASK_TIMEOUT_MS = 60_000;
public static ValidationSpec empty() {
return new ValidationSpec(CURRENT_VERSION, List.of());
}
public List<ValidationTaskSpec> safeTasks() {
return tasks == null ? List.of() : List.copyOf(tasks);
}
public Optional<ValidationTaskSpec> findTask(String name) {
return safeTasks().stream().filter(task -> task.name().equals(name)).findFirst();
}
}

View file

@ -0,0 +1,211 @@
package com.iflytek.skillhub.domain.authoring.spec;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.yaml.snakeyaml.LoaderOptions;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.SafeConstructor;
/**
* Parses a draft's {@code validation.yaml} into {@link ValidationSpec}.
*
* <p>Follows the same defensive YAML loading rules as {@code SkillMetadataParser}:
* SnakeYAML safe constructor, bounded aliases, nesting depth, and code points.
* Parse problems are reported as structured spec errors (rule code + message) instead
* of exceptions so the configuration validation layer can surface them as findings.
*/
public class ValidationSpecParser {
private static final int MAX_YAML_ALIASES = 20;
private static final int MAX_YAML_NESTING_DEPTH = 20;
private static final int MAX_YAML_CODE_POINTS = 100_000;
private static final int MAX_TASKS = 50;
private static final int MAX_TASK_TIMEOUT_MS = 600_000;
/** A structured parse or semantic error detected while reading the spec. */
public record SpecError(String ruleCode, String message) {}
public record ParseOutcome(ValidationSpec spec, List<SpecError> errors) {
public boolean isValid() {
return errors.isEmpty();
}
}
public ParseOutcome parse(String content) {
if (content == null || content.isBlank()) {
return failure("SPEC_EMPTY", "validation.yaml is empty");
}
Object parsed;
try {
LoaderOptions loaderOptions = new LoaderOptions();
loaderOptions.setAllowDuplicateKeys(false);
loaderOptions.setMaxAliasesForCollections(MAX_YAML_ALIASES);
loaderOptions.setNestingDepthLimit(MAX_YAML_NESTING_DEPTH);
loaderOptions.setCodePointLimit(MAX_YAML_CODE_POINTS);
Yaml yaml = new Yaml(new SafeConstructor(loaderOptions));
parsed = yaml.load(content);
} catch (Exception exception) {
return failure("SPEC_YAML_INVALID", "validation.yaml is not valid YAML: "
+ exception.getMessage());
}
if (!(parsed instanceof Map<?, ?> root)) {
return failure("SPEC_NOT_MAP", "validation.yaml must be a YAML object");
}
List<SpecError> errors = new ArrayList<>();
Object versionValue = root.get("version");
int version = 1;
if (versionValue instanceof Number number) {
version = number.intValue();
} else if (versionValue != null) {
try {
version = Integer.parseInt(versionValue.toString().trim());
} catch (NumberFormatException e) {
errors.add(new SpecError("SPEC_VERSION_INVALID",
"version must be an integer, got: " + versionValue));
}
}
if (version != ValidationSpec.CURRENT_VERSION) {
errors.add(new SpecError("SPEC_VERSION_INVALID",
"unsupported spec version: " + version + " (expected "
+ ValidationSpec.CURRENT_VERSION + ")"));
}
List<ValidationTaskSpec> tasks = new ArrayList<>();
java.util.Set<String> seenNames = new java.util.HashSet<>();
Object tasksValue = root.get("tasks");
if (tasksValue == null) {
errors.add(new SpecError("SPEC_TASKS_MISSING", "validation.yaml must define a tasks list"));
} else if (!(tasksValue instanceof List<?> rawTasks)) {
errors.add(new SpecError("SPEC_TASKS_INVALID", "tasks must be a list"));
} else {
if (rawTasks.size() > MAX_TASKS) {
errors.add(new SpecError("SPEC_TASKS_TOO_MANY",
"too many tasks: " + rawTasks.size() + " (max " + MAX_TASKS + ")"));
}
for (int i = 0; i < rawTasks.size(); i++) {
ValidationTaskSpec task = parseTask(rawTasks.get(i), i, errors, seenNames);
if (task != null) {
tasks.add(task);
}
}
}
if (!errors.isEmpty()) {
return new ParseOutcome(ValidationSpec.empty(), List.copyOf(errors));
}
return new ParseOutcome(new ValidationSpec(version, List.copyOf(tasks)), List.of());
}
private ValidationTaskSpec parseTask(Object rawTask, int index, List<SpecError> errors,
java.util.Set<String> seenNames) {
String where = "tasks[" + index + "]";
if (!(rawTask instanceof Map<?, ?> taskMap)) {
errors.add(new SpecError("TASK_NOT_MAP", where + " must be a YAML object"));
return null;
}
String name = stringField(taskMap, "name");
if (name == null || name.isBlank()) {
errors.add(new SpecError("TASK_NAME_MISSING", where + " is missing a name"));
} else if (!name.matches("[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}")) {
errors.add(new SpecError("TASK_NAME_INVALID",
where + " has an invalid name: " + name));
} else if (!seenNames.add(name)) {
errors.add(new SpecError("TASK_NAME_DUPLICATE", where + " duplicates task name: " + name));
}
String typeRaw = stringField(taskMap, "type");
TaskType type;
if (typeRaw == null) {
errors.add(new SpecError("TASK_TYPE_MISSING", where + " is missing a type"));
return null;
}
try {
type = TaskType.valueOf(typeRaw.trim().toUpperCase());
} catch (IllegalArgumentException e) {
errors.add(new SpecError("TASK_TYPE_INVALID",
where + " has unknown type: " + typeRaw + " (expected script or prompt)"));
return null;
}
String script = stringField(taskMap, "script");
String prompt = stringField(taskMap, "prompt");
if (type == TaskType.SCRIPT && (script == null || script.isBlank())) {
errors.add(new SpecError("TASK_SCRIPT_MISSING",
where + " (script task) must define script"));
}
if (type == TaskType.PROMPT && (prompt == null || prompt.isBlank())) {
errors.add(new SpecError("TASK_PROMPT_MISSING",
where + " (prompt task) must define prompt"));
}
List<String> args = new ArrayList<>();
Object argsValue = taskMap.get("args");
if (argsValue instanceof List<?> rawArgs) {
for (Object arg : rawArgs) {
if (arg != null) {
args.add(arg.toString());
}
}
} else if (argsValue != null) {
errors.add(new SpecError("TASK_ARGS_INVALID", where + " args must be a list"));
}
int timeoutMs = ValidationSpec.DEFAULT_TASK_TIMEOUT_MS;
Object timeoutValue = taskMap.get("timeoutMs");
if (timeoutValue instanceof Number number) {
timeoutMs = number.intValue();
} else if (timeoutValue != null) {
errors.add(new SpecError("TASK_TIMEOUT_INVALID",
where + " timeoutMs must be an integer"));
}
if (timeoutMs <= 0 || timeoutMs > MAX_TASK_TIMEOUT_MS) {
errors.add(new SpecError("TASK_TIMEOUT_INVALID",
where + " timeoutMs must be between 1 and " + MAX_TASK_TIMEOUT_MS));
}
List<AssertionSpec> assertions = new ArrayList<>();
Object assertionsValue = taskMap.get("assertions");
if (assertionsValue instanceof List<?> rawAssertions) {
for (Object rawAssertion : rawAssertions) {
if (rawAssertion instanceof Map<?, ?> assertionMap) {
Object assertionType = assertionMap.get("type");
Map<String, Object> params = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : assertionMap.entrySet()) {
if (!"type".equals(entry.getKey()) && entry.getKey() != null) {
params.put(entry.getKey().toString(), entry.getValue());
}
}
if (assertionType == null || assertionType.toString().isBlank()) {
errors.add(new SpecError("ASSERTION_TYPE_MISSING",
where + " has an assertion without a type"));
} else {
assertions.add(new AssertionSpec(assertionType.toString(), params));
}
} else {
errors.add(new SpecError("ASSERTION_NOT_MAP",
where + " assertions must be objects"));
}
}
} else if (assertionsValue != null) {
errors.add(new SpecError("ASSERTIONS_INVALID", where + " assertions must be a list"));
}
String description = stringField(taskMap, "description");
return new ValidationTaskSpec(name == null ? where : name, description, type, script,
List.copyOf(args), prompt, timeoutMs, List.copyOf(assertions));
}
private String stringField(Map<?, ?> map, String key) {
Object value = map.get(key);
return value == null ? null : value.toString();
}
private ParseOutcome failure(String ruleCode, String message) {
return new ParseOutcome(ValidationSpec.empty(), List.of(new SpecError(ruleCode, message)));
}
}

View file

@ -0,0 +1,26 @@
package com.iflytek.skillhub.domain.authoring.spec;
import java.util.List;
/**
* One behavior validation task parsed from {@code validation.yaml}.
*/
public record ValidationTaskSpec(
String name,
String description,
TaskType type,
String script,
List<String> args,
String prompt,
int timeoutMs,
List<AssertionSpec> assertions
) {
public List<String> safeArgs() {
return args == null ? List.of() : List.copyOf(args);
}
public List<AssertionSpec> safeAssertions() {
return assertions == null ? List.of() : List.copyOf(assertions);
}
}

View file

@ -0,0 +1,4 @@
/**
* Behavior validation task specifications parsed from a draft's {@code validation.yaml}.
*/
package com.iflytek.skillhub.domain.authoring.spec;

View file

@ -0,0 +1,138 @@
package com.iflytek.skillhub.domain.authoring.runtime;
import static org.assertj.core.api.Assertions.assertThat;
import com.iflytek.skillhub.domain.authoring.spec.AssertionSpec;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import org.junit.jupiter.api.Test;
class AssertionEvaluatorTest {
private final AssertionEvaluator evaluator = new AssertionEvaluator();
private static TaskResult scriptResult(int exitCode, String stdout, String stderr) {
return TaskResult.ofScript(exitCode, stdout, stderr, 1, path -> Optional.empty());
}
@Test
void exitCodeAssertionPassesAndFails() {
AssertionSpec assertion = new AssertionSpec("exit_code", Map.of("equals", 0));
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "", ""))).isEmpty();
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(1, "", "")))
.singleElement()
.satisfies(failure -> assertThat(failure.describe()).contains("exit code"));
}
@Test
void stdoutContainsChecksValue() {
AssertionSpec assertion = new AssertionSpec("stdout_contains", Map.of("value", "OK"));
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "everything OK here", "")))
.isEmpty();
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "failed", "")))
.hasSize(1);
}
@Test
void stdoutMatchesUsesRegexFindSemantics() {
AssertionSpec assertion =
new AssertionSpec("stdout_matches", Map.of("pattern", "duration: \\d+ms"));
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "run finished, duration: 42ms", "")))
.isEmpty();
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "no timing info", "")))
.hasSize(1);
}
@Test
void jsonPointerAssertionComparesValue() {
AssertionSpec assertion = new AssertionSpec("stdout_json",
Map.of("pointer", "/status", "equals", "healthy"));
assertThat(evaluator.evaluate(List.of(assertion),
scriptResult(0, "{\"status\":\"healthy\"}", ""))).isEmpty();
assertThat(evaluator.evaluate(List.of(assertion),
scriptResult(0, "{\"status\":\"unhealthy\"}", ""))).hasSize(1);
assertThat(evaluator.evaluate(List.of(assertion), scriptResult(0, "not json", "")))
.singleElement()
.satisfies(failure -> assertThat(failure.describe()).contains("not valid JSON"));
}
@Test
void artifactExistsChecksContentHash() {
byte[] content = "artifact-bytes".getBytes();
String sha = TaskResult.sha256Hex(content);
TaskResult result = TaskResult.ofScript(0, "", "", 0, path ->
"out/report.txt".equals(path) ? Optional.of(content) : Optional.empty());
AssertionSpec exists = new AssertionSpec("artifact_exists", Map.of("path", "out/report.txt"));
AssertionSpec missing = new AssertionSpec("artifact_exists", Map.of("path", "out/none.txt"));
AssertionSpec wrongHash = new AssertionSpec("artifact_exists",
Map.of("path", "out/report.txt", "sha256", "deadbeef"));
assertThat(evaluator.evaluate(List.of(exists), result)).isEmpty();
assertThat(evaluator.evaluate(List.of(missing), result)).hasSize(1);
assertThat(evaluator.evaluate(List.of(wrongHash), result))
.singleElement()
.satisfies(failure -> assertThat(failure.describe()).contains("sha256"));
}
@Test
void toolCallCountHonorsMinAndMax() {
TaskResult result = TaskResult.ofScript(0, "", "", 2, path -> Optional.empty());
assertThat(evaluator.evaluate(
List.of(new AssertionSpec("tool_call_count", Map.of("min", 1, "max", 3))), result))
.isEmpty();
assertThat(evaluator.evaluate(
List.of(new AssertionSpec("tool_call_count", Map.of("min", 3))), result))
.hasSize(1);
assertThat(evaluator.evaluate(
List.of(new AssertionSpec("tool_call_count", Map.of("max", 1))), result))
.hasSize(1);
}
@Test
void responseAssertionsApplyToPromptResults() {
TaskResult result = TaskResult.ofPrompt("the skill summarizes documents", 0,
path -> Optional.empty());
assertThat(evaluator.evaluate(List.of(
new AssertionSpec("response_contains", Map.of("value", "summarizes"))), result))
.isEmpty();
assertThat(evaluator.evaluate(List.of(
new AssertionSpec("response_contains", Map.of("value", "translates"))), result))
.hasSize(1);
}
@Test
void unknownAssertionTypeFailsEvaluation() {
List<AssertionSpec> assertions = List.of(new AssertionSpec("mind_read", Map.of()));
assertThat(evaluator.evaluate(assertions, scriptResult(0, "", "")))
.singleElement()
.satisfies(failure -> assertThat(failure.describe()).contains("unknown assertion type"));
}
@Test
void validateSyntaxAcceptsKnownTypesPerTaskKind() {
List<AssertionSpec> scriptAssertions = List.of(
new AssertionSpec("exit_code", Map.of("equals", 0)),
new AssertionSpec("stdout_contains", Map.of("value", "OK")));
List<AssertionSpec> promptAssertions = List.of(
new AssertionSpec("response_contains", Map.of("value", "OK")),
new AssertionSpec("tool_call_count", Map.of("min", 0)));
assertThat(evaluator.validateSyntax(scriptAssertions, false)).isEmpty();
assertThat(evaluator.validateSyntax(promptAssertions, true)).isEmpty();
List<AssertionSpec> crossKind = List.of(
new AssertionSpec("exit_code", Map.of("equals", 0)),
new AssertionSpec("response_contains", Map.of("value", "OK")));
assertThat(evaluator.validateSyntax(crossKind, true)).hasSize(1);
assertThat(evaluator.validateSyntax(crossKind, false)).hasSize(1);
}
}

View file

@ -0,0 +1,182 @@
package com.iflytek.skillhub.domain.authoring.spec;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.List;
import org.junit.jupiter.api.Test;
class ValidationSpecParserTest {
private final ValidationSpecParser parser = new ValidationSpecParser();
@Test
void parsesValidScriptTask() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: smoke
description: basic check
type: script
script: scripts/check.sh
args: ["--quiet"]
timeoutMs: 5000
assertions:
- type: exit_code
equals: 0
- type: stdout_contains
value: OK
""");
assertThat(outcome.isValid()).isTrue();
ValidationSpec spec = outcome.spec();
assertThat(spec.version()).isEqualTo(1);
assertThat(spec.safeTasks()).hasSize(1);
ValidationTaskSpec task = spec.safeTasks().get(0);
assertThat(task.name()).isEqualTo("smoke");
assertThat(task.type()).isEqualTo(TaskType.SCRIPT);
assertThat(task.script()).isEqualTo("scripts/check.sh");
assertThat(task.safeArgs()).containsExactly("--quiet");
assertThat(task.timeoutMs()).isEqualTo(5000);
assertThat(task.safeAssertions()).hasSize(2);
assertThat(task.safeAssertions().get(0).type()).isEqualTo("exit_code");
assertThat(task.safeAssertions().get(0).paramInt("equals")).isZero();
}
@Test
void parsesPromptTaskWithoutOptionalFields() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: ask
type: prompt
prompt: What is this skill about?
""");
assertThat(outcome.isValid()).isTrue();
ValidationTaskSpec task = outcome.spec().safeTasks().get(0);
assertThat(task.type()).isEqualTo(TaskType.PROMPT);
assertThat(task.timeoutMs()).isEqualTo(ValidationSpec.DEFAULT_TASK_TIMEOUT_MS);
assertThat(task.safeAssertions()).isEmpty();
}
@Test
void emptyContentIsASpecError() {
ValidationSpecParser.ParseOutcome outcome = parser.parse(" \n");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_EMPTY"));
}
@Test
void invalidYamlIsASpecErrorNotAnException() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("version: [unbalanced");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_YAML_INVALID"));
}
@Test
void unsupportedVersionIsRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 2
tasks: []
""");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_VERSION_INVALID"));
}
@Test
void missingTasksListIsRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("version: 1\n");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("SPEC_TASKS_MISSING"));
}
@Test
void duplicateTaskNamesAreRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: smoke
type: script
script: a.sh
- name: smoke
type: script
script: b.sh
""");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_DUPLICATE"));
}
@Test
void scriptTaskRequiresScriptAndPromptTaskRequiresPrompt() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: no-script
type: script
- name: no-prompt
type: prompt
""");
List<String> codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList();
assertThat(codes).containsExactly("TASK_SCRIPT_MISSING", "TASK_PROMPT_MISSING");
}
@Test
void unknownTypeAndInvalidTimeoutAreRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: weird
type: telepathy
- name: slow
type: script
script: a.sh
timeoutMs: 999999999
""");
List<String> codes = outcome.errors().stream().map(ValidationSpecParser.SpecError::ruleCode).toList();
assertThat(codes).contains("TASK_TYPE_INVALID", "TASK_TIMEOUT_INVALID");
}
@Test
void invalidTaskNameIsRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: "has space"
type: script
script: a.sh
""");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("TASK_NAME_INVALID"));
}
@Test
void assertionWithoutTypeIsRejected() {
ValidationSpecParser.ParseOutcome outcome = parser.parse("""
version: 1
tasks:
- name: smoke
type: script
script: a.sh
assertions:
- equals: 0
""");
assertThat(outcome.isValid()).isFalse();
assertThat(outcome.errors()).singleElement()
.satisfies(error -> assertThat(error.ruleCode()).isEqualTo("ASSERTION_TYPE_MISSING"));
}
}

View file

@ -0,0 +1,19 @@
package com.iflytek.skillhub.infra.jpa;
import com.iflytek.skillhub.domain.authoring.RuntimeBinding;
import com.iflytek.skillhub.domain.authoring.RuntimeBindingRepository;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
/**
* JPA-backed repository for per-draft runtime bindings.
*/
@Repository
public interface RuntimeBindingJpaRepository extends JpaRepository<RuntimeBinding, Long>,
RuntimeBindingRepository {
Optional<RuntimeBinding> findByDraftId(Long draftId);
void deleteByDraftId(Long draftId);
}