From 37e30370135db2b72293ecb7b7f4c71df3779a7d Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sat, 10 Oct 2026 14:49:20 +0800 Subject: [PATCH 1/9] feat(authoring): runtime bindings, MCP probing and the security policies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer (b) of the authoring platform (#934), stacking on #935: - **Runtime bindings**: agent type + config + tool allowlist + MCP server declarations per draft, persisted as JSONB and validated at save time; local-script and OpenAI-compatible agent types - **CONFIG and BEHAVIOR validation layers**: validation.yaml spec parsing (defensive YAML), per-task execution through the runtime adapters (inline subprocesses with a cleared environment, or the docker backend with a locked-down container), MCP servers probed for real during validation with tool discovery surfaced in the event stream; assertion evaluation (exit codes, stdout/stderr contains, timeouts) - **Security policies** (the layer that wants the dedicated review): the shared AuthoringSecurityPolicy gates OpenAI-compatible and http/sse MCP endpoints at save time and again at connect time — cloud-metadata and link-local addresses are always rejected, loopback/RFC1918/ULA/ CGNAT/RFC2544 ranges sit behind per-surface flags; stdio MCP transport is disabled by default and, in docker execution mode, wraps its command in a hardened container (no network, dropped capabilities, read-only root, resource caps) that is torn down on close; envRefs may only name variables in the operator-configured allowlist; docker execution-mode is the default and inline mode fails startup outside local/dev/test; HTTP redirects are never followed and response/stdio line sizes are capped at 2 MiB. Verified: security-policy and MCP unit tests (policy classification per surface, redirect refusal, response caps, envRef filtering, docker-wrapped commands), adapter tests including a real-container isolation check, and the runtime flow integration test (three-layer validation with inline script execution; cloud-metadata/envRefs/LLM-surface bindings rejected at save time). Signed-off-by: zjncs <18910855655@163.com> --- .../skillhub/config/AuthoringProperties.java | 286 +++++++++++++- .../config/AuthoringStartupGuard.java | 51 +++ .../ConfiguredAuthoringSecurityPolicy.java | 142 +++++++ .../authoring/SkillAuthoringController.java | 54 ++- .../skillhub/dto/RuntimeBindingRequest.java | 18 + .../skillhub/dto/RuntimeBindingResponse.java | 15 + .../authoring/ValidationRunOrchestrator.java | 359 +++++++++++++++++- .../adapter/DockerScriptCommandBuilder.java | 164 ++++++++ .../adapter/InlineScriptCommandBuilder.java | 38 ++ .../adapter/LocalScriptRuntimeAdapter.java | 237 ++++++++++++ .../OpenAiCompatibleRuntimeAdapter.java | 345 +++++++++++++++++ .../adapter/ScriptCommandBuilder.java | 53 +++ .../adapter/WorkingDirectoryArtifacts.java | 30 ++ .../service/authoring/mcp/HttpMcpClient.java | 264 +++++++++++++ .../service/authoring/mcp/McpClient.java | 29 ++ .../authoring/mcp/McpClientFactory.java | 159 ++++++++ .../authoring/mcp/McpProbeService.java | 92 +++++ .../service/authoring/mcp/McpTool.java | 14 + .../service/authoring/mcp/StdioMcpClient.java | 236 ++++++++++++ .../src/main/resources/application.yml | 43 +++ .../src/main/resources/messages.properties | 7 + .../src/main/resources/messages_ru.properties | 7 + .../src/main/resources/messages_zh.properties | 7 + .../AuthoringDraftFlowIntegrationTest.java | 15 +- .../AuthoringRuntimeFlowIntegrationTest.java | 225 +++++++++++ .../config/AuthoringStartupGuardTest.java | 70 ++++ ...ConfiguredAuthoringSecurityPolicyTest.java | 151 ++++++++ .../TestingAuthoringSecurityPolicy.java | 53 +++ .../DockerScriptCommandBuilderTest.java | 85 +++++ .../DockerScriptRuntimeAdapterTest.java | 140 +++++++ ...iCompatibleRuntimeAdapterToolLoopTest.java | 281 ++++++++++++++ .../authoring/mcp/HttpMcpClientTest.java | 172 +++++++++ .../authoring/mcp/McpClientFactoryTest.java | 148 ++++++++ .../authoring/mcp/McpProbeServiceTest.java | 98 +++++ .../authoring/mcp/StdioMcpClientTest.java | 133 +++++++ .../authoring/mcp/TestingMcpHttpServer.java | 156 ++++++++ .../src/test/resources/application-test.yml | 13 + .../domain/authoring/AgentRuntimeType.java | 33 ++ .../domain/authoring/RuntimeBinding.java | 134 +++++++ .../authoring/RuntimeBindingRepository.java | 15 + .../authoring/runtime/AssertionEvaluator.java | 221 +++++++++++ .../authoring/runtime/AssertionFailure.java | 13 + .../authoring/runtime/RuntimeEventSink.java | 21 + .../runtime/RuntimeExecutionContext.java | 50 +++ .../runtime/SkillRuntimeAdapter.java | 46 +++ .../authoring/runtime/TaskCancellation.java | 12 + .../domain/authoring/runtime/TaskResult.java | 56 +++ .../authoring/runtime/package-info.java | 5 + .../service/AuthoringSecurityPolicy.java | 33 ++ .../service/RuntimeBindingService.java | 101 +++++ .../service/RuntimeBindingValidator.java | 236 ++++++++++++ .../domain/authoring/spec/AssertionSpec.java | 36 ++ .../domain/authoring/spec/TaskType.java | 13 + .../domain/authoring/spec/ValidationSpec.java | 28 ++ .../authoring/spec/ValidationSpecParser.java | 211 ++++++++++ .../authoring/spec/ValidationTaskSpec.java | 26 ++ .../domain/authoring/spec/package-info.java | 4 + .../jpa/RuntimeBindingJpaRepository.java | 19 + 58 files changed, 5677 insertions(+), 26 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringRuntimeFlowIntegrationTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java create mode 100644 server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java index 2379f0794..3a5283a3a 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringProperties.java @@ -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,12 @@ 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(); + + private final OpenAiCompatible openAiCompatible = new OpenAiCompatible(); + + private final Mcp mcp = new Mcp(); + public String getWorkspaceRoot() { return workspaceRoot; } @@ -54,4 +60,278 @@ public int getStaleRunMinutes() { public void setStaleRunMinutes(int staleRunMinutes) { this.staleRunMinutes = staleRunMinutes; } + + public LocalScript getLocalScript() { + return localScript; + } + + public OpenAiCompatible getOpenAiCompatible() { + return openAiCompatible; + } + + public Mcp getMcp() { + return mcp; + } + + /** 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 + } + + /** + * OpenAI-compatible chat completion runtime. The API key is resolved here + * (environment/config), never from a per-draft runtime binding. + */ + public static class OpenAiCompatible { + private boolean enabled = false; + private String apiKey; + private String defaultEndpoint; + private String defaultModel; + private int timeoutMs = 120_000; + /** Max chat-completion rounds per prompt task (each round may execute MCP tool calls). */ + private int maxToolRounds = 4; + /** + * SSRF guard escape hatch for enterprise deployments whose LLM endpoint + * lives on a private network (e.g. an internal vLLM). Loopback and + * RFC1918 destinations stay blocked by default; link-local (cloud + * metadata) is always blocked. + */ + private boolean allowPrivateEndpoints = false; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getApiKey() { + return apiKey; + } + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public String getDefaultEndpoint() { + return defaultEndpoint; + } + + public void setDefaultEndpoint(String defaultEndpoint) { + this.defaultEndpoint = defaultEndpoint; + } + + public String getDefaultModel() { + return defaultModel; + } + + public void setDefaultModel(String defaultModel) { + this.defaultModel = defaultModel; + } + + public int getTimeoutMs() { + return timeoutMs; + } + + public void setTimeoutMs(int timeoutMs) { + this.timeoutMs = timeoutMs; + } + + public int getMaxToolRounds() { + return maxToolRounds; + } + + public void setMaxToolRounds(int maxToolRounds) { + this.maxToolRounds = maxToolRounds; + } + + public boolean isAllowPrivateEndpoints() { + return allowPrivateEndpoints; + } + + public void setAllowPrivateEndpoints(boolean allowPrivateEndpoints) { + this.allowPrivateEndpoints = allowPrivateEndpoints; + } + } + + /** + * Security knobs for user-declared MCP servers. Defaults are the safe + * production posture: private endpoints blocked, stdio transport off, no + * environment references allowed. Local development relaxes them via + * application-local.yml. + */ + public static class Mcp { + /** + * When false (default), http/sse MCP endpoints pointing at loopback, + * RFC1918, ULA, or CGNAT addresses are rejected. Link-local addresses + * (cloud metadata) are always rejected. + */ + private boolean allowPrivateEndpoints = false; + + /** When false (default), stdio MCP servers cannot be declared or spawned. */ + private boolean stdioEnabled = false; + + /** Server environment variables a binding may reference via envRefs. */ + private List envAllowlist = new ArrayList<>(); + + private final Docker docker = new Docker(); + + public boolean isAllowPrivateEndpoints() { + return allowPrivateEndpoints; + } + + public void setAllowPrivateEndpoints(boolean allowPrivateEndpoints) { + this.allowPrivateEndpoints = allowPrivateEndpoints; + } + + public boolean isStdioEnabled() { + return stdioEnabled; + } + + public void setStdioEnabled(boolean stdioEnabled) { + this.stdioEnabled = stdioEnabled; + } + + public List getEnvAllowlist() { + return envAllowlist; + } + + public void setEnvAllowlist(List envAllowlist) { + this.envAllowlist = envAllowlist; + } + + public Docker getDocker() { + return docker; + } + + /** Isolation profile for stdio MCP servers in 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; + } + } + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java new file mode 100644 index 000000000..47eb0e36a --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/AuthoringStartupGuard.java @@ -0,0 +1,51 @@ +package com.iflytek.skillhub.config; + +import java.util.Arrays; +import java.util.Set; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.core.env.Environment; +import org.springframework.stereotype.Component; + +/** + * Fails application startup when user-authored code would run directly on the + * host outside a development profile: with {@code skillhub.authoring.local-script} + * enabled in {@code INLINE} execution mode, validation scripts and stdio MCP + * commands execute as server subprocesses. Production deployments must use the + * {@code DOCKER} execution mode (or disable local-script entirely). + */ +@Component +public class AuthoringStartupGuard implements ApplicationRunner { + + /** Profiles in which host-side execution is a deliberate development choice. */ + private static final Set NON_PRODUCTION_PROFILES = Set.of("local", "dev", "test"); + + private final AuthoringProperties properties; + private final Environment environment; + + public AuthoringStartupGuard(AuthoringProperties properties, Environment environment) { + this.properties = properties; + this.environment = environment; + } + + @Override + public void run(ApplicationArguments args) { + if (!properties.getLocalScript().isEnabled()) { + return; + } + if (properties.getLocalScript().getExecutionMode() + != AuthoringProperties.ScriptExecutionMode.INLINE) { + return; + } + boolean nonProduction = Arrays.stream(environment.getActiveProfiles()) + .anyMatch(NON_PRODUCTION_PROFILES::contains); + if (nonProduction) { + return; + } + throw new IllegalStateException(""" + skillhub.authoring.local-script.execution-mode=inline is not allowed in \ + production: user-authored scripts and stdio MCP commands would run \ + directly on the host. Set SKILLHUB_AUTHORING_LOCAL_SCRIPT_MODE=docker \ + (locked-down containers) or disable local-script."""); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java new file mode 100644 index 000000000..02380113e --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicy.java @@ -0,0 +1,142 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * The production {@link AuthoringSecurityPolicy}, derived from + * {@code skillhub.authoring.*} configuration. + * + *

SSRF posture: the server only makes outbound requests to public + * addresses. Link-local addresses (which include the 169.254.169.254 cloud + * metadata service), multicast, and the unspecified address are always + * rejected. Loopback, RFC1918, unique-local IPv6, CGNAT, and the RFC 2544 + * benchmark range (198.18.0.0/15) are rejected unless the surface-specific + * {@code allow-private-endpoints} flag is set (used by local development to + * reach loopback fake servers). + * + *

Known limitation: the guard resolves the hostname at check time; the + * HttpClient re-resolves at connect time, which mitigates (but cannot fully + * eliminate) DNS-rebinding races. Binding the socket to the checked address + * is not implemented because it would break virtual-hosted MCP endpoints. + */ +@Component +public class ConfiguredAuthoringSecurityPolicy implements AuthoringSecurityPolicy { + + /** Resolves a host to addresses; the default delegates to the JDK resolver. */ + @FunctionalInterface + interface HostResolver { + InetAddress[] resolve(String host) throws UnknownHostException; + } + + private final AuthoringProperties properties; + private final HostResolver resolver; + + @Autowired + public ConfiguredAuthoringSecurityPolicy(AuthoringProperties properties) { + this(properties, InetAddress::getAllByName); + } + + /** Test seam: injects a deterministic resolver so tests need no network. */ + ConfiguredAuthoringSecurityPolicy(AuthoringProperties properties, HostResolver resolver) { + this.properties = properties; + this.resolver = resolver; + } + + @Override + public String endpointRejection(String url, EndpointUse use) { + URI uri; + try { + uri = URI.create(url); + } catch (IllegalArgumentException exception) { + return "malformed URL: " + url; + } + String scheme = uri.getScheme(); + if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { + return "only http(s) endpoints are allowed, got: " + scheme; + } + String host = uri.getHost(); + if (host == null || host.isBlank()) { + return "URL has no host: " + url; + } + boolean allowPrivate = use == EndpointUse.MCP_SERVER + ? properties.getMcp().isAllowPrivateEndpoints() + : properties.getOpenAiCompatible().isAllowPrivateEndpoints(); + + InetAddress[] addresses; + try { + addresses = resolver.resolve(host); + } catch (UnknownHostException exception) { + return "cannot resolve host '" + host + "'"; + } + for (InetAddress address : addresses) { + String reason = classify(address, allowPrivate); + if (reason != null) { + return reason; + } + } + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return properties.getMcp().isStdioEnabled(); + } + + @Override + public boolean envRefAllowed(String name) { + return properties.getMcp().getEnvAllowlist().contains(name); + } + + /** @return null when the address is allowed, otherwise the rejection reason. */ + private static String classify(InetAddress address, boolean allowPrivate) { + if (address.isAnyLocalAddress()) { + return "unspecified address is not a valid endpoint: " + address.getHostAddress(); + } + if (address.isLinkLocalAddress()) { + // 169.254.0.0/16 (incl. the cloud metadata service) and fe80::/10 + return "link-local address (cloud metadata range) is always blocked: " + + address.getHostAddress(); + } + if (address.isMulticastAddress()) { + return "multicast address is not a valid endpoint: " + address.getHostAddress(); + } + boolean privateRange = address.isLoopbackAddress() + || address.isSiteLocalAddress() + || isUniqueLocalIpv6(address) + || isCarrierGradeNat(address) + || isBenchmarkRange(address); + if (privateRange && !allowPrivate) { + return "private/loopback address is blocked unless allow-private-endpoints" + + " is enabled: " + address.getHostAddress(); + } + return null; + } + + /** fc00::/7 unique-local addresses are not covered by {@code isSiteLocalAddress}. */ + private static boolean isUniqueLocalIpv6(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 16 && (bytes[0] & 0xFE) == 0xFC; + } + + /** 100.64.0.0/10 carrier-grade NAT, a common internal service range. */ + private static boolean isCarrierGradeNat(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 4 && (bytes[0] & 0xFF) == 100 + && (bytes[1] & 0xFF) >= 64 && (bytes[1] & 0xFF) <= 127; + } + + /** + * 198.18.0.0/15 RFC 2544 benchmark range: reserved, never a legitimate + * endpoint, and used as the synthetic pool by fake-IP VPN/proxy resolvers. + */ + private static boolean isBenchmarkRange(InetAddress address) { + byte[] bytes = address.getAddress(); + return bytes.length == 4 && (bytes[0] & 0xFF) == 198 + && (bytes[1] & 0xFE) == 18; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java index ac0a3bf5f..936de7b9c 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java @@ -2,6 +2,7 @@ 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.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 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.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, tool allowlist and MCP servers; validation runs execute the + * structure, config and behavior layers and report findings with fix + * suggestions. The fix-apply loop and publish integration arrive 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 ApiResponse deleteFile(@PathVariable Long draftId, return ok("response.success.deleted", null); } + // ---------------------------------------------------------------- runtime binding + + @Operation(operationId = "getDraftRuntimeBinding", summary = "Get the draft's runtime binding") + @GetMapping("/drafts/{draftId}/runtime") + public ApiResponse getRuntime(@PathVariable Long draftId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set 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 saveRuntime(@PathVariable Long draftId, + @Valid @RequestBody RuntimeBindingRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set 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") diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java new file mode 100644 index 000000000..2a2cef8f0 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingRequest.java @@ -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 config, + List toolAllowlist, + List> mcpServers +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java new file mode 100644 index 000000000..7489cbb64 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/RuntimeBindingResponse.java @@ -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 config, + List toolAllowlist, + List> mcpServers, + Instant updatedAt +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java index 2a0c36ab7..ff8f74ddb 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java @@ -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,87 @@ 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. + * + *

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 McpProbeService mcpProbeService; + private final ValidationSpecParser specParser = new ValidationSpecParser(); + private final AssertionEvaluator assertionEvaluator = new AssertionEvaluator(); + private final List adapters; private final ValidationEventBroadcaster broadcaster; + private final AuthoringProperties properties; private final Executor executor; private final ScheduledExecutorService timer; - private final AuthoringProperties properties; private final Map> tasks = new ConcurrentHashMap<>(); public ValidationRunOrchestrator(ValidationRunService runService, SkillDraftService draftService, + RuntimeBindingService bindingService, DraftStructureValidator structureValidator, + RuntimeBindingValidator bindingValidator, + McpProbeService mcpProbeService, + List 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.mcpProbeService = mcpProbeService; + 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 task = new FutureTask<>(() -> { executeRun(runId); return null; }) { @Override protected void done() { tasks.remove(runId, this); } @@ -168,6 +206,7 @@ private void executePipeline(ValidationRun run) { "draftName", snapshot.name(), "draftRevision", run.getDraftRevision())); + Path workspace = workspaceFor(runId); try { // ---- STRUCTURE layer emit(runId, ValidationEventType.PHASE_STARTED, "STRUCTURE", Map.of()); @@ -179,20 +218,209 @@ private void executePipeline(ValidationRun run) { "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 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); + + // declared MCP servers must actually answer before the behavior layer + // relies on their tools: connect, initialize, tools/list + List> mcpServers = binding.getMcpServers() == null + ? List.of() : binding.getMcpServers(); + if (!mcpServers.isEmpty()) { + McpProbeService.ProbeReport probeReport = + mcpProbeService.probe(mcpServers, java.time.Duration.ofSeconds(10)); + probeReport.findings().forEach(context::recordFinding); + for (McpProbeService.ServerProbe serverProbe : probeReport.servers()) { + if (serverProbe.connected()) { + emit(context.runId, ValidationEventType.LOG, "CONFIG", Map.of( + "task", "mcp:" + serverProbe.server(), "stream", "summary", + "line", "connected; tools: " + (serverProbe.tools().isEmpty() + ? "(none)" : String.join(", ", serverProbe.tools())))); + } + } + } + + 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 adapterConfig = binding.getConfig() == null + ? Map.of() : binding.getConfig(); + List 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 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 payload) { ValidationEvent event = runService.appendEvent(runId, type, phase, payload); broadcaster.publish(runId, event); @@ -208,15 +436,75 @@ private void emitTerminalOnce(Long runId) { // ---------------------------------------------------------------- small helpers - private Map 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 summary(RunContext context, boolean behaviorSkipped) { Map 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 entries) + throws IOException { + Files.createDirectories(workspace); + for (PackageEntry entry : entries) { + Path target = workspace.resolve(entry.relativePath()).normalize(); + if (!target.startsWith(workspace.normalize())) { + throw new IOException("package entry escapes the workspace: " + entry.relativePath()); + } + Files.createDirectories(target.getParent()); + Files.write(target, entry.content()); + } + } + + private String readEntryText(List entries, String path) { + return entries.stream() + .filter(entry -> entry.relativePath().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 +512,15 @@ private static String truncate(String value, int limit) { 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 +528,8 @@ private final class RunContext { private final List findings = new ArrayList<>(); private int errorCount; private int warningCount; + private int tasksRun; + private int tasksFailed; RunContext(Long runId) { this.runId = runId; @@ -267,5 +566,37 @@ void recordFinding(FindingDraft draft) { "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))); + } + }; + } } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java new file mode 100644 index 000000000..197b0c133 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java @@ -0,0 +1,164 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.io.IOException; +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. + * + *

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 args, Map scrubbedEnvironment, + String runTag) { + AuthoringProperties.LocalScript.Docker docker = properties.getLocalScript().getDocker(); + String hostWorkspace = realPath(workspace); + + List command = new ArrayList<>(); + command.add("docker"); + command.add("run"); + command.add("--rm"); + command.add("--name"); + command.add(containerName(runTag)); + 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 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()); + } + } + + /** 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(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java new file mode 100644 index 000000000..6873649af --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/InlineScriptCommandBuilder.java @@ -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 args, Map scrubbedEnvironment, + String runTag) { + List command = new ArrayList<>(); + command.add(interpreter); + command.add(scriptPath.toString()); + command.addAll(args); + return new ScriptExecutionPlan(List.copyOf(command), Map.copyOf(scrubbedEnvironment)); + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java new file mode 100644 index 000000000..df1f1ae7f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/LocalScriptRuntimeAdapter.java @@ -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. + * + *

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 commandBuilders; + + public LocalScriptRuntimeAdapter(AuthoringProperties properties, + List 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 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 scrubEnvironment(RuntimeExecutionContext context) { + Map 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(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java new file mode 100644 index 000000000..f0f4124df --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapter.java @@ -0,0 +1,345 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.service.AuthoringSecurityPolicy; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.mcp.HttpMcpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClient; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.McpTool; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * Behavior-layer adapter for prompt tasks: an agentic loop against an + * OpenAI-compatible chat-completion endpoint. Tools declared on the binding's MCP + * servers are discovered live (initialize + tools/list), filtered by the server's + * toolFilters and the binding's toolAllowlist, exposed to the model as function + * tools, and executed for real when the model calls them — every chat round and + * every tool invocation/reply lands in the run's event trace. The API key is + * resolved from server-side configuration only; runtime bindings reference + * endpoints and models, never credentials. + */ +@Component +public class OpenAiCompatibleRuntimeAdapter implements SkillRuntimeAdapter { + + private static final String SKILL_MD = "SKILL.md"; + /** Cap on a single tool output fed back into the model context. */ + private static final int MAX_TOOL_OUTPUT_CHARS = 8_000; + /** Cap on tool discovery (initialize + tools/list) regardless of task timeout. */ + private static final Duration DISCOVERY_TIMEOUT = Duration.ofSeconds(30); + + private final AuthoringProperties properties; + private final ObjectMapper objectMapper; + private final McpClientFactory mcpClientFactory; + private final AuthoringSecurityPolicy securityPolicy; + private final HttpClient httpClient; + + public OpenAiCompatibleRuntimeAdapter(AuthoringProperties properties, ObjectMapper objectMapper, + McpClientFactory mcpClientFactory, + AuthoringSecurityPolicy securityPolicy) { + this.properties = properties; + this.objectMapper = objectMapper; + this.mcpClientFactory = mcpClientFactory; + this.securityPolicy = securityPolicy; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + // never follow redirects: a public endpoint answering 302 towards + // a link-local target must not become an SSRF bypass + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + @Override + public String agentType() { + return "openai-compatible"; + } + + @Override + public boolean supports(TaskType taskType) { + return taskType == TaskType.PROMPT; + } + + @Override + public boolean enabled() { + return properties.getOpenAiCompatible().isEnabled(); + } + + /** One tool the model may call, bound to the client that can execute it. */ + private record ExposedTool(McpClient client, String server, McpTool tool) { + } + + @Override + public TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception { + AuthoringProperties.OpenAiCompatible config = properties.getOpenAiCompatible(); + String endpoint = firstNonBlank(context.configString("endpoint"), config.getDefaultEndpoint()); + String model = firstNonBlank(context.configString("model"), config.getDefaultModel()); + if (endpoint == null || model == null) { + throw new IllegalStateException( + "openai-compatible runtime requires endpoint and model (binding config or server defaults)"); + } + // requests carry the server-side API key, so the destination must pass + // the SSRF guard before any credential leaves the host + String endpointRejection = securityPolicy.endpointRejection( + endpoint, AuthoringSecurityPolicy.EndpointUse.LLM_API); + if (endpointRejection != null) { + throw new IllegalStateException( + "openai-compatible endpoint is not allowed: " + endpointRejection); + } + String url = endpoint.endsWith("/") ? endpoint + "chat/completions" : endpoint + "/chat/completions"; + Duration requestTimeout = Duration.ofMillis(Math.max(task.timeoutMs(), 1_000)); + + List clients = new ArrayList<>(); + Map exposedTools = discoverTools(context, task, sink, clients); + try { + ArrayNode messages = objectMapper.createArrayNode(); + messages.addObject().put("role", "system").put("content", readSkillMd(context.workingDirectory())); + messages.addObject().put("role", "user").put("content", task.prompt()); + + int executedToolCalls = 0; + String content = ""; + int maxRounds = Math.max(config.getMaxToolRounds(), 1); + for (int round = 1; round <= maxRounds; round++) { + if (context.cancellation().isCancelRequested()) { + throw new IllegalStateException("task '" + task.name() + "' cancelled"); + } + + ObjectNode requestBody = chatRequest(model, context, messages, exposedTools, round); + sink.toolCall(task.name(), "openai-chat", requestBody.get("meta").toString()); + JsonNode message = postChatCompletion(url, config, requestBody, requestTimeout); + + content = message.path("content").asText(""); + JsonNode toolCalls = message.path("tool_calls"); + if (toolCalls.isEmpty() || exposedTools.isEmpty()) { + break; // final answer (or nothing we can execute) + } + + // record the assistant turn verbatim, then append one tool reply per call + ObjectNode assistantMessage = objectMapper.createObjectNode(); + assistantMessage.put("role", "assistant"); + if (!content.isEmpty()) { + assistantMessage.put("content", content); + } + HttpMcpClient.attachToolCalls(objectMapper, assistantMessage, toolCalls); + messages.add(assistantMessage); + + for (JsonNode call : toolCalls) { + String callId = call.path("id").asText("call-" + executedToolCalls); + String functionName = call.path("function").path("name").asText(""); + String argumentsJson = call.path("function").path("arguments").asText("{}"); + ExposedTool exposed = exposedTools.get(functionName); + if (exposed == null) { + appendToolReply(messages, callId, "error: unknown tool '" + functionName + "'"); + sink.toolResult(task.name(), "mcp:" + functionName, + "{\"error\":\"unknown tool\"}"); + continue; + } + sink.toolCall(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + argumentsJson); + String reply; + try { + Map arguments = parseArguments(argumentsJson); + String output = exposed.client().callTool( + exposed.tool().name(), arguments, requestTimeout); + reply = truncate(output, MAX_TOOL_OUTPUT_CHARS); + executedToolCalls++; + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"ok\":true,\"length\":" + output.length() + "}"); + } catch (Exception exception) { + reply = "error: tool call failed: " + exception.getMessage(); + sink.toolResult(task.name(), "mcp:" + exposed.server() + "/" + exposed.tool().name(), + "{\"error\":" + jsonString(String.valueOf(exception.getMessage())) + "}"); + } + appendToolReply(messages, callId, reply); + } + if (round == maxRounds) { + sink.log(task.name(), "summary", + "round limit (" + maxRounds + ") reached while the model still requests tools"); + } + } + + sink.agentMessage(task.name(), truncate(content, 4_000)); + return TaskResult.ofPrompt(content, executedToolCalls, + new WorkingDirectoryArtifacts(context.workingDirectory())); + } finally { + clients.forEach(McpClient::close); + } + } + + // ---------------------------------------------------------------- tool discovery + + /** + * Connects to every declared MCP server, lists its tools, and keeps the ones + * allowed by the server's toolFilters and the binding's global toolAllowlist + * (empty allowlist = unrestricted; entries match the bare tool name or + * "server.tool"). Keys of the returned map are the function names exposed to + * the model. Every client created along the way is appended to {@code clients} + * so the caller can close them all — even servers whose tools were entirely + * filtered out. + */ + private Map discoverTools(RuntimeExecutionContext context, + ValidationTaskSpec task, RuntimeEventSink sink, + List clients) { + Map exposed = new LinkedHashMap<>(); + List allowlist = context.safeToolAllowlist(); + for (Map server : context.safeMcpServers()) { + String serverName = String.valueOf(server.get("name")); + Set toolFilters = server.get("toolFilters") instanceof List filters + ? filters.stream().map(String::valueOf).collect(Collectors.toSet()) + : Set.of(); + List tools; + McpClient client; + try { + client = mcpClientFactory.create(server); + clients.add(client); + tools = client.listTools(DISCOVERY_TIMEOUT); + } catch (Exception exception) { + // the config-layer probe already reported unreachable servers; skip quietly + sink.log(task.name(), "mcp", "server '" + serverName + "' unavailable during task: " + + exception.getMessage()); + continue; + } + for (McpTool tool : tools) { + if (!toolFilters.isEmpty() && !toolFilters.contains(tool.name())) { + continue; + } + if (!allowlist.isEmpty() && !allowlist.contains(tool.name()) + && !allowlist.contains(serverName + "." + tool.name())) { + continue; + } + exposed.put(exposedName(exposed.keySet(), serverName, tool.name()), + new ExposedTool(client, serverName, tool)); + } + } + return exposed; + } + + /** Function names must match ^[a-zA-Z0-9_-]{1,64}$; MCP names may contain dots. */ + private String exposedName(Set taken, String serverName, String toolName) { + String candidate = (serverName + "__" + toolName).replaceAll("[^a-zA-Z0-9_-]", "_"); + if (candidate.length() > 64) { + candidate = candidate.substring(0, 64); + } + String unique = candidate; + int suffix = 2; + while (taken.contains(unique)) { + String tail = "_" + suffix++; + unique = candidate.substring(0, 64 - tail.length()) + tail; + } + return unique; + } + + // ---------------------------------------------------------------- chat protocol + + private ObjectNode chatRequest(String model, RuntimeExecutionContext context, ArrayNode messages, + Map exposedTools, int round) { + ObjectNode body = objectMapper.createObjectNode(); + body.put("model", model); + body.put("stream", false); + Object temperature = context.safeAdapterConfig().get("temperature"); + if (temperature instanceof Number number) { + body.put("temperature", number.doubleValue()); + } + body.set("messages", messages); + if (!exposedTools.isEmpty()) { + ArrayNode tools = body.putArray("tools"); + exposedTools.forEach((exposedName, tool) -> tools.add( + HttpMcpClient.toFunctionSchema(objectMapper, tool.tool(), exposedName))); + } + // trace metadata; removed before the body reaches the endpoint + ObjectNode meta = objectMapper.createObjectNode(); + meta.put("model", model); + meta.put("round", round); + meta.put("messages", messages.size()); + meta.put("tools", exposedTools.size()); + body.set("meta", meta); + return body; + } + + /** Posts one chat-completion request and returns the first choice's message. */ + private JsonNode postChatCompletion(String url, AuthoringProperties.OpenAiCompatible config, + ObjectNode requestBody, Duration timeout) throws Exception { + requestBody.remove("meta"); // trace-only field must not reach the endpoint + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(url)) + .timeout(timeout) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(requestBody.toString(), StandardCharsets.UTF_8)); + if (config.getApiKey() != null && !config.getApiKey().isBlank()) { + requestBuilder.header("Authorization", "Bearer " + config.getApiKey()); + } + HttpResponse response = + httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream()); + String body = HttpMcpClient.readBodyCapped(response.body(), HttpMcpClient.MAX_RESPONSE_BYTES); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("chat completion failed with HTTP " + response.statusCode() + + ": " + truncate(body, 500)); + } + return objectMapper.readTree(body).path("choices").path(0).path("message"); + } + + private void appendToolReply(ArrayNode messages, String toolCallId, String content) { + messages.add(HttpMcpClient.toolResponseMessage(objectMapper, toolCallId, content)); + } + + @SuppressWarnings("unchecked") + private Map parseArguments(String argumentsJson) { + try { + return objectMapper.readValue(argumentsJson, Map.class); + } catch (Exception exception) { + return new HashMap<>(); + } + } + + private String jsonString(String value) { + return objectMapper.valueToTree(value == null ? "" : value).toString(); + } + + private String readSkillMd(Path workingDirectory) throws IOException { + Path skillMd = workingDirectory.resolve(SKILL_MD); + if (Files.isRegularFile(skillMd)) { + return Files.readString(skillMd, StandardCharsets.UTF_8); + } + return "No SKILL.md is present in this draft."; + } + + private static String firstNonBlank(String primary, String fallback) { + if (primary != null && !primary.isBlank()) { + return primary; + } + return fallback == null || fallback.isBlank() ? null : fallback; + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java new file mode 100644 index 000000000..c257be56c --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/ScriptCommandBuilder.java @@ -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 args, Map 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 command, Map environment) { + + public static ScriptExecutionPlan inherited(List command) { + return new ScriptExecutionPlan(command, null); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java new file mode 100644 index 000000000..25d6ae65f --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/WorkingDirectoryArtifacts.java @@ -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 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(); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java new file mode 100644 index 000000000..47c4ec5ff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClient.java @@ -0,0 +1,264 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * MCP client for the streamable-HTTP transport (also used for legacy SSE + * endpoints that accept plain JSON-RPC POSTs). Each request is one JSON-RPC + * POST; responses may arrive as a bare JSON object or as a text/event-stream + * body whose data lines carry the JSON-RPC message. Response bodies are + * size-capped so a hostile endpoint cannot exhaust server memory. + */ +public class HttpMcpClient implements McpClient { + + public static final String PROTOCOL_VERSION = "2024-11-05"; + public static final String CLIENT_NAME = "skillhub-authoring"; + /** One HTTP response body may not exceed this many bytes. */ + public static final int MAX_RESPONSE_BYTES = 2 * 1024 * 1024; + + private final String serverName; + private final String endpoint; + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final Map extraHeaders; + private int nextId = 1; + private String sessionId; + + public HttpMcpClient(String serverName, String endpoint, HttpClient httpClient, + ObjectMapper objectMapper, Map extraHeaders) { + this.serverName = serverName; + this.endpoint = endpoint; + this.httpClient = httpClient; + this.objectMapper = objectMapper; + this.extraHeaders = Map.copyOf(extraHeaders); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", CLIENT_NAME); + clientInfo.put("version", "1.0"); + // the server may assign a session id here; request() captures it for later calls + request("initialize", params, timeout); + // notify the server the handshake is complete; the response is irrelevant + notification("notifications/initialized", timeout); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + // HTTP transport is stateless from this client's point of view + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(request.toString(), StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + + HttpResponse response; + try { + response = httpClient.send(builder.build(), HttpResponse.BodyHandlers.ofInputStream()); + } catch (IOException exception) { + throw new IOException("MCP server '" + serverName + "' request failed (" + + method + "): " + exception, exception); + } + String body = readBodyCapped(response.body(), MAX_RESPONSE_BYTES); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IOException("MCP server '" + serverName + "' returned HTTP " + + response.statusCode() + " for " + method + ": " + truncate(body, 200)); + } + String sessionHeader = response.headers().firstValue("Mcp-Session-Id").orElse(null); + if (sessionHeader != null) { + sessionId = sessionHeader; + } + JsonNode message = parseMessage(body, request.path("id").asInt()); + if (message.has("error")) { + throw new IOException("MCP server '" + serverName + "' error on " + method + ": " + + message.path("error").path("message").asText()); + } + return message.path("result"); + } + + private void notification(String method, Duration timeout) { + try { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + HttpRequest.Builder builder = HttpRequest.newBuilder() + .uri(URI.create(endpoint)) + .timeout(timeout) + .header("Content-Type", "application/json") + .header("Accept", "application/json, text/event-stream") + .POST(HttpRequest.BodyPublishers.ofString(notification.toString(), + StandardCharsets.UTF_8)); + extraHeaders.forEach(builder::header); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + httpClient.send(builder.build(), HttpResponse.BodyHandlers.discarding()); + } catch (Exception ignored) { + // notifications are best-effort; servers that reject them still work + } + } + + /** Accepts either a bare JSON-RPC response or an SSE body with data lines. */ + private JsonNode parseMessage(String body, int requestId) throws IOException { + if (body == null || body.isBlank()) { + throw new IOException("MCP server '" + serverName + "' returned an empty body"); + } + String text = body.trim(); + if (text.startsWith("{")) { + return objectMapper.readTree(text); + } + for (String line : body.split("\n")) { + String trimmed = line.trim(); + if (!trimmed.startsWith("data:")) { + continue; + } + String payload = trimmed.substring("data:".length()).trim(); + if (payload.isEmpty()) { + continue; + } + JsonNode node = objectMapper.readTree(payload); + if (node.path("id").asInt(-1) == requestId) { + return node; + } + } + throw new IOException("MCP server '" + serverName + + "' response did not contain a result for request " + requestId); + } + + /** Helper for tests and callers that need the OpenAI-style tool schema. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool) { + return toFunctionSchema(objectMapper, tool, tool.name()); + } + + /** Same, under the function name the caller exposes to the model. */ + public static ObjectNode toFunctionSchema(ObjectMapper objectMapper, McpTool tool, + String functionName) { + ObjectNode function = objectMapper.createObjectNode(); + function.put("name", functionName); + function.put("description", tool.description() == null ? "" : tool.description()); + function.set("parameters", tool.inputSchema() == null || tool.inputSchema().isNull() + ? objectMapper.createObjectNode() + : tool.inputSchema()); + ObjectNode wrapper = objectMapper.createObjectNode(); + wrapper.put("type", "function"); + wrapper.set("function", function); + return wrapper; + } + + /** Helper: builds the messages-array tool role entry for chat completions. */ + public static ObjectNode toolResponseMessage(ObjectMapper objectMapper, String toolCallId, + String content) { + ObjectNode message = objectMapper.createObjectNode(); + message.put("role", "tool"); + message.put("tool_call_id", toolCallId); + message.put("content", content); + return message; + } + + /** Helper: attaches tool call requests to an assistant message. */ + public static void attachToolCalls(ObjectMapper objectMapper, ObjectNode assistantMessage, + JsonNode toolCalls) { + ArrayNode calls = assistantMessage.putArray("tool_calls"); + for (JsonNode call : toolCalls) { + calls.add(call.deepCopy()); + } + } + + /** + * Reads a response body fully into a UTF-8 string, refusing bodies beyond + * {@code maxBytes}: a hostile endpoint must not be able to buffer an + * unbounded response in server memory. + */ + public static String readBodyCapped(InputStream body, int maxBytes) throws IOException { + try (body) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] chunk = new byte[8192]; + int read; + while ((read = body.read(chunk)) != -1) { + if (buffer.size() + read > maxBytes) { + throw new IOException("response body exceeds " + maxBytes + " bytes"); + } + buffer.write(chunk, 0, read); + } + return buffer.toString(StandardCharsets.UTF_8); + } + } + + private static String truncate(String value, int limit) { + if (value == null) { + return ""; + } + return value.length() <= limit ? value : value.substring(0, limit) + "…"; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java new file mode 100644 index 000000000..870c2f2fc --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClient.java @@ -0,0 +1,29 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * A live connection to one MCP server declared in a runtime binding. Implementations + * speak the MCP JSON-RPC protocol (initialize, tools/list, tools/call) over their + * transport and are closed after use. + */ +public interface McpClient extends AutoCloseable { + + /** Display name from the binding declaration, used in events and findings. */ + String serverName(); + + /** Performs the initialize handshake and a tools/list; returns the offered tools. */ + List listTools(Duration timeout) throws Exception; + + /** + * Executes one tool call and returns its text content. Implementations must + * surface MCP-level errors (isError or JSON-RPC error) as exceptions. + */ + String callTool(String toolName, Map arguments, Duration timeout) + throws Exception; + + @Override + void close(); +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java new file mode 100644 index 000000000..36a080aeb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactory.java @@ -0,0 +1,159 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import java.io.IOException; +import java.net.http.HttpClient; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.springframework.stereotype.Component; + +/** + * Builds {@link McpClient} instances from the runtime binding's MCP server + * declarations. Transport "http"/"sse" yield an HTTP JSON-RPC client; "stdio" + * spawns the declared command with only the envRefs-named variables from the + * server process environment (the only way credentials reach an MCP server). + * + *

Every declaration is re-checked here against the + * {@link AuthoringSecurityPolicy} even though the binding validator already + * rejected unsafe saves: this is the connect-time enforcement point that also + * covers bindings persisted by older deployments. In docker execution mode, + * stdio servers run inside the same locked-down container profile as + * validation scripts — no network, resource caps, read-only rootfs — and the + * container is force-removed when the client closes. + */ +@Component +public class McpClientFactory { + + private final ObjectMapper objectMapper; + private final AuthoringProperties properties; + private final AuthoringSecurityPolicy securityPolicy; + private final HttpClient httpClient; + + public McpClientFactory(ObjectMapper objectMapper, AuthoringProperties properties, + AuthoringSecurityPolicy securityPolicy) { + this.objectMapper = objectMapper; + this.properties = properties; + this.securityPolicy = securityPolicy; + this.httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + // redirects must never be followed: a public URL answering 302 + // towards a link-local target is the classic SSRF bypass + .followRedirects(HttpClient.Redirect.NEVER) + .build(); + } + + /** @param server declaration map: name, transport, endpoint|command, toolFilters, envRefs */ + public McpClient create(Map server) throws IOException { + String name = String.valueOf(server.get("name")); + String transport = String.valueOf(server.get("transport")); + if ("http".equals(transport) || "sse".equals(transport)) { + String endpoint = String.valueOf(server.get("endpoint")); + String rejection = securityPolicy.endpointRejection( + endpoint, AuthoringSecurityPolicy.EndpointUse.MCP_SERVER); + if (rejection != null) { + throw new IOException("MCP server '" + name + "' endpoint rejected: " + rejection); + } + return new HttpMcpClient(name, endpoint, httpClient, objectMapper, Map.of()); + } + if ("stdio".equals(transport)) { + if (!securityPolicy.stdioTransportAllowed()) { + throw new IOException("MCP server '" + name + + "': stdio transport is disabled (skillhub.authoring.mcp.stdio-enabled)"); + } + List command = commandOf(server); + Map environment = environmentOf(server); + if (properties.getLocalScript().getExecutionMode() + == AuthoringProperties.ScriptExecutionMode.DOCKER) { + String container = containerName(name); + return new StdioMcpClient(name, dockerWrappedCommand(container, command, environment), + Map.of(), objectMapper, List.of("docker", "rm", "-f", container), true); + } + return new StdioMcpClient(name, command, environment, objectMapper); + } + throw new IllegalArgumentException( + "MCP server '" + name + "' has unsupported transport: " + transport); + } + + /** + * The docker argv that isolates a stdio MCP server: throwaway container, + * no network, resource caps, read-only rootfs, all capabilities dropped. + * Environment variables travel as explicit {@code -e} flags so the docker + * CLI can keep its own environment (needed to reach the daemon). + */ + List dockerWrappedCommand(String container, List command, + Map environment) { + AuthoringProperties.Mcp.Docker docker = properties.getMcp().getDocker(); + List wrapped = new ArrayList<>(); + wrapped.add("docker"); + wrapped.add("run"); + wrapped.add("--rm"); + wrapped.add("--name"); + wrapped.add(container); + wrapped.add("--network"); + wrapped.add("none"); + wrapped.add("--memory"); + wrapped.add(docker.getMemory()); + wrapped.add("--cpus"); + wrapped.add(docker.getCpus()); + wrapped.add("--pids-limit"); + wrapped.add(String.valueOf(docker.getPidsLimit())); + wrapped.add("--read-only"); + wrapped.add("--tmpfs"); + wrapped.add("/tmp:rw,size=" + docker.getTmpfsSize()); + wrapped.add("--cap-drop"); + wrapped.add("ALL"); + wrapped.add("--security-opt"); + wrapped.add("no-new-privileges"); + wrapped.add("--log-driver"); + wrapped.add("none"); + for (Map.Entry entry : environment.entrySet()) { + wrapped.add("-e"); + wrapped.add(entry.getKey() + "=" + entry.getValue()); + } + wrapped.add(docker.getImage()); + wrapped.addAll(command); + return List.copyOf(wrapped); + } + + /** Container names allow [a-zA-Z0-9][a-zA-Z0-9_.-]+ and must be unique per client. */ + static String containerName(String serverName) { + String sanitized = serverName == null ? "unknown" : serverName.replaceAll("[^a-zA-Z0-9_.-]", "-"); + if (sanitized.isEmpty() || !Character.isLetterOrDigit(sanitized.charAt(0))) { + sanitized = "m" + sanitized; + } + return "skillhub-mcp-" + sanitized + "-" + UUID.randomUUID().toString().substring(0, 8); + } + + /** Splits the command on whitespace; no shell is involved. */ + private static List commandOf(Map server) { + String command = String.valueOf(server.get("command")); + return List.of(command.trim().split("\\s+")); + } + + /** + * Only envRefs-named variables from the server environment are passed + * through, and only when the security policy allowlists them. + */ + Map environmentOf(Map server) { + Map environment = new HashMap<>(); + if (server.get("envRefs") instanceof List refs) { + for (Object ref : refs) { + String name = String.valueOf(ref); + if (!securityPolicy.envRefAllowed(name)) { + continue; + } + String value = System.getenv(name); + if (value != null) { + environment.put(name, value); + } + } + } + return environment; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java new file mode 100644 index 000000000..b3c902d39 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeService.java @@ -0,0 +1,92 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.ValidationLayer; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.springframework.stereotype.Component; + +/** + * CONFIG-layer liveness probe for declared MCP servers: connects, completes the + * initialize handshake, lists tools, and reports unreachable servers and + * toolFilters that reference tools the server does not expose. This is what turns + * an MCP declaration from "syntactically valid" into "verified working before + * the behavior layer relies on it". + */ +@Component +public class McpProbeService { + + private static final Set KNOWN_TRANSPORTS = Set.of("http", "sse", "stdio"); + + private final McpClientFactory clientFactory; + + public McpProbeService(McpClientFactory clientFactory) { + this.clientFactory = clientFactory; + } + + /** One server's probe outcome: either the discovered tool names or the failure. */ + public record ServerProbe(String server, boolean connected, List tools, String error) { + static ServerProbe connected(String server, List tools) { + return new ServerProbe(server, true, List.copyOf(tools), null); + } + + static ServerProbe failed(String server, String error) { + return new ServerProbe(server, false, List.of(), error); + } + } + + /** All probes plus the findings the orchestrator should record. */ + public record ProbeReport(List servers, List findings) { + } + + public ProbeReport probe(List> mcpServers, java.time.Duration timeout) { + List probes = new ArrayList<>(); + List findings = new ArrayList<>(); + for (Map server : mcpServers) { + Object name = server.get("name"); + Object transport = server.get("transport"); + if (name == null || transport == null || !KNOWN_TRANSPORTS.contains(transport.toString())) { + continue; // already reported by RuntimeBindingValidator + } + probes.add(probeOne(server, name.toString(), timeout, findings)); + } + return new ProbeReport(List.copyOf(probes), List.copyOf(findings)); + } + + private ServerProbe probeOne(Map server, String name, + java.time.Duration timeout, List findings) { + try (McpClient client = clientFactory.create(server)) { + List tools = client.listTools(timeout); + List toolNames = tools.stream().map(McpTool::name).toList(); + checkToolFilters(server, name, toolNames, findings); + return ServerProbe.connected(name, toolNames); + } catch (Exception exception) { + String message = String.valueOf(exception.getMessage()); + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_CONNECT_FAILED", + "MCP server '" + name + "' cannot be reached: " + + (message.length() > 300 ? message.substring(0, 300) + "…" : message))); + return ServerProbe.failed(name, message); + } + } + + private void checkToolFilters(Map server, String name, List toolNames, + List findings) { + if (!(server.get("toolFilters") instanceof List filters) || filters.isEmpty()) { + return; + } + Set discovered = Set.copyOf(toolNames); + List unknown = filters.stream() + .map(String::valueOf) + .filter(filter -> !discovered.contains(filter)) + .collect(Collectors.toList()); + if (!unknown.isEmpty()) { + findings.add(FindingDraft.warning(ValidationLayer.CONFIG, "MCP_TOOL_FILTER_UNKNOWN", + "runtime binding", "MCP server '" + name + "' toolFilters reference unknown " + + "tool(s): " + String.join(", ", unknown) + "; discovered tools: " + + (toolNames.isEmpty() ? "(none)" : String.join(", ", toolNames)))); + } + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java new file mode 100644 index 000000000..ad6f546eb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/McpTool.java @@ -0,0 +1,14 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * One tool discovered from an MCP server via {@code tools/list}. + */ +public record McpTool(String name, String description, JsonNode inputSchema) { + + @Override + public String toString() { + return name; + } +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java new file mode 100644 index 000000000..bb9ef8cbb --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClient.java @@ -0,0 +1,236 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * MCP client for the stdio transport: the declared command is spawned as a + * subprocess and JSON-RPC messages are exchanged as newline-delimited JSON on + * its stdin/stdout. Only environment variables named in {@code envRefs} are + * passed through; the process is destroyed on close. A single stdout line is + * capped so a hostile server cannot exhaust memory with an unterminated line. + */ +public class StdioMcpClient implements McpClient { + + /** One JSON-RPC message may not exceed this many characters. */ + private static final int MAX_LINE_CHARS = 2 * 1024 * 1024; + private static final long TEARDOWN_TIMEOUT_MS = 5_000; + + private final String serverName; + private final Process process; + private final BufferedWriter stdin; + private final BufferedReader stdout; + private final ObjectMapper objectMapper; + private final List teardownCommand; + private int nextId = 1; + + public StdioMcpClient(String serverName, List command, Map environment, + ObjectMapper objectMapper) throws IOException { + this(serverName, command, environment, objectMapper, null, false); + } + + /** + * @param teardownCommand best-effort cleanup executed after the process is + * destroyed (e.g. {@code docker rm -f }); + * may be null + * @param inheritEnvironment when true the parent environment is kept — only + * legitimate for docker-wrapped commands, where the + * docker CLI needs it to reach the daemon and the + * container only receives its explicit {@code -e} vars + */ + public StdioMcpClient(String serverName, List command, Map environment, + ObjectMapper objectMapper, List teardownCommand, + boolean inheritEnvironment) throws IOException { + this.serverName = serverName; + this.objectMapper = objectMapper; + this.teardownCommand = teardownCommand; + ProcessBuilder processBuilder = new ProcessBuilder(command); + if (!inheritEnvironment) { + processBuilder.environment().clear(); + processBuilder.environment().putAll(environment); + } + processBuilder.redirectErrorStream(false); + this.process = processBuilder.start(); + this.stdin = new BufferedWriter( + new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8)); + this.stdout = new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8)); + } + + @Override + public String serverName() { + return serverName; + } + + @Override + public List listTools(Duration timeout) throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("protocolVersion", HttpMcpClient.PROTOCOL_VERSION); + params.set("capabilities", objectMapper.createObjectNode()); + ObjectNode clientInfo = params.putObject("clientInfo"); + clientInfo.put("name", HttpMcpClient.CLIENT_NAME); + clientInfo.put("version", "1.0"); + request("initialize", params, timeout); + notification("notifications/initialized"); + + JsonNode tools = request("tools/list", objectMapper.createObjectNode(), timeout); + List discovered = new ArrayList<>(); + for (JsonNode tool : tools.path("tools")) { + discovered.add(new McpTool( + tool.path("name").asText(""), + tool.path("description").asText(""), + tool.path("inputSchema"))); + } + return discovered; + } + + @Override + public String callTool(String toolName, Map arguments, Duration timeout) + throws Exception { + ObjectNode params = objectMapper.createObjectNode(); + params.put("name", toolName); + params.set("arguments", objectMapper.valueToTree(arguments == null ? Map.of() : arguments)); + JsonNode result = request("tools/call", params, timeout); + if (result.path("isError").asBoolean(false)) { + throw new IOException("MCP tool '" + toolName + "' returned an error result on server '" + + serverName + "'"); + } + StringBuilder content = new StringBuilder(); + for (JsonNode part : result.path("content")) { + if ("text".equals(part.path("type").asText())) { + if (content.length() > 0) { + content.append('\n'); + } + content.append(part.path("text").asText("")); + } + } + return content.toString(); + } + + @Override + public void close() { + process.destroy(); + try { + if (!process.waitFor(3, TimeUnit.SECONDS)) { + process.destroyForcibly(); + } + } catch (InterruptedException ignored) { + process.destroyForcibly(); + Thread.currentThread().interrupt(); + } + runTeardown(); + } + + /** + * Best-effort cleanup after the process is gone: a SIGKILLed + * {@code docker run} client can leave its container behind, so the + * docker-wrapped mode removes the container by name. + */ + private void runTeardown() { + if (teardownCommand == null) { + return; + } + try { + Process cleanup = new ProcessBuilder(teardownCommand).start(); + if (!cleanup.waitFor(TEARDOWN_TIMEOUT_MS, TimeUnit.MILLISECONDS)) { + cleanup.destroyForcibly(); + } + } catch (IOException | InterruptedException exception) { + // cleanup is best-effort; the container also exits via --rm + } + } + + private JsonNode request(String method, ObjectNode params, Duration timeout) throws Exception { + ObjectNode request = objectMapper.createObjectNode(); + request.put("jsonrpc", "2.0"); + request.put("id", nextId++); + request.put("method", method); + request.set("params", params); + stdin.write(request.toString()); + stdin.write('\n'); + stdin.flush(); + return readResponse(request.path("id").asInt(), timeout); + } + + private void notification(String method) throws IOException { + ObjectNode notification = objectMapper.createObjectNode(); + notification.put("jsonrpc", "2.0"); + notification.put("method", method); + stdin.write(notification.toString()); + stdin.write('\n'); + stdin.flush(); + } + + /** Reads stdout lines until the response with the matching id arrives. */ + private JsonNode readResponse(int requestId, Duration timeout) throws IOException { + // readLine() blocks, so a silently hanging server needs a watchdog: kill the + // process after the timeout and readLine unblocks with end-of-stream. + Thread watchdog = Thread.ofVirtual().start(() -> { + try { + Thread.sleep(timeout.toMillis()); + process.destroyForcibly(); + } catch (InterruptedException ignored) { + // the response arrived in time + } + }); + try { + long deadline = System.nanoTime() + timeout.toNanos(); + while (System.nanoTime() < deadline) { + String line = readLineCapped(); + if (line == null) { + throw new IOException("MCP server '" + serverName + "' (stdio) closed its output"); + } + String trimmed = line.trim(); + if (!trimmed.startsWith("{")) { + continue; + } + JsonNode node = objectMapper.readTree(trimmed); + if (node.path("id").asInt(-1) != requestId) { + continue; // notification or log output from the server + } + if (node.has("error")) { + throw new IOException("MCP server '" + serverName + "' error: " + + node.path("error").path("message").asText()); + } + return node.path("result"); + } + throw new IOException("MCP server '" + serverName + "' (stdio) timed out after " + timeout); + } finally { + watchdog.interrupt(); + } + } + + /** + * Reads one line like {@link BufferedReader#readLine()} but refuses lines + * beyond {@link #MAX_LINE_CHARS}: a hostile stdio server could otherwise + * buffer an unterminated line of unbounded length in memory. + */ + private String readLineCapped() throws IOException { + StringBuilder line = new StringBuilder(); + int character; + while ((character = stdout.read()) != -1) { + if (character == '\n' || character == '\r') { + return line.toString(); + } + line.append((char) character); + if (line.length() > MAX_LINE_CHARS) { + process.destroyForcibly(); + throw new IOException("MCP server '" + serverName + + "' (stdio) sent a line longer than " + MAX_LINE_CHARS + " chars"); + } + } + return line.length() == 0 ? null : line.toString(); + } +} diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index 3b2f58f82..444d3a4d3 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -161,6 +161,49 @@ skillhub: lease-duration: ${SKILLHUB_GITHUB_SYNC_LEASE_DURATION:PT5M} repository-check-interval: ${SKILLHUB_GITHUB_SYNC_REPOSITORY_CHECK_INTERVAL:PT24H} + 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} + openai-compatible: + # Disabled by default: point it at an OpenAI-compatible endpoint to enable prompt tasks + enabled: ${SKILLHUB_AUTHORING_LLM_ENABLED:false} + api-key: ${SKILLHUB_AUTHORING_LLM_API_KEY:} + default-endpoint: ${SKILLHUB_AUTHORING_LLM_ENDPOINT:} + default-model: ${SKILLHUB_AUTHORING_LLM_MODEL:} + timeout-ms: ${SKILLHUB_AUTHORING_LLM_TIMEOUT_MS:120000} + # rounds of chat-completion (with MCP tool execution between rounds) per prompt task + max-tool-rounds: ${SKILLHUB_AUTHORING_LLM_MAX_TOOL_ROUNDS:4} + # SSRF guard: set true only for private-network LLM endpoints (e.g. internal vLLM); + # loopback/RFC1918 destinations are rejected by default, link-local always + allow-private-endpoints: ${SKILLHUB_AUTHORING_LLM_ALLOW_PRIVATE:false} + mcp: + # SSRF guard for http/sse MCP endpoints: private/loopback addresses are + # rejected by default; link-local (cloud metadata) is always rejected + allow-private-endpoints: ${SKILLHUB_AUTHORING_MCP_ALLOW_PRIVATE:false} + # stdio MCP servers spawn server-side processes — off unless explicitly enabled + stdio-enabled: ${SKILLHUB_AUTHORING_MCP_STDIO_ENABLED:false} + # server environment variables a binding may reference via envRefs + # (comma-separated, e.g. MCP_ECHO_TOKEN,WEATHER_API_KEY) + env-allowlist: ${SKILLHUB_AUTHORING_MCP_ENV_ALLOWLIST:} + docker: + # isolation profile for stdio MCP servers in docker execution mode + image: ${SKILLHUB_AUTHORING_MCP_DOCKER_IMAGE:alpine:3.20} + memory: ${SKILLHUB_AUTHORING_MCP_DOCKER_MEMORY:256m} + cpus: ${SKILLHUB_AUTHORING_MCP_DOCKER_CPUS:1.0} + pids-limit: ${SKILLHUB_AUTHORING_MCP_DOCKER_PIDS_LIMIT:128} + tmpfs-size: ${SKILLHUB_AUTHORING_MCP_DOCKER_TMPFS_SIZE:64m} suite: # Fail closed for deployment modes whose upgrade topology is unknown. Single-instance # distributions explicitly enable this after ruling out mixed application versions. diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 56bf53a85..78f02d6bb 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -392,3 +392,10 @@ 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, MCP and security policies + +# 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 diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 3b7efbb1b..5e9b8fa14 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -335,3 +335,10 @@ error.githubSync.deepDiscovery.exclusionInvalid=Исключения должн error.githubSync.deepDiscovery.configurationConflict=Конфигурация официального поиска изменена. Загрузите актуальную конфигурацию перед сохранением error.githubSync.deepDiscovery.queryInvalid=Недопустимые параметры запроса глубокого обнаружения. +# authoring runtime, MCP and security policies + +# authoring runtime bindings +error.authoring.binding.notFound=Для черновика {0} не настроена привязка среды выполнения +error.authoring.binding.agentType.unknown=Неизвестный тип среды выполнения агента: {0} +error.authoring.binding.invalid=Недопустимая привязка среды выполнения: {0} +error.authoring.binding.unserializable=Привязка среды выполнения не может быть сериализована diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 6b2787c2e..00b07d183 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -394,3 +394,10 @@ error.githubSync.deepDiscovery.exclusionInvalid=排除项须为有效的GitHub error.githubSync.deepDiscovery.configurationConflict=官方探索配置已被修改,请重新读取最新配置后再保存 error.githubSync.deepDiscovery.queryInvalid=深度探索查询参数无效。 +# authoring runtime, MCP and security policies + +# authoring runtime bindings +error.authoring.binding.notFound=草稿 {0} 尚未配置运行时绑定 +error.authoring.binding.agentType.unknown=未知的 Agent 运行时类型:{0} +error.authoring.binding.invalid=运行时绑定不合法:{0} +error.authoring.binding.unserializable=运行时绑定无法序列化 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java index 6c9cfc5c2..f39affb5b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringDraftFlowIntegrationTest.java @@ -213,9 +213,13 @@ void structureLayerFailsFixablyThenSucceedsAfterRepair() { assertThat(failed.getStatus()).isEqualTo(ValidationRunStatus.FAILED); List 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 runtime 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 @@ void structureLayerFailsFixablyThenSucceedsAfterRepair() { 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()); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringRuntimeFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringRuntimeFlowIntegrationTest.java new file mode 100644 index 000000000..30f47c46c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringRuntimeFlowIntegrationTest.java @@ -0,0 +1,225 @@ +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; + +/** + * Runtime-binding layer of the authoring platform: three-layer validation with + * a local-script binding (structure + config + behavior, script subprocess + * executed inline under the test profile) and the save-time security policy + * for bindings (cloud-metadata endpoints, envRefs allowlist, LLM surface). + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({TestRedisConfig.class, AuthoringRuntimeFlowIntegrationTest.InMemoryStorageConfig.class}) +@Testcontainers +class AuthoringRuntimeFlowIntegrationTest { + + @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; + + @TestConfiguration + static class InMemoryStorageConfig { + + @Bean + @Primary + ObjectStorageService inMemoryObjectStorage() { + return new AuthoringDraftFlowIntegrationTest.InMemoryObjectStorage(); + } + } + + @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(); + } + + @Test + void securityPolicyRejectsUnsafeBindingsAtSaveTime() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "security-flow", + "exercises the binding security policy", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd().getBytes(StandardCharsets.UTF_8), + "text/markdown", null, null); + + // cloud metadata endpoints are always blocked — even under the test + // profile where loopback/RFC1918 endpoints are deliberately allowed + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "metadata", "transport", "http", + "endpoint", "http://169.254.169.254/latest/meta-data/")), null), + "link-local"); + + // envRefs outside the configured allowlist are rejected: bindings must + // not be able to read arbitrary server environment variables + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-hog", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("HOME"))), null), + "env-allowlist"); + + // the LLM surface is guarded too — those requests carry the server API key + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "openai-compatible", + Map.of("endpoint", "http://169.254.169.254/v1", "model", "test-model"), null, null, null), + "link-local"); + + // the allowlisted env ref saves fine (loopback is allowed under the test profile) + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-ok", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("TEST_ALLOWED_MCP_VAR"))), null); + } + + 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; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java new file mode 100644 index 000000000..09dfd282c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/AuthoringStartupGuardTest.java @@ -0,0 +1,70 @@ +package com.iflytek.skillhub.config; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.env.MockEnvironment; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * The startup guard makes host-side script execution impossible to configure + * by accident in production: inline mode outside local/dev/test fails fast. + */ +class AuthoringStartupGuardTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + + private AuthoringStartupGuard guard(MockEnvironment environment) { + return new AuthoringStartupGuard(properties, environment); + } + + @Test + void inlineModeWithoutDevProfileFailsStartup() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + assertThatThrownBy(() -> guard(new MockEnvironment()).run(null)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("execution-mode=inline") + .hasMessageContaining("docker"); + } + + @Test + void inlineModeWithProdLikeProfileStillFails() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles("redis-sentinel"); + assertThatThrownBy(() -> guard(environment).run(null)) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void inlineModeIsAllowedInLocalDevAndTestProfiles() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + for (String profile : new String[]{"local", "dev", "test"}) { + MockEnvironment environment = new MockEnvironment(); + environment.setActiveProfiles(profile); + assertThatCode(() -> guard(environment).run(null)) + .as("profile " + profile) + .doesNotThrowAnyException(); + } + } + + @Test + void dockerModePassesEverywhere() { + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.DOCKER); + assertThatCode(() -> guard(new MockEnvironment()).run(null)) + .doesNotThrowAnyException(); + } + + @Test + void disabledLocalScriptPassesEverywhere() { + properties.getLocalScript().setEnabled(false); + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.INLINE); + assertThatCode(() -> guard(new MockEnvironment()).run(null)) + .doesNotThrowAnyException(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java new file mode 100644 index 000000000..089a792ce --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/config/ConfiguredAuthoringSecurityPolicyTest.java @@ -0,0 +1,151 @@ +package com.iflytek.skillhub.config; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Pins the SSRF classification of user-supplied endpoints: link-local (cloud + * metadata) is always blocked, private/loopback ranges follow the per-surface + * flags, and public addresses pass. + */ +class ConfiguredAuthoringSecurityPolicyTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + private final ConfiguredAuthoringSecurityPolicy policy = + new ConfiguredAuthoringSecurityPolicy(properties); + + @Test + void publicHttpAndHttpsEndpointsAreAllowed() { + // literal public IPs keep the test hermetic: hostname resolution varies + // with the local network (VPNs and DNS64 may answer with private ranges) + assertThat(policy.endpointRejection("https://1.1.1.1/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNull(); + assertThat(policy.endpointRejection("http://8.8.8.8/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + } + + @Test + void nonHttpSchemesAndHostlessUrlsAreRejected() { + assertThat(policy.endpointRejection("file:///etc/passwd", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("http(s)"); + assertThat(policy.endpointRejection("ftp://example.com/x", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("http(s)"); + assertThat(policy.endpointRejection("http://", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http:///no-host-here", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("no host"); + assertThat(policy.endpointRejection("not a url at all", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNotNull(); + } + + @Test + void cloudMetadataAndLinkLocalAreAlwaysBlockedEvenWhenPrivateIsAllowed() { + properties.getMcp().setAllowPrivateEndpoints(true); + properties.getOpenAiCompatible().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://169.254.169.254/latest/meta-data/", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("link-local"); + assertThat(policy.endpointRejection("http://169.254.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("link-local"); + assertThat(policy.endpointRejection("http://[fe80::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // the LLM surface carries the server API key — same absolute block + assertThat(policy.endpointRejection("http://169.254.169.254/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).contains("link-local"); + } + + @Test + void unspecifiedAndMulticastAreAlwaysBlocked() { + assertThat(policy.endpointRejection("http://0.0.0.0/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://224.0.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void loopbackIsBlockedByDefaultAndAllowedWithTheFlag() { + assertThat(policy.endpointRejection("http://127.0.0.1:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).contains("private/loopback"); + assertThat(policy.endpointRejection("http://localhost:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://[::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + + properties.getMcp().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://127.0.0.1:8080/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + // the LLM surface has its own flag and stays blocked + assertThat(policy.endpointRejection("http://127.0.0.1:8080/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNotNull(); + properties.getOpenAiCompatible().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://127.0.0.1:8080/v1", + AuthoringSecurityPolicy.EndpointUse.LLM_API)).isNull(); + } + + @Test + void privateV4V6AndCgnatRangesAreBlockedByDefault() { + assertThat(policy.endpointRejection("http://10.1.2.3/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://172.16.0.9/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://192.168.1.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 172.32.x.x is public (just outside the RFC1918 block) + assertThat(policy.endpointRejection("http://172.32.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + assertThat(policy.endpointRejection("http://100.64.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 100.128.x.x is public (just outside CGNAT) + assertThat(policy.endpointRejection("http://100.128.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + assertThat(policy.endpointRejection("http://[fd12::1]/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void unresolvableOrSyntheticHostsNeverPassSilently() throws Exception { + // Resolution is stubbed through the test seam: whether a bogus host + // fails to resolve (normal network) or a fake-IP VPN answers it with + // a synthetic range, the endpoint must not pass silently. + ConfiguredAuthoringSecurityPolicy unresolved = new ConfiguredAuthoringSecurityPolicy( + properties, host -> { throw new java.net.UnknownHostException("nx"); }); + assertThat(unresolved.endpointRejection("http://this-host-does-not-exist.invalid/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)) + .contains("cannot resolve host"); + + // fake-IP VPN resolvers answer everything inside 198.18.0.0/15 + java.net.InetAddress fakeVpnAnswer = java.net.InetAddress.getByAddress( + new byte[] {(byte) 198, 18, 1, (byte) 200}); + ConfiguredAuthoringSecurityPolicy fakeDns = new ConfiguredAuthoringSecurityPolicy( + properties, host -> new java.net.InetAddress[] {fakeVpnAnswer}); + assertThat(fakeDns.endpointRejection("http://anything.example/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + } + + @Test + void rfc2544BenchmarkRangeFollowsThePrivateFlag() { + assertThat(policy.endpointRejection("http://198.18.1.200/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + assertThat(policy.endpointRejection("http://198.19.255.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNotNull(); + // 198.20.x.x is public (just outside the /15) + assertThat(policy.endpointRejection("http://198.20.0.1/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + properties.getMcp().setAllowPrivateEndpoints(true); + assertThat(policy.endpointRejection("http://198.18.1.200/mcp", + AuthoringSecurityPolicy.EndpointUse.MCP_SERVER)).isNull(); + } + + @Test + void stdioAndEnvRefsFollowConfiguration() { + assertThat(policy.stdioTransportAllowed()).isFalse(); + properties.getMcp().setStdioEnabled(true); + assertThat(policy.stdioTransportAllowed()).isTrue(); + + assertThat(policy.envRefAllowed("ANY_VAR")).isFalse(); + properties.getMcp().setEnvAllowlist(java.util.List.of("WEATHER_API_KEY")); + assertThat(policy.envRefAllowed("WEATHER_API_KEY")).isTrue(); + assertThat(policy.envRefAllowed("PATH")).isFalse(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java new file mode 100644 index 000000000..506680c45 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/TestingAuthoringSecurityPolicy.java @@ -0,0 +1,53 @@ +package com.iflytek.skillhub.service.authoring; + +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; + +/** + * Ready-made policies for unit tests that build components by hand instead of + * through the Spring context. + */ +public final class TestingAuthoringSecurityPolicy { + + private TestingAuthoringSecurityPolicy() { + } + + /** Allows every endpoint, stdio, and env ref — for tests focused elsewhere. */ + public static AuthoringSecurityPolicy permissive() { + return new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + } + + /** Production posture: rejects every endpoint, stdio, and env ref. */ + public static AuthoringSecurityPolicy denyAll() { + return new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return "blocked by test policy"; + } + + @Override + public boolean stdioTransportAllowed() { + return false; + } + + @Override + public boolean envRefAllowed(String name) { + return false; + } + }; + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java new file mode 100644 index 000000000..d99ed4c1c --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilderTest.java @@ -0,0 +1,85 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.iflytek.skillhub.config.AuthoringProperties; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Verifies the docker command line: every isolation flag must be present, the + * workspace mounted exactly once, and the script referenced by its + * container-relative path. + */ +class DockerScriptCommandBuilderTest { + + private final AuthoringProperties properties = new AuthoringProperties(); + private final DockerScriptCommandBuilder builder = new DockerScriptCommandBuilder(properties); + + private final Path workspace = Path.of("/tmp/skillhub-authoring/run-42"); + private final Path script = workspace.resolve("scripts/greet.sh"); + + private ScriptCommandBuilder.ScriptExecutionPlan plan() { + return builder.plan(workspace, "sh", script, List.of("arg1", "arg two"), + Map.of("PATH", "/usr/bin:/bin", "HOME", workspace.toString(), "LANG", "C.UTF-8"), + "42-greet"); + } + + @Test + void commandEnforcesIsolationProfile() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("docker", "run", "--rm", "--name", "skillhub-validation-42-greet")); + assertThat(command).containsSubsequence(List.of("--network", "none")); + assertThat(command).containsSubsequence(List.of("--memory", "256m")); + assertThat(command).containsSubsequence(List.of("--cpus", "1.0")); + assertThat(command).containsSubsequence(List.of("--pids-limit", "128")); + assertThat(command).contains("--read-only"); + assertThat(command).containsSubsequence(List.of("--tmpfs", "/tmp:rw,size=64m")); + assertThat(command).containsSubsequence(List.of("--cap-drop", "ALL")); + assertThat(command).containsSubsequence(List.of("--security-opt", "no-new-privileges")); + assertThat(command).containsSubsequence(List.of("--log-driver", "none")); + } + + @Test + void workspaceIsMountedAndScriptUsesContainerPath() { + List command = plan().command(); + assertThat(command).containsSubsequence( + List.of("-v", workspace + ":" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).containsSubsequence(List.of("-w", DockerScriptCommandBuilder.CONTAINER_WORKSPACE)); + assertThat(command).contains("/workspace/scripts/greet.sh"); + assertThat(command).endsWith("alpine:3.20", "sh", "/workspace/scripts/greet.sh", "arg1", "arg two"); + } + + @Test + void environmentIsPassedViaFlagsWithHomeRemapped() { + List command = plan().command(); + assertThat(command).contains("PATH=/usr/bin:/bin"); + assertThat(command).contains("HOME=" + DockerScriptCommandBuilder.CONTAINER_WORKSPACE); + assertThat(command).contains("LANG=C.UTF-8"); + // the docker CLI keeps its own environment to reach the daemon + assertThat(plan().environment()).isNull(); + } + + @Test + void containerNamesAreDockerSafe() { + assertThat(DockerScriptCommandBuilder.containerName("42-greet")).isEqualTo("skillhub-validation-42-greet"); + assertThat(DockerScriptCommandBuilder.containerName("7-task/with spaces")) + .isEqualTo("skillhub-validation-7-task-with-spaces"); + assertThat(DockerScriptCommandBuilder.containerName("task")) + .isEqualTo("skillhub-validation-task"); + } + + @Test + void dockerLimitsComeFromConfiguration() { + properties.getLocalScript().getDocker().setImage("python:3.12-alpine"); + properties.getLocalScript().getDocker().setMemory("512m"); + properties.getLocalScript().getDocker().setCpus("2.0"); + List command = plan().command(); + assertThat(command).containsSubsequence(List.of("--memory", "512m")); + assertThat(command).containsSubsequence(List.of("--cpus", "2.0")); + assertThat(command).contains("python:3.12-alpine"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java new file mode 100644 index 000000000..11d97c94e --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptRuntimeAdapterTest.java @@ -0,0 +1,140 @@ +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.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Executes real behavior tasks through the docker backend and proves the + * isolation profile inside the container: no network interfaces beyond loopback, + * read-only root filesystem, and the workspace mounted writable. Skipped + * silently when no docker daemon is reachable, or when the daemon resolves + * bind-mount sources against a filesystem where the workspace does not exist + * (CI runners whose job container shares the host daemon behave this way). + */ +class DockerScriptRuntimeAdapterTest { + + @TempDir + Path workspace; + + private final AuthoringProperties properties = new AuthoringProperties(); + + @BeforeAll + static void requireDockerWithImage() throws Exception { + DockerScriptCommandBuilder probe = new DockerScriptCommandBuilder(new AuthoringProperties()); + Assumptions.assumeTrue(probe.available(), "docker daemon not reachable"); + try { + Process pull = new ProcessBuilder("docker", "image", "inspect", "alpine:3.20").start(); + if (pull.waitFor() != 0) { + new ProcessBuilder("docker", "pull", "alpine:3.20").start().waitFor(); + } + } catch (Exception exception) { + Assumptions.assumeTrue(false, "alpine image unavailable: " + exception.getMessage()); + } + // Round-trip a bind mount: on shared-daemon CI runners the -v source is + // resolved against the host filesystem and comes up empty, which is an + // environment limitation, not something these tests should fail on. + Path probeDir = Files.createTempDirectory("docker-mount-probe"); + Path probeFile = probeDir.resolve("probe.txt"); + Files.writeString(probeFile, "bind-mount-OK"); + try { + Process roundTrip = new ProcessBuilder("docker", "run", "--rm", + "-v", probeDir + ":/probe", "alpine:3.20", "cat", "/probe/probe.txt") + .redirectErrorStream(true).start(); + String output = new String(roundTrip.getInputStream().readAllBytes()); + roundTrip.waitFor(); + Assumptions.assumeTrue(output.contains("bind-mount-OK"), + "docker bind-mounts do not round-trip on this host (shared-daemon runner?)"); + } finally { + Files.deleteIfExists(probeFile); + Files.deleteIfExists(probeDir); + } + } + + private LocalScriptRuntimeAdapter dockerAdapter() { + properties.getLocalScript().setExecutionMode(AuthoringProperties.ScriptExecutionMode.DOCKER); + return new LocalScriptRuntimeAdapter(properties, List.of( + new InlineScriptCommandBuilder(), new DockerScriptCommandBuilder(properties))); + } + + private record RecordingSink(List logLines) implements RuntimeEventSink { + RecordingSink() { + this(new ArrayList<>()); + } + + @Override + public void agentMessage(String taskName, String content) { + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + } + + @Override + public void log(String taskName, String stream, String line) { + logLines.add(line); + } + } + + private TaskResult runScript(String script) throws Exception { + Files.createDirectories(workspace.resolve("scripts")); + Files.writeString(workspace.resolve("scripts/task.sh"), script); + ValidationTaskSpec task = new ValidationTaskSpec("probe", "isolation probe", + com.iflytek.skillhub.domain.authoring.spec.TaskType.SCRIPT, + "scripts/task.sh", List.of(), null, 60_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext( + 99L, workspace, Map.of("interpreter", "sh"), List.of(), () -> false); + return dockerAdapter().execute(context, task, new RecordingSink()); + } + + @Test + void runsScriptAndCapturesOutput() throws Exception { + TaskResult result = runScript("echo hello from docker\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("hello from docker"); + } + + @Test + void containerHasNoNetworkRoutes() throws Exception { + // with --network none the routing table is empty (header line only), + // so nothing beyond loopback is reachable + TaskResult result = runScript("tail -n +2 /proc/net/route | wc -l | tr -d ' '\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout().trim()).isEqualTo("0"); + } + + @Test + void rootFilesystemIsReadOnly() throws Exception { + TaskResult result = runScript( + "if touch /etc/forbidden 2>/dev/null; then echo writable; else echo readonly; fi\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("readonly").doesNotContain("writable"); + } + + @Test + void workspaceIsWritableForArtifacts() throws Exception { + TaskResult result = runScript("mkdir -p artifacts && echo data > artifacts/out.txt && cat artifacts/out.txt\n"); + assertThat(result.exitCode()).isZero(); + assertThat(result.stdout()).contains("data"); + assertThat(result.artifacts().read("artifacts/out.txt")).isPresent(); + assertThat(new String(result.artifacts().read("artifacts/out.txt").orElseThrow())) + .contains("data"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java new file mode 100644 index 000000000..f5d73f0e9 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/adapter/OpenAiCompatibleRuntimeAdapterToolLoopTest.java @@ -0,0 +1,281 @@ +package com.iflytek.skillhub.service.authoring.adapter; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +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.TaskResult; +import com.iflytek.skillhub.domain.authoring.spec.TaskType; +import com.iflytek.skillhub.domain.authoring.spec.ValidationTaskSpec; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import com.iflytek.skillhub.service.authoring.mcp.McpClientFactory; +import com.iflytek.skillhub.service.authoring.mcp.TestingMcpHttpServer; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Drives the full prompt-task agent loop against a fake OpenAI-compatible + * endpoint and a fake MCP server: tools are discovered over MCP, exposed to the + * model, executed for real when called, and every step lands in the trace sink. + * Also pins the toolFilters / toolAllowlist restrictions on what gets exposed. + */ +class OpenAiCompatibleRuntimeAdapterToolLoopTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @TempDir + Path workspace; + + private TestingMcpHttpServer mcpServer; + private FakeOpenAiEndpoint openAi; + private final List trace = new ArrayList<>(); + private OpenAiCompatibleRuntimeAdapter adapter; + private AuthoringProperties properties; + + private record TraceEvent(String kind, String tool, String payload) { + } + + /** Records toolCall/toolResult/agentMessage/log in order. */ + private final RuntimeEventSink sink = new RuntimeEventSink() { + @Override + public void agentMessage(String taskName, String content) { + trace.add(new TraceEvent("agentMessage", null, content)); + } + + @Override + public void toolCall(String taskName, String tool, String argumentsJson) { + trace.add(new TraceEvent("toolCall", tool, argumentsJson)); + } + + @Override + public void toolResult(String taskName, String tool, String summaryJson) { + trace.add(new TraceEvent("toolResult", tool, summaryJson)); + } + + @Override + public void log(String taskName, String stream, String line) { + trace.add(new TraceEvent("log", stream, line)); + } + }; + + @BeforeEach + void start() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + openAi = new FakeOpenAiEndpoint(); + properties = new AuthoringProperties(); + properties.getOpenAiCompatible().setEnabled(true); + adapter = new OpenAiCompatibleRuntimeAdapter(properties, MAPPER, + new McpClientFactory(MAPPER, properties, TestingAuthoringSecurityPolicy.permissive()), + TestingAuthoringSecurityPolicy.permissive()); + Files.writeString(workspace.resolve("SKILL.md"), "---\nname: test\n---\nUse the weather tools.\n"); + trace.clear(); + } + + @AfterEach + void stop() { + if (mcpServer != null) { + mcpServer.close(); + } + if (openAi != null) { + openAi.close(); + } + } + + private Map weatherServer() { + return Map.of("name", "weather", "transport", "http", "endpoint", mcpServer.endpoint()); + } + + private TaskResult run(List> mcpServers, List allowlist) throws Exception { + ValidationTaskSpec task = new ValidationTaskSpec("ask", "asks the weather", + TaskType.PROMPT, null, List.of(), "What is the weather in Paris?", 30_000, List.of()); + RuntimeExecutionContext context = new RuntimeExecutionContext(99L, workspace, + Map.of("endpoint", openAi.baseUrl() + "/", "model", "test-model"), + allowlist, mcpServers, () -> false); + return adapter.execute(context, task, sink); + } + + private static ObjectNode assistantMessage(String content) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", content); + return message; + } + + private static ObjectNode toolCallMessage(String functionName, String arguments) { + ObjectNode message = MAPPER.createObjectNode(); + message.put("role", "assistant"); + message.put("content", ""); + ObjectNode call = message.putArray("tool_calls").addObject(); + call.put("id", "call-1"); + call.put("type", "function"); + call.putObject("function").put("name", functionName).put("arguments", arguments); + return message; + } + + @Test + void executesMcpToolAndTracesEveryStep() throws Exception { + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + assistantMessage("The forecast for Paris is sunny."))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.response()).isEqualTo("The forecast for Paris is sunny."); + assertThat(result.toolCallCount()).isEqualTo(1); + + // the real MCP server saw the call with the model's arguments + assertThat(mcpServer.toolCalls).hasSize(1); + assertThat(mcpServer.toolCalls.get(0).getKey()).isEqualTo("get_forecast"); + assertThat(mcpServer.toolCalls.get(0).getValue().path("city").asText()).isEqualTo("Paris"); + + // two chat rounds; the second carries the tool reply back to the model + assertThat(openAi.requests).hasSize(2); + assertThat(exposedFunctionNames(0)).containsExactlyInAnyOrder( + "weather__get_forecast", "weather__lookup_city"); + JsonNode secondMessages = openAi.requests.get(1).path("messages"); + assertThat(secondMessages).anySatisfy(message -> { + assertThat(message.path("role").asText()).isEqualTo("tool"); + assertThat(message.path("tool_call_id").asText()).isEqualTo("call-1"); + assertThat(message.path("content").asText()).contains("result of get_forecast"); + }); + + // the trace shows both chat rounds, the tool invocation, and the result + assertThat(trace).extracting(TraceEvent::kind) + .containsExactly("toolCall", "toolCall", "toolResult", "toolCall", "agentMessage"); + assertThat(trace.get(1).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(1).payload()).contains("Paris"); + assertThat(trace.get(2).tool()).isEqualTo("mcp:weather/get_forecast"); + assertThat(trace.get(2).payload()).contains("\"ok\":true"); + } + + @Test + void toolAllowlistRestrictsExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("I only have the lookup tool."))); + + run(List.of(weatherServer()), List.of("lookup_city")); + + assertThat(openAi.requests).hasSize(1); + assertThat(exposedFunctionNames(0)).containsExactly("weather__lookup_city"); + } + + @Test + void serverToolFiltersRestrictExposedTools() throws Exception { + openAi.script(List.of(assistantMessage("done"))); + + Map filtered = new java.util.HashMap<>(weatherServer()); + filtered.put("toolFilters", List.of("get_forecast")); + run(List.of(filtered), List.of()); + + assertThat(exposedFunctionNames(0)).containsExactly("weather__get_forecast"); + } + + @Test + void withoutMcpServersNoToolsAreSentAndASingleRoundRuns() throws Exception { + openAi.script(List.of(assistantMessage("plain answer"))); + + TaskResult result = run(List.of(), List.of()); + + assertThat(result.response()).isEqualTo("plain answer"); + assertThat(result.toolCallCount()).isZero(); + assertThat(openAi.requests).hasSize(1); + assertThat(openAi.requests.get(0).has("tools")).isFalse(); + } + + @Test + void roundLimitCapsTheToolLoop() throws Exception { + properties.getOpenAiCompatible().setMaxToolRounds(2); + openAi.script(List.of( + toolCallMessage("weather__get_forecast", "{\"city\":\"Paris\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Rome\"}"), + toolCallMessage("weather__get_forecast", "{\"city\":\"Oslo\"}"))); + + TaskResult result = run(List.of(weatherServer()), List.of()); + + assertThat(result.toolCallCount()).isEqualTo(2); + assertThat(openAi.requests).hasSize(2); + assertThat(trace).anySatisfy(event -> { + assertThat(event.kind()).isEqualTo("log"); + assertThat(event.payload()).contains("round limit"); + }); + } + + private List exposedFunctionNames(int requestIndex) { + List names = new ArrayList<>(); + for (JsonNode tool : openAi.requests.get(requestIndex).path("tools")) { + names.add(tool.path("function").path("name").asText()); + } + return names; + } + + // ---------------------------------------------------------------- fake endpoint + + /** Serves scripted chat-completion responses and records every request body. */ + private static final class FakeOpenAiEndpoint implements AutoCloseable { + + private final HttpServer server; + private final List requests = new CopyOnWriteArrayList<>(); + private final List scriptedResponses = new CopyOnWriteArrayList<>(); + + FakeOpenAiEndpoint() throws IOException { + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + String baseUrl() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/v1"; + } + + void script(List assistantMessages) { + scriptedResponses.addAll(assistantMessages); + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + requests.add((ObjectNode) MAPPER.readTree(body)); + ObjectNode message = scriptedResponses.isEmpty() + ? assistantMessage("no script left") : scriptedResponses.remove(0); + ObjectNode choice = MAPPER.createObjectNode(); + choice.put("index", 0); + choice.set("message", message); + choice.put("finish_reason", "stop"); + ObjectNode response = MAPPER.createObjectNode(); + response.put("id", "chatcmpl-test"); + response.putArray("choices").add(choice); + byte[] bytes = response.toString().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } finally { + exchange.close(); + } + } + + @Override + public void close() { + server.stop(0); + } + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java new file mode 100644 index 000000000..404dab1b2 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/HttpMcpClientTest.java @@ -0,0 +1,172 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpServer; +import java.io.ByteArrayInputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Exercises HttpMcpClient against a real local HTTP server implementing the MCP + * streamable-HTTP protocol, including the session-id contract and SSE-framed + * responses. + */ +class HttpMcpClientTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private HttpMcpClient client() { + return new HttpMcpClient("weather", mcpServer.endpoint(), httpClient, objectMapper, Map.of()); + } + + @Test + void initializesListsToolsAndCallsTools() throws Exception { + try (HttpMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name) + .containsExactly("get_forecast", "lookup_city"); + assertThat(tools.get(0).description()).isEqualTo("test tool get_forecast"); + + String output = client.callTool("get_forecast", + Map.of("city", "Paris"), Duration.ofSeconds(5)); + assertThat(output).isEqualTo("result of get_forecast: {\"city\":\"Paris\"}"); + assertThat(mcpServer.toolCalls).hasSize(1); + } + assertThat(mcpServer.initializeCount.get()).isEqualTo(1); + } + + @Test + void sessionHeaderIsReusedAfterInitialize() throws Exception { + // the fake server answers tools/list with 400 unless the Mcp-Session-Id + // captured during initialize is replayed — so a successful listing proves it + try (HttpMcpClient client = client()) { + assertThat(client.listTools(Duration.ofSeconds(5))).hasSize(2); + } + assertThat(mcpServer.sawRequestWithoutSessionId) + .as("tools/list must carry the session id from initialize") + .isFalse(); + } + + @Test + void parsesSseFramedResponses() throws Exception { + try (TestingMcpHttpServer sseServer = + new TestingMcpHttpServer(List.of("echo"), true); + HttpMcpClient client = new HttpMcpClient("sse", sseServer.endpoint(), + httpClient, objectMapper, Map.of())) { + List tools = client.listTools(Duration.ofSeconds(5)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + } + } + + @Test + void toolErrorResultBecomesException() throws Exception { + try (HttpMcpClient client = client()) { + client.listTools(Duration.ofSeconds(5)); + assertThatThrownBy(() -> client.callTool("boom", Map.of(), Duration.ofSeconds(5))) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("boom"); + } + } + + @Test + void unreachableServerFailsFastWithClearMessage() { + try (HttpMcpClient client = new HttpMcpClient("dead", + "http://127.0.0.1:9/mcp", httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(5))) + .isInstanceOf(Exception.class) + .hasMessageContaining("dead"); + } + } + + @Test + void redirectsAreNeverFollowed() throws Exception { + // a public URL answering 302 towards a link-local target is the classic + // SSRF bypass: the client must surface the redirect as an error, not follow it + AtomicInteger requests = new AtomicInteger(); + HttpServer redirector = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + redirector.createContext("/", exchange -> { + requests.incrementAndGet(); + exchange.getResponseHeaders().set("Location", "http://169.254.169.254/mcp"); + exchange.sendResponseHeaders(302, -1); + exchange.close(); + }); + redirector.start(); + try (HttpMcpClient client = new HttpMcpClient("redirector", + "http://127.0.0.1:" + redirector.getAddress().getPort() + "/mcp", + httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(5))) + .isInstanceOf(Exception.class) + .hasMessageContaining("302"); + } finally { + redirector.stop(0); + } + assertThat(requests.get()) + .as("the redirect target must never be requested") + .isEqualTo(1); + } + + @Test + void oversizedResponseBodyIsRejected() throws Exception { + // 3 MiB body against a 2 MiB cap: a hostile endpoint must not be able to + // buffer an unbounded response in server memory + HttpServer flood = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + flood.createContext("/", exchange -> { + byte[] bytes = new byte[3 * 1024 * 1024]; + java.util.Arrays.fill(bytes, (byte) 'x'); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + exchange.close(); + }); + flood.start(); + try (HttpMcpClient client = new HttpMcpClient("flood", + "http://127.0.0.1:" + flood.getAddress().getPort() + "/mcp", + httpClient, objectMapper, Map.of())) { + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(10))) + .isInstanceOf(Exception.class) + .hasMessageContaining("exceeds"); + } finally { + flood.stop(0); + } + } + + @Test + void readBodyCappedAllowsBodiesUnderTheLimit() throws Exception { + byte[] small = "hello".getBytes(StandardCharsets.UTF_8); + assertThat(HttpMcpClient.readBodyCapped(new ByteArrayInputStream(small), 10)) + .isEqualTo("hello"); + assertThatThrownBy(() -> HttpMcpClient.readBodyCapped( + new ByteArrayInputStream(small), 3)) + .isInstanceOf(java.io.IOException.class) + .hasMessageContaining("exceeds"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java new file mode 100644 index 000000000..bcd4fb3df --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpClientFactoryTest.java @@ -0,0 +1,148 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.service.AuthoringSecurityPolicy; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import java.io.IOException; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Connect-time enforcement in McpClientFactory: blocked endpoints and the + * disabled stdio transport fail before any connection or process is created, + * envRefs are filtered through the policy, and docker execution mode wraps + * stdio commands in the hardened container profile. + */ +class McpClientFactoryTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final AuthoringProperties properties = new AuthoringProperties(); + + private McpClientFactory factory(AuthoringSecurityPolicy policy) { + return new McpClientFactory(MAPPER, properties, policy); + } + + /** Allows everything except env refs (for the envRefs filter test). */ + private final AuthoringSecurityPolicy pathOnlyEnvRefs = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return "PATH".equals(name); + } + }; + + @Test + void httpEndpointRejectedByPolicyNeverConnects() { + AuthoringSecurityPolicy blocking = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return "blocked by test policy"; + } + + @Override + public boolean stdioTransportAllowed() { + return true; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + assertThatThrownBy(() -> factory(blocking).create(Map.of( + "name", "evil", "transport", "http", "endpoint", "http://169.254.169.254/mcp"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("endpoint rejected"); + } + + @Test + void stdioIsRefusedWhenDisabled() { + AuthoringSecurityPolicy stdioDisabled = new AuthoringSecurityPolicy() { + @Override + public String endpointRejection(String url, EndpointUse use) { + return null; + } + + @Override + public boolean stdioTransportAllowed() { + return false; + } + + @Override + public boolean envRefAllowed(String name) { + return true; + } + }; + assertThatThrownBy(() -> factory(stdioDisabled).create( + Map.of("name", "local", "transport", "stdio", "command", "sleep 30"))) + .isInstanceOf(IOException.class) + .hasMessageContaining("stdio transport is disabled"); + } + + @Test + void envRefsNotInAllowlistNeverReachTheSpawnedProcess() { + // HOME exists in every environment, so dropping it is observable: only + // the allowlisted PATH survives the filter + Map server = Map.of( + "name", "envtest", "transport", "stdio", "command", "sleep 30", + "envRefs", List.of("PATH", "HOME")); + Map environment = factory(pathOnlyEnvRefs).environmentOf(server); + assertThat(environment).containsKey("PATH"); + assertThat(environment).doesNotContainKey("HOME"); + } + + @Test + void dockerModeWrapsStdioCommandInHardenedContainer() { + properties.getMcp().setStdioEnabled(true); + properties.getLocalScript().setExecutionMode( + AuthoringProperties.ScriptExecutionMode.DOCKER); + properties.getMcp().getDocker().setImage("alpine:3.20"); + + List wrapped = factory(TestingAuthoringSecurityPolicy.permissive()) + .dockerWrappedCommand("skillhub-mcp-test-abcd1234", + List.of("python3", "-m", "fake_server"), + Map.of("WEATHER_API_KEY", "secret-value")); + + assertThat(wrapped.get(0)).isEqualTo("docker"); + assertThat(wrapped).containsSubsequence("run", "--rm", "--name", + "skillhub-mcp-test-abcd1234"); + assertThat(wrapped).containsSubsequence("--network", "none"); + assertThat(wrapped).containsSubsequence("--memory", "256m"); + assertThat(wrapped).containsSubsequence("--cpus", "1.0"); + assertThat(wrapped).containsSubsequence("--pids-limit", "128"); + assertThat(wrapped).contains("--read-only"); + assertThat(wrapped).containsSubsequence("--cap-drop", "ALL"); + assertThat(wrapped).contains("no-new-privileges"); + // environment travels as -e flags, not through the parent environment + assertThat(wrapped).containsSubsequence("-e", "WEATHER_API_KEY=secret-value"); + // the image runs the declared command + int image = wrapped.indexOf("alpine:3.20"); + assertThat(image).isPositive(); + assertThat(wrapped.subList(image + 1, wrapped.size())) + .containsExactly("python3", "-m", "fake_server"); + } + + @Test + void containerNamesAreSanitizedAndUnique() { + String first = McpClientFactory.containerName("my server!"); + String second = McpClientFactory.containerName("my server!"); + assertThat(first).startsWith("skillhub-mcp-my-server-"); + assertThat(first).isNotEqualTo(second); + assertThat(McpClientFactory.containerName(null)).startsWith("skillhub-mcp-unknown-"); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java new file mode 100644 index 000000000..85e825e55 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/McpProbeServiceTest.java @@ -0,0 +1,98 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iflytek.skillhub.config.AuthoringProperties; +import com.iflytek.skillhub.domain.authoring.validation.FindingDraft; +import com.iflytek.skillhub.domain.authoring.validation.FindingSeverity; +import com.iflytek.skillhub.service.authoring.TestingAuthoringSecurityPolicy; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * McpProbeService turns MCP declarations into verified facts: reachable servers + * with their discovered tools, and findings for unreachable servers or filters + * naming tools the server does not expose. + */ +class McpProbeServiceTest { + + private final McpProbeService probeService = new McpProbeService(new McpClientFactory( + new ObjectMapper(), new AuthoringProperties(), + TestingAuthoringSecurityPolicy.permissive())); + + private TestingMcpHttpServer mcpServer; + + @BeforeEach + void startServer() throws Exception { + mcpServer = new TestingMcpHttpServer(List.of("get_forecast", "lookup_city")); + } + + @AfterEach + void stopServer() { + if (mcpServer != null) { + mcpServer.close(); + } + } + + private Map server(Map overrides) { + Map server = new java.util.HashMap<>(); + server.put("name", "weather"); + server.put("transport", "http"); + server.put("endpoint", mcpServer.endpoint()); + server.putAll(overrides); + return server; + } + + @Test + void reachableServerReportsDiscoveredToolsWithoutFindings() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of())), Duration.ofSeconds(5)); + assertThat(report.servers()).hasSize(1); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.servers().get(0).tools()) + .containsExactly("get_forecast", "lookup_city"); + assertThat(report.findings()).isEmpty(); + } + + @Test + void unreachableServerProducesConnectFailedError() { + Map dead = Map.of( + "name", "dead", "transport", "http", "endpoint", "http://127.0.0.1:9/mcp"); + McpProbeService.ProbeReport report = probeService.probe(List.of(dead), Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isFalse(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_CONNECT_FAILED"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.ERROR); + assertThat(finding.message()).contains("dead"); + } + + @Test + void unknownToolFilterProducesWarningButStaysConnected() { + McpProbeService.ProbeReport report = probeService.probe( + List.of(server(Map.of("toolFilters", List.of("get_forecast", "does_not_exist")))), + Duration.ofSeconds(5)); + assertThat(report.servers().get(0).connected()).isTrue(); + assertThat(report.findings()).hasSize(1); + FindingDraft finding = report.findings().get(0); + assertThat(finding.ruleCode()).isEqualTo("MCP_TOOL_FILTER_UNKNOWN"); + assertThat(finding.severity()).isEqualTo(FindingSeverity.WARNING); + assertThat(finding.message()).contains("does_not_exist").contains("get_forecast"); + } + + @Test + void malformedDeclarationsAreSkippedNotProbed() { + // name/transport problems are already reported by RuntimeBindingValidator; + // the probe must not duplicate them or crash + McpProbeService.ProbeReport report = probeService.probe( + List.of(Map.of("name", "weird", "transport", "carrier-pigeon")), + Duration.ofSeconds(5)); + assertThat(report.servers()).isEmpty(); + assertThat(report.findings()).isEmpty(); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java new file mode 100644 index 000000000..f918ceca1 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/StdioMcpClientTest.java @@ -0,0 +1,133 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Runs StdioMcpClient against a python3 process implementing the stdio transport + * (newline-delimited JSON-RPC on stdin/stdout). Skipped when python3 is absent. + */ +class StdioMcpClientTest { + + private static final String FAKE_SERVER = """ + import json, sys + def send(obj): + sys.stdout.write(json.dumps(obj) + "\\n") + sys.stdout.flush() + for line in sys.stdin: + line = line.strip() + if not line: + continue + msg = json.loads(line) + if 'id' not in msg: + continue + method = msg.get('method') + if method == 'initialize': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': { + 'protocolVersion': '2024-11-05', 'capabilities': {}, + 'serverInfo': {'name': 'fake', 'version': '1.0'}}}) + elif method == 'tools/list': + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'tools': [ + {'name': 'echo', 'description': 'echo text', + 'inputSchema': {'type': 'object'}}]}}) + elif method == 'tools/call': + args = msg['params']['arguments'] + send({'jsonrpc': '2.0', 'id': msg['id'], 'result': {'content': [ + {'type': 'text', 'text': 'echo:' + args.get('text', '')}]}}) + """; + + @BeforeAll + static void requirePython3() { + try { + int exit = new ProcessBuilder("python3", "--version").start().waitFor(); + Assumptions.assumeTrue(exit == 0, "python3 unavailable"); + } catch (Exception exception) { + Assumptions.assumeTrue(false, "python3 unavailable: " + exception.getMessage()); + } + } + + private StdioMcpClient client() throws Exception { + return new StdioMcpClient("echo-server", List.of("python3", "-c", FAKE_SERVER), + Map.of(), new ObjectMapper()); + } + + @Test + void listsToolsAndCallsThemOverStdio() throws Exception { + try (StdioMcpClient client = client()) { + List tools = client.listTools(Duration.ofSeconds(10)); + assertThat(tools).extracting(McpTool::name).containsExactly("echo"); + + String output = client.callTool("echo", Map.of("text", "hello"), Duration.ofSeconds(10)); + assertThat(output).isEqualTo("echo:hello"); + } + } + + @Test + void processIsTerminatedOnClose() throws Exception { + StdioMcpClient client = client(); + client.listTools(Duration.ofSeconds(10)); + client.close(); + // a second close must not hang or throw; the process is already gone + client.close(); + } + + @Test + void failingCommandSurfacesAsException() { + assertThatThrownBy(() -> new StdioMcpClient("broken", + List.of("definitely-not-a-real-command-xyz"), Map.of(), new ObjectMapper()) + .listTools(Duration.ofSeconds(10))) + .isInstanceOf(Exception.class); + } + + @Test + void unterminatedHugeLineIsRejectedAndTheProcessKilled() { + // a hostile stdio server streaming one endless line must not exhaust + // memory: the capped reader destroys the process past the limit + String floodServer = """ + import sys, time + sys.stdout.write('x' * (5 * 1024 * 1024)) + sys.stdout.flush() + time.sleep(60) + """; + StdioMcpClient client; + try { + client = new StdioMcpClient("flood", List.of("python3", "-c", floodServer), + Map.of(), new ObjectMapper()); + } catch (Exception exception) { + // python3 disappeared between the class check and here + org.junit.jupiter.api.Assumptions.assumeTrue(false, exception.getMessage()); + return; + } + assertThatThrownBy(() -> client.listTools(Duration.ofSeconds(30))) + .isInstanceOf(Exception.class) + .hasMessageContaining("longer than"); + client.close(); + } + + @Test + void teardownCommandRunsOnClose() throws Exception { + // docker-wrapped stdio servers rely on this: a killed docker client can + // leave its container behind, so close() must run the cleanup command + Path marker = Files.createTempFile("skillhub-stdio-teardown", ".marker"); + Files.deleteIfExists(marker); + StdioMcpClient client = new StdioMcpClient("cleaned", + List.of("python3", "-c", FAKE_SERVER), Map.of(), new ObjectMapper(), + List.of("touch", marker.toString()), false); + client.listTools(Duration.ofSeconds(10)); + client.close(); + assertThat(Files.exists(marker)) + .as("the teardown command must have run after process destruction") + .isTrue(); + Files.deleteIfExists(marker); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java new file mode 100644 index 000000000..c2f9678aa --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/mcp/TestingMcpHttpServer.java @@ -0,0 +1,156 @@ +package com.iflytek.skillhub.service.authoring.mcp; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpServer; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Minimal MCP streamable-HTTP server for tests. Implements the initialize / + * notifications/initialized / tools/list / tools/call exchange the real client + * performs, can answer in plain JSON or SSE framing, enforces the session id on + * post-initialize requests, and records every tools/call it received. The tool + * named "boom" always answers with an error result. + */ +public class TestingMcpHttpServer implements AutoCloseable { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + private final HttpServer server; + private final List toolNames; + private final boolean sseMode; + + /** Every tools/call: tool name -> argument map, in arrival order. */ + public final List> toolCalls = new CopyOnWriteArrayList<>(); + /** Whether a post-initialize request arrived without the session id header. */ + public volatile boolean sawRequestWithoutSessionId; + /** How many initialize requests were handled. */ + public final AtomicInteger initializeCount = new AtomicInteger(); + + public TestingMcpHttpServer(List toolNames) throws IOException { + this(toolNames, false); + } + + public TestingMcpHttpServer(List toolNames, boolean sseMode) throws IOException { + this.toolNames = List.copyOf(toolNames); + this.sseMode = sseMode; + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/", this::handle); + server.start(); + } + + public String endpoint() { + return "http://127.0.0.1:" + server.getAddress().getPort() + "/mcp"; + } + + private void handle(HttpExchange exchange) throws IOException { + try { + String body = new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8); + JsonNode request = MAPPER.readTree(body); + String method = request.path("method").asText(""); + if ("initialize".equals(method)) { + initializeCount.incrementAndGet(); + respond(exchange, result(exchange, request, initializeResult()), "test-session-1"); + } else if ("notifications/initialized".equals(method)) { + exchange.sendResponseHeaders(202, -1); + } else if ("tools/list".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + respond(exchange, result(exchange, request, toolsListResult()), null); + } else if ("tools/call".equals(method)) { + if (exchange.getRequestHeaders().getFirst("Mcp-Session-Id") == null) { + sawRequestWithoutSessionId = true; + exchange.sendResponseHeaders(400, -1); + return; + } + String tool = request.path("params").path("name").asText(); + toolCalls.add(Map.entry(tool, request.path("params").path("arguments"))); + respond(exchange, result(exchange, request, toolCallResult(tool, + request.path("params").path("arguments"))), null); + } else { + exchange.sendResponseHeaders(404, -1); + } + } finally { + exchange.close(); + } + } + + private ObjectNode initializeResult() { + ObjectNode result = MAPPER.createObjectNode(); + result.put("protocolVersion", "2024-11-05"); + result.set("capabilities", MAPPER.createObjectNode()); + result.putObject("serverInfo").put("name", "testing-mcp").put("version", "1.0"); + return result; + } + + private ObjectNode toolsListResult() { + ObjectNode result = MAPPER.createObjectNode(); + ArrayNode tools = result.putArray("tools"); + for (String name : toolNames) { + ObjectNode tool = tools.addObject(); + tool.put("name", name); + tool.put("description", "test tool " + name); + tool.putObject("inputSchema").put("type", "object"); + } + return result; + } + + private ObjectNode toolCallResult(String tool, JsonNode arguments) { + ObjectNode result = MAPPER.createObjectNode(); + if ("boom".equals(tool)) { + result.put("isError", true); + result.putArray("content").addObject().put("type", "text").put("text", "tool exploded"); + return result; + } + result.putArray("content").addObject().put("type", "text") + .put("text", "result of " + tool + ": " + arguments); + return result; + } + + private ObjectNode result(HttpExchange exchange, JsonNode request, ObjectNode payload) { + ObjectNode response = MAPPER.createObjectNode(); + response.put("jsonrpc", "2.0"); + response.put("id", request.path("id").asInt()); + response.set("result", payload); + return response; + } + + private void respond(HttpExchange exchange, ObjectNode response, String sessionId) + throws IOException { + byte[] bytes; + String contentType; + if (sseMode) { + bytes = ("event: message\ndata: " + response + "\n\n").getBytes(StandardCharsets.UTF_8); + contentType = "text/event-stream"; + } else { + bytes = response.toString().getBytes(StandardCharsets.UTF_8); + contentType = "application/json"; + } + if (sessionId != null) { + exchange.getResponseHeaders().set("Mcp-Session-Id", sessionId); + } + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(200, bytes.length); + try (OutputStream output = exchange.getResponseBody()) { + output.write(bytes); + } + } + + @Override + public void close() { + server.stop(0); + } +} diff --git a/server/skillhub-app/src/test/resources/application-test.yml b/server/skillhub-app/src/test/resources/application-test.yml index c95ada164..099c38af6 100644 --- a/server/skillhub-app/src/test/resources/application-test.yml +++ b/server/skillhub-app/src/test/resources/application-test.yml @@ -49,6 +49,19 @@ skillhub: security: scanner: enabled: false + authoring: + local-script: + # tests exercise the inline script runtime directly; no docker needed + execution-mode: inline + openai-compatible: + # fake LLM endpoints in tests bind to loopback + allow-private-endpoints: true + mcp: + # fake MCP servers in tests bind to loopback; link-local stays blocked + allow-private-endpoints: true + stdio-enabled: true + # the one variable bindings may reference in tests; everything else is rejected + env-allowlist: TEST_ALLOWED_MCP_VAR logging: level: diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java new file mode 100644 index 000000000..c412a6c8b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/AgentRuntimeType.java @@ -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); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java new file mode 100644 index 000000000..c741569a5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBinding.java @@ -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. + * + *

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 config; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "tool_allowlist") + private List toolAllowlist; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "mcp_servers") + private List> 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 config, + List toolAllowlist, List> 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 getConfig() { + return config; + } + + public List getToolAllowlist() { + return toolAllowlist; + } + + public List> getMcpServers() { + return mcpServers; + } + + public String getUpdatedBy() { + return updatedBy; + } + + public Instant getUpdatedAt() { + return updatedAt; + } + + public Instant getCreatedAt() { + return createdAt; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java new file mode 100644 index 000000000..6ff71050d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/RuntimeBindingRepository.java @@ -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 findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java new file mode 100644 index 000000000..981ace3b9 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionEvaluator.java @@ -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 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 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 validateSyntax(List assertions, boolean promptTask) { + List problems = new ArrayList<>(); + List 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 evaluate(List assertions, TaskResult result) { + List 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; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java new file mode 100644 index 000000000..cf5bbd49f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/AssertionFailure.java @@ -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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java new file mode 100644 index 000000000..3db676471 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeEventSink.java @@ -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); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java new file mode 100644 index 000000000..41eed79c5 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/RuntimeExecutionContext.java @@ -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 adapterConfig, + List toolAllowlist, + List> mcpServers, + TaskCancellation cancellation +) { + + /** Convenience constructor for runtimes that do not consume MCP servers. */ + public RuntimeExecutionContext(Long runId, Path workingDirectory, Map adapterConfig, + List toolAllowlist, TaskCancellation cancellation) { + this(runId, workingDirectory, adapterConfig, toolAllowlist, List.of(), cancellation); + } + + public Map safeAdapterConfig() { + return adapterConfig == null ? Map.of() : adapterConfig; + } + + public List safeToolAllowlist() { + return toolAllowlist == null ? List.of() : List.copyOf(toolAllowlist); + } + + public List> 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(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java new file mode 100644 index 000000000..82b20c446 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/SkillRuntimeAdapter.java @@ -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. + * + *

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: + *

    + *
  • honor {@code context.cancellation()} while running;
  • + *
  • enforce the task timeout themselves;
  • + *
  • report progress through {@code sink};
  • + *
  • never modify draft state — the working directory is a disposable copy.
  • + *
+ * + * @return the raw task outcome for assertion evaluation + * @throws InterruptedException when cancelled mid-execution + */ + TaskResult execute(RuntimeExecutionContext context, ValidationTaskSpec task, + RuntimeEventSink sink) throws Exception; +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java new file mode 100644 index 000000000..e4bef908e --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskCancellation.java @@ -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(); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java new file mode 100644 index 000000000..9fefcfd27 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/TaskResult.java @@ -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 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)); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java new file mode 100644 index 000000000..c99d70d0b --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/runtime/package-info.java @@ -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; diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java new file mode 100644 index 000000000..42f70ca1d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/AuthoringSecurityPolicy.java @@ -0,0 +1,33 @@ +package com.iflytek.skillhub.domain.authoring.service; + +/** + * Security boundary for user-supplied runtime bindings, implemented by the + * application layer from {@code skillhub.authoring.*} configuration. The domain + * validator consults it at save time so that dangerous bindings are rejected + * before they can ever run. + * + *

Implementations must be conservative: an endpoint, transport, or + * environment reference that cannot be checked must be rejected, not allowed. + */ +public interface AuthoringSecurityPolicy { + + /** Where a user-supplied URL is about to be used, so flags can differ per surface. */ + enum EndpointUse { + MCP_SERVER, + LLM_API + } + + /** + * Decides whether the server may make an outbound HTTP(S) request to the + * given URL (SSRF guard). + * + * @return {@code null} when the URL is allowed, otherwise the rejection reason + */ + String endpointRejection(String url, EndpointUse use); + + /** Whether MCP servers may use the stdio transport (server-side process spawn). */ + boolean stdioTransportAllowed(); + + /** Whether a binding may reference the named server environment variable. */ + boolean envRefAllowed(String name); +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java new file mode 100644 index 000000000..90153578f --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingService.java @@ -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 adapterConfig, + List toolAllowlist, + List> mcpServers, + Set 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 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 findBinding(Long draftId) { + return bindingRepository.findByDraftId(draftId); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java new file mode 100644 index 000000000..7902f9879 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/RuntimeBindingValidator.java @@ -0,0 +1,236 @@ +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 + * {@link AuthoringSecurityPolicy} so unsafe bindings are rejected at save time. + */ +@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 TRANSPORTS = List.of("http", "sse", "stdio"); + private static final List INTERPRETERS = List.of("sh", "bash", "python3", "node"); + + private final AuthoringSecurityPolicy securityPolicy; + + public RuntimeBindingValidator(AuthoringSecurityPolicy securityPolicy) { + this.securityPolicy = securityPolicy; + } + + /** + * @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 validate(String agentType, Map adapterConfig, + List toolAllowlist, List> mcpServers) { + List 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 server : mcpServers) { + validateMcpServer(server, findings); + } + } + + scanForEmbeddedSecrets(agentType, adapterConfig, toolAllowlist, mcpServers, findings); + return findings; + } + + private void validateLocalScriptConfig(Map config, List 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 config, List 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)); + } else { + // these requests carry the server-side API key; the SSRF guard must + // approve the destination before any credential can leave the host + String rejection = securityPolicy.endpointRejection( + endpoint.toString(), AuthoringSecurityPolicy.EndpointUse.LLM_API); + if (rejection != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "LLM_ENDPOINT_BLOCKED", + "openai-compatible endpoint is not allowed: " + rejection)); + } + } + 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 server, List 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 { + String rejection = securityPolicy.endpointRejection( + endpoint.toString(), AuthoringSecurityPolicy.EndpointUse.MCP_SERVER); + if (rejection != null) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENDPOINT_BLOCKED", + "MCP server " + name + " endpoint is not allowed: " + rejection)); + } + } + } else { + if (!securityPolicy.stdioTransportAllowed()) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_STDIO_DISABLED", + "MCP server " + name + " uses the stdio transport, which is disabled" + + " (skillhub.authoring.mcp.stdio-enabled=false); 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)); + } else if (!securityPolicy.envRefAllowed(ref.toString())) { + findings.add(FindingDraft.error(ValidationLayer.CONFIG, "MCP_ENV_REF_NOT_ALLOWED", + "MCP server " + name + " may not reference environment variable " + + ref + " (not in skillhub.authoring.mcp.env-allowlist)")); + } + } + } + } + + /** Rejects secret-looking keys anywhere in the binding payload. */ + private void scanForEmbeddedSecrets(String agentType, Map adapterConfig, + List toolAllowlist, + List> mcpServers, + List findings) { + if (adapterConfig != null) { + scanMap(adapterConfig, findings); + } + if (mcpServers != null) { + for (Map server : mcpServers) { + scanMap(server, findings); + } + } + } + + private void scanMap(Map map, List findings) { + for (Map.Entry 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; + } + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java new file mode 100644 index 000000000..43e6dd755 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/AssertionSpec.java @@ -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 params) { + + public Map 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; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java new file mode 100644 index 000000000..58fc6ff96 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/TaskType.java @@ -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 +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java new file mode 100644 index 000000000..b1bc5b34d --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpec.java @@ -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 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 safeTasks() { + return tasks == null ? List.of() : List.copyOf(tasks); + } + + public Optional findTask(String name) { + return safeTasks().stream().filter(task -> task.name().equals(name)).findFirst(); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java new file mode 100644 index 000000000..60695ab12 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationSpecParser.java @@ -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}. + * + *

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 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 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 tasks = new ArrayList<>(); + java.util.Set 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 errors, + java.util.Set 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 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 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 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))); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java new file mode 100644 index 000000000..dce5ba21a --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/ValidationTaskSpec.java @@ -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 args, + String prompt, + int timeoutMs, + List assertions +) { + + public List safeArgs() { + return args == null ? List.of() : List.copyOf(args); + } + + public List safeAssertions() { + return assertions == null ? List.of() : List.copyOf(assertions); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java new file mode 100644 index 000000000..01f47fb37 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/spec/package-info.java @@ -0,0 +1,4 @@ +/** + * Behavior validation task specifications parsed from a draft's {@code validation.yaml}. + */ +package com.iflytek.skillhub.domain.authoring.spec; diff --git a/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java new file mode 100644 index 000000000..c8b354fbf --- /dev/null +++ b/server/skillhub-infra/src/main/java/com/iflytek/skillhub/infra/jpa/RuntimeBindingJpaRepository.java @@ -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, + RuntimeBindingRepository { + + Optional findByDraftId(Long draftId); + + void deleteByDraftId(Long draftId); +} From 55583d5e5fbfcc162f84f387f74cd83226ef9317 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Wed, 14 Oct 2026 22:21:26 +0800 Subject: [PATCH 2/9] merge(authoring): fold the upstream lifecycle hardening into the runtime orchestrator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebases the runtime layer onto main, which now carries #935 plus the follow-up hardening (FutureTask per run with duplicate-submit fencing, a wall-clock watchdog on the timer, settle-first cancellation with worker interrupt, appendTerminalEvent, revision-pinned materializeSnapshot, and the PackageEntry streaming API). The three-phase orchestrator keeps all of it: the watchdog remains the hard deadline while the behavior layer keeps its between-task soft deadline; the cooperative cancel signal is now the worker's interrupt status; the config/behavior layers read the pinned snapshot and the draft id from the run instead of a reloaded draft. The upstream orchestrator test is adapted to the runtime-layer constructor (no adapters registered — the lifecycle paths under test never reach the config layer) and passes unchanged in its assertions. Signed-off-by: zjncs <18910855655@163.com> --- .../service/authoring/ValidationRunOrchestrator.java | 7 ++++--- .../service/authoring/ValidationRunOrchestratorTest.java | 9 ++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java index ff8f74ddb..48f2a9873 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestrator.java @@ -26,6 +26,7 @@ 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.service.authoring.mcp.McpProbeService; import com.iflytek.skillhub.domain.skill.validation.PackageEntry; import java.io.IOException; import java.nio.charset.StandardCharsets; @@ -474,9 +475,9 @@ private void materializeWorkspace(Path workspace, List entries) throws IOException { Files.createDirectories(workspace); for (PackageEntry entry : entries) { - Path target = workspace.resolve(entry.relativePath()).normalize(); + Path target = workspace.resolve(entry.path()).normalize(); if (!target.startsWith(workspace.normalize())) { - throw new IOException("package entry escapes the workspace: " + entry.relativePath()); + throw new IOException("package entry escapes the workspace: " + entry.path()); } Files.createDirectories(target.getParent()); Files.write(target, entry.content()); @@ -485,7 +486,7 @@ private void materializeWorkspace(Path workspace, List entries) private String readEntryText(List entries, String path) { return entries.stream() - .filter(entry -> entry.relativePath().equals(path)) + .filter(entry -> entry.path().equals(path)) .findFirst() .map(entry -> new String(entry.content(), StandardCharsets.UTF_8)) .orElse(null); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestratorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestratorTest.java index 9c15aa191..031ff954d 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestratorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/authoring/ValidationRunOrchestratorTest.java @@ -30,6 +30,12 @@ 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); + private final com.iflytek.skillhub.service.authoring.mcp.McpProbeService mcpProbeService = + mock(com.iflytek.skillhub.service.authoring.mcp.McpProbeService.class); @Test void duplicateClaimCannotEmitAnActiveRunAsFinished() { @@ -135,6 +141,7 @@ private ValidationRunOrchestrator orchestrator(java.util.concurrent.Executor exe 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, + mcpProbeService, java.util.List.of(), broadcaster, properties, executor, timer); } } From 14e0e9edde1fbc72a22440f113114acecadf5016 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Wed, 14 Oct 2026 22:45:21 +0800 Subject: [PATCH 3/9] fix(authoring): make the docker workspace readable and container files deletable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validation container drops every capability, including DAC_OVERRIDE, so the mounted workspace must be world-readable on the host for the in-container interpreter to open the script, and directories must be traversable and writable so the script can create artifacts. Fresh JVM temp directories are owner-only on Linux (umask 077 runners, hardened hosts), which broke script execution with 'sh: can't open: Permission denied' — macOS Docker Desktop masks this by remapping ownership inside its VM, which is why the local suite passed while the hosted-runner CI failed. The container also runs as the host JVM's uid:gid (--user) so files the script creates inside the mount belong to the same user that cleans the workspace up; running as the image-default root left artifacts that the host user (and the test harness) could not delete. Signed-off-by: zjncs <18910855655@163.com> --- .../adapter/DockerScriptCommandBuilder.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java index 197b0c133..67d112618 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/service/authoring/adapter/DockerScriptCommandBuilder.java @@ -2,6 +2,7 @@ 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; @@ -81,6 +82,7 @@ public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptP String runTag) { AuthoringProperties.LocalScript.Docker docker = properties.getLocalScript().getDocker(); String hostWorkspace = realPath(workspace); + ensureContainerReadable(workspace); List command = new ArrayList<>(); command.add("docker"); @@ -88,6 +90,10 @@ public ScriptExecutionPlan plan(Path workspace, String interpreter, Path scriptP 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"); @@ -135,6 +141,66 @@ public void cleanup(String runTag) { } } + + /** Cached "uid:gid" of the host JVM user, so container-created files stay deletable. */ + private static final java.util.Optional HOST_USER = resolveHostUser(); + + private static java.util.Optional 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 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_.-]", "-"); From 39d21f6b37a6367d8512918c14dad564a94c7d88 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Thu, 15 Oct 2026 14:30:36 +0800 Subject: [PATCH 4/9] chore: retrigger backend CI on this head The push of this rebase did not create a workflow run (GitHub-side hiccup); an empty commit to retrigger the backend-tests check. Signed-off-by: zjncs <18910855655@163.com> From d4cd9f07fa4973a2ce79a7306b175e39491e1ca3 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Wed, 21 Oct 2026 16:57:13 +0800 Subject: [PATCH 5/9] chore(authoring): regenerate the API schema after the rebase Picks up upstream API drift (S3 storage options, github-sync, dsh 0.3.1) on top of the authoring endpoints. Signed-off-by: zjncs <18910855655@163.com> --- web/src/api/generated/schema.d.ts | 163 ++++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 81a8b7dbd..61b72971d 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -530,6 +530,42 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/runtime": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get the draft's runtime binding */ + get: operations["getDraftRuntimeBinding_1"]; + /** Configure the agent runtime, tools, and MCP servers */ + put: operations["saveDraftRuntimeBinding_1"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/web/authoring/drafts/{draftId}/files": { parameters: { query?: never; @@ -7498,6 +7534,37 @@ export interface components { sourceId?: number; ignorePatterns?: string[]; }; + RuntimeBindingRequest: { + agentType: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + }; + ApiResponseRuntimeBindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["RuntimeBindingResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + RuntimeBindingResponse: { + agentType?: string; + config?: { + [key: string]: Record; + }; + toolAllowlist?: string[]; + mcpServers?: { + [key: string]: Record; + }[]; + /** Format: date-time */ + updatedAt?: string; + }; SaveDraftFileRequest: { path: string; content: string; @@ -13045,6 +13112,102 @@ export interface operations { }; }; }; + getDraftRuntimeBinding: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; + }; + }; + }; + }; + saveDraftRuntimeBinding: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RuntimeBindingRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; + }; + }; + }; + }; + getDraftRuntimeBinding_1: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; + }; + }; + }; + }; + saveDraftRuntimeBinding_1: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RuntimeBindingRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseRuntimeBindingResponse"]; + }; + }; + }; + }; listDraftFiles: { parameters: { query?: never; From f4880578bdb95f6b97244f8d138aba319422e3c5 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sat, 10 Oct 2026 14:54:22 +0800 Subject: [PATCH 6/9] feat(authoring): fix loop and publish-pipeline integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer (c) of the authoring platform (#934), stacking on the runtime layer: - **Fix loop**: apply a finding's suggested patch to the draft in one call (sha256-guarded old content, revision bump, validation verdict invalidated by the new revision) or dismiss it as not applicable - **Submission**: validated drafts submit into the existing SkillHub publish pipeline — the gate is strict (the draft's current revision must be bound to a run that succeeded with zero errors), so content can never be published without the exact bytes that were validated; reuses SkillPublishService (package re-validation, security scan, review flow) and records the resulting skill id on the draft Verified by the full authoring flow integration test: create → edit → bind runtime → three-layer validation → fix a finding → re-validate → submit into the (mocked) publish pipeline. Signed-off-by: zjncs <18910855655@163.com> --- .../authoring/SkillAuthoringController.java | 61 ++- .../skillhub/dto/SubmitDraftRequest.java | 10 + .../skillhub/dto/SubmitDraftResponse.java | 17 + .../src/main/resources/messages.properties | 3 + .../src/main/resources/messages_ru.properties | 3 + .../src/main/resources/messages_zh.properties | 3 + .../AuthoringFlowIntegrationTest.java | 397 ++++++++++++++++++ .../authoring/service/DraftSubmitService.java | 94 +++++ .../authoring/service/FindingFixService.java | 94 +++++ 9 files changed, 680 insertions(+), 2 deletions(-) create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java create mode 100644 server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java create mode 100644 server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java create mode 100644 server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java index 936de7b9c..504ac3b5e 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/authoring/SkillAuthoringController.java @@ -2,9 +2,13 @@ import com.iflytek.skillhub.controller.BaseApiController; import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; 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.ValidationFinding; import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; import com.iflytek.skillhub.dto.ApiResponse; import com.iflytek.skillhub.dto.CreateDraftRequest; @@ -13,6 +17,8 @@ import com.iflytek.skillhub.dto.DraftResponse; import com.iflytek.skillhub.dto.RuntimeBindingRequest; import com.iflytek.skillhub.dto.RuntimeBindingResponse; +import com.iflytek.skillhub.dto.SubmitDraftRequest; +import com.iflytek.skillhub.dto.SubmitDraftResponse; import com.iflytek.skillhub.dto.SaveDraftFileRequest; import com.iflytek.skillhub.dto.SaveDraftFileResponse; import com.iflytek.skillhub.dto.ValidationEventResponse; @@ -42,8 +48,8 @@ * SKILL.md scaffold and arbitrary resource files; runtime bindings configure * the agent, tool allowlist and MCP servers; validation runs execute the * structure, config and behavior layers and report findings with fix - * suggestions. The fix-apply loop and publish integration arrive with the - * next layer. + * suggestions that can be applied in one click; validated drafts submit into + * the existing SkillHub publish pipeline. */ @RestController @RequestMapping({"/api/v1/authoring", "/api/web/authoring"}) @@ -52,17 +58,23 @@ public class SkillAuthoringController extends BaseApiController { private final SkillDraftService draftService; private final RuntimeBindingService bindingService; private final ValidationRunService runService; + private final DraftSubmitService submitService; + private final FindingFixService fixService; private final ValidationRunOrchestrator orchestrator; public SkillAuthoringController(com.iflytek.skillhub.dto.ApiResponseFactory responseFactory, SkillDraftService draftService, RuntimeBindingService bindingService, ValidationRunService runService, + DraftSubmitService submitService, + FindingFixService fixService, ValidationRunOrchestrator orchestrator) { super(responseFactory); this.draftService = draftService; this.bindingService = bindingService; this.runService = runService; + this.submitService = submitService; + this.fixService = fixService; this.orchestrator = orchestrator; } @@ -272,4 +284,49 @@ public ApiResponse> listFindings( .map(ValidationFindingResponse::from) .toList()); } + + // ---------------------------------------------------------------- fix loop + + @Operation(operationId = "applyFindingFix", summary = "Apply a finding's fix suggestion to the draft") + @PostMapping("/runs/{runId}/findings/{findingId}/apply") + public ApiResponse applyFindingFix( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + FindingFixService.FixOutcome outcome = fixService.applyFix(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(outcome.finding())); + } + + @Operation(operationId = "dismissFindingFix", summary = "Dismiss a finding as not applicable") + @PostMapping("/runs/{runId}/findings/{findingId}/dismiss") + public ApiResponse dismissFinding( + @PathVariable Long runId, + @PathVariable Long findingId, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + ValidationFinding finding = fixService.dismissFinding(findingId, userId, platformRoles); + return ok("response.success.updated", ValidationFindingResponse.from(finding)); + } + + // ---------------------------------------------------------------- submission + + @Operation(operationId = "submitValidatedDraft", summary = "Submit a validated draft into the publish pipeline") + @PostMapping("/drafts/{draftId}/submit") + public ApiResponse submitDraft(@PathVariable Long draftId, + @RequestBody(required = false) SubmitDraftRequest request, + @RequestAttribute("userId") String userId, + @RequestAttribute(value = "platformRoles", required = false) + Set platformRoles) { + SkillVisibility visibility = request == null || request.visibility() == null + ? SkillVisibility.PRIVATE + : SkillVisibility.valueOf(request.visibility()); + Set roles = request == null || request.platformRoles() == null + ? platformRoles + : request.platformRoles(); + return ok("response.success.created", + SubmitDraftResponse.from(submitService.submit(draftId, userId, visibility, roles))); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java new file mode 100644 index 000000000..b6fe7eaff --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftRequest.java @@ -0,0 +1,10 @@ +package com.iflytek.skillhub.dto; + +import java.util.Set; + +/** Request body for submitting a validated draft into the publish pipeline. */ +public record SubmitDraftRequest( + String visibility, + Set platformRoles +) { +} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java new file mode 100644 index 000000000..ebaaf9823 --- /dev/null +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SubmitDraftResponse.java @@ -0,0 +1,17 @@ +package com.iflytek.skillhub.dto; + +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; + +/** Outcome of submitting a validated draft: the published skill version coordinates. */ +public record SubmitDraftResponse( + Long skillId, + Long versionId, + String slug, + String version +) { + + public static SubmitDraftResponse from(DraftSubmitService.SubmitOutcome outcome) { + return new SubmitDraftResponse( + outcome.skillId(), outcome.versionId(), outcome.slug(), outcome.version()); + } +} diff --git a/server/skillhub-app/src/main/resources/messages.properties b/server/skillhub-app/src/main/resources/messages.properties index 78f02d6bb..c40961d7f 100644 --- a/server/skillhub-app/src/main/resources/messages.properties +++ b/server/skillhub-app/src/main/resources/messages.properties @@ -399,3 +399,6 @@ 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 +# authoring fix loop and submission +error.authoring.submit.notValidated=Revision {0} has no passing validation run; run validation first +error.authoring.submit.runActive=Draft {0} has an active validation run diff --git a/server/skillhub-app/src/main/resources/messages_ru.properties b/server/skillhub-app/src/main/resources/messages_ru.properties index 5e9b8fa14..924e13e10 100644 --- a/server/skillhub-app/src/main/resources/messages_ru.properties +++ b/server/skillhub-app/src/main/resources/messages_ru.properties @@ -342,3 +342,6 @@ error.authoring.binding.notFound=Для черновика {0} не настро error.authoring.binding.agentType.unknown=Неизвестный тип среды выполнения агента: {0} error.authoring.binding.invalid=Недопустимая привязка среды выполнения: {0} error.authoring.binding.unserializable=Привязка среды выполнения не может быть сериализована +# authoring fix loop and submission +error.authoring.submit.notValidated=У ревизии {0} нет пройденной задачи проверки; сначала выполните проверку +error.authoring.submit.runActive=У черновика {0} есть активная задача проверки diff --git a/server/skillhub-app/src/main/resources/messages_zh.properties b/server/skillhub-app/src/main/resources/messages_zh.properties index 00b07d183..8a01d28eb 100644 --- a/server/skillhub-app/src/main/resources/messages_zh.properties +++ b/server/skillhub-app/src/main/resources/messages_zh.properties @@ -401,3 +401,6 @@ error.authoring.binding.notFound=草稿 {0} 尚未配置运行时绑定 error.authoring.binding.agentType.unknown=未知的 Agent 运行时类型:{0} error.authoring.binding.invalid=运行时绑定不合法:{0} error.authoring.binding.unserializable=运行时绑定无法序列化 +# authoring fix loop and submission +error.authoring.submit.notValidated=版本 {0} 没有通过的验证任务,请先执行验证 +error.authoring.submit.runActive=草稿 {0} 有正在进行的验证任务 diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java new file mode 100644 index 000000000..6464abb16 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/authoring/AuthoringFlowIntegrationTest.java @@ -0,0 +1,397 @@ +package com.iflytek.skillhub.authoring; + +import com.iflytek.skillhub.TestRedisConfig; +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.service.DraftSubmitService; +import com.iflytek.skillhub.domain.authoring.service.FindingFixService; +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.FindingSeverity; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +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.NamespaceRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.SkillVersion; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +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.ObjectMetadata; +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.Optional; +import java.util.Set; +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.boot.test.mock.mockito.MockBean; +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; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +/** + * End-to-end authoring flow against the real service stack (Testcontainers + * PostgreSQL, in-memory object storage, mocked publish pipeline): create draft → + * edit files → bind runtime → validate (structure + config + behavior) → fix + * findings → re-validate → submit. Runs on real PostgreSQL because the JSONB + * columns must round-trip exactly as they do in production. + */ +@SpringBootTest +@ActiveProfiles("test") +@Import({TestRedisConfig.class, AuthoringFlowIntegrationTest.InMemoryStorageConfig.class}) +@Testcontainers +class AuthoringFlowIntegrationTest { + + @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-1"; + private static final String NS_SLUG = "authoring-test-ns"; + + @Autowired private SkillDraftService draftService; + @Autowired private RuntimeBindingService bindingService; + @Autowired private ValidationRunService runService; + @Autowired private DraftSubmitService submitService; + @Autowired private FindingFixService fixService; + @Autowired private ValidationRunOrchestrator orchestrator; + @Autowired private NamespaceRepository namespaceRepository; + @Autowired private NamespaceMemberRepository namespaceMemberRepository; + @Autowired private UserAccountRepository userAccountRepository; + + @MockBean private SkillPublishService publishService; + + @TestConfiguration + static class InMemoryStorageConfig { + + @Bean + @Primary + ObjectStorageService inMemoryObjectStorage() { + return new InMemoryObjectStorage(); + } + } + + static final class InMemoryObjectStorage implements ObjectStorageService { + private final Map objects = new ConcurrentHashMap<>(); + + @Override + public void putObject(String key, InputStream data, long size, String contentType) { + try { + objects.put(key, data.readAllBytes()); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + + @Override + public InputStream getObject(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new java.io.ByteArrayInputStream(content); + } + + @Override + public void deleteObject(String key) { + objects.remove(key); + } + + @Override + public void deleteObjects(List keys) { + keys.forEach(objects::remove); + } + + @Override + public boolean exists(String key) { + return objects.containsKey(key); + } + + @Override + public ObjectMetadata getMetadata(String key) { + byte[] content = objects.get(key); + if (content == null) { + throw new IllegalArgumentException("missing object: " + key); + } + return new ObjectMetadata(content.length, "application/octet-stream", java.time.Instant.now()); + } + + @Override + public String generatePresignedUrl(String key, Duration expiry, String downloadFilename) { + return "http://localhost/" + key; + } + } + + @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 validatedDraftRunsAllLayersAndSubmits() { + // ---- create with scaffold + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "demo-flow", "summarizes documents", null); + assertThat(draft.getRevision()).isEqualTo(1); + assertThat(draftService.listFiles(draft.getId())) + .extracting(file -> file.getFilePath()) + .containsExactly("SKILL.md"); + // the scaffold bytes must be readable straight away, before any manual + // save — creation persists content, not just the metadata row + assertThat(draftService.readFile(draft.getId(), USER, "SKILL.md", null).asText()) + .contains("name: demo-flow") + .contains("description: summarizes documents"); + + // ---- edit files: overwrite SKILL.md, add script + validation.yaml + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "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); + assertThat(draftService.getDraft(draft.getId()).getRevision()).isEqualTo(4); + + // ---- bind runtime: local script execution + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, null, null); + + // ---- validate: all three layers + 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", "PHASE_STARTED", "TOOL_CALL", "LOG", "RUN_FINISHED"); + + // ---- draft is validated for this revision + SkillDraft validated = draftService.getDraft(draft.getId()); + assertThat(validated.isCurrentRevisionValidated()).isTrue(); + assertThat(validated.getValidatedRevision()).isEqualTo(validated.getRevision()); + + // ---- submit into the (mocked) publish pipeline + SkillVersion publishedVersion = new SkillVersion(99L, "1.0.0", USER); + when(publishService.publishFromEntries(eq(NS_SLUG), any(), eq(USER), + eq(SkillVisibility.PRIVATE), any(), anyBoolean())) + .thenReturn(new SkillPublishService.PublishResult(99L, "demo-flow", publishedVersion)); + + DraftSubmitService.SubmitOutcome outcome = submitService.submit( + draft.getId(), USER, SkillVisibility.PRIVATE, null); + + assertThat(outcome.skillId()).isEqualTo(99L); + assertThat(outcome.version()).isEqualTo("1.0.0"); + assertThat(draftService.getDraft(draft.getId()).getSubmittedSkillId()).isEqualTo(99L); + } + + @Test + void brokenFrontmatterProducesFixableFindingAndRevalidationLoop() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "broken-flow", null, null); + // SKILL.md without the required description field + draftService.saveFile(draft.getId(), USER, "SKILL.md", + "---\nname: broken-flow\n---\n\n# Broken\n".getBytes(StandardCharsets.UTF_8), + "text/markdown", null, null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + awaitTerminal(run.getId()); + + List findings = runService.listFindings(run.getId()); + assertThat(findings).isNotEmpty(); + Optional missingField = findings.stream() + .filter(finding -> "FRONTMATTER_FIELD_MISSING".equals(finding.getRuleCode())) + .findFirst(); + assertThat(missingField).isPresent(); + assertThat(missingField.get().getSeverity()).isEqualTo(FindingSeverity.ERROR); + assertThat(missingField.get().hasSuggestion()).isTrue(); + assertThat(runService.getRun(run.getId()).getStatus()).isEqualTo(ValidationRunStatus.FAILED); + + // ---- apply the suggested fix + int revisionBefore = draftService.getDraft(draft.getId()).getRevision(); + FindingFixService.FixOutcome outcome = fixService.applyFix( + missingField.get().getId(), USER, null); + assertThat(outcome.finding().getStatus().name()).isEqualTo("APPLIED"); + assertThat(outcome.draft().getRevision()).isGreaterThan(revisionBefore); + + // ---- validation verdict is invalidated by the new revision + SkillDraft fixed = draftService.getDraft(draft.getId()); + assertThat(fixed.isCurrentRevisionValidated()).isFalse(); + + // ---- re-validate: now the description field exists and the run passes + ValidationRun second = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(second.getId()); + ValidationRun finishedSecond = awaitTerminal(second.getId()); + + assertThat(finishedSecond.getStatus()).isEqualTo(ValidationRunStatus.SUCCEEDED); + assertThat(draftService.getDraft(draft.getId()).isCurrentRevisionValidated()).isTrue(); + } + + @Test + void deadMcpServerFailsTheRunWithoutValidationYaml() { + // no validation.yaml on purpose: the config layer must still check the + // runtime binding — a declared MCP server that cannot be reached is a + // config error even when the behavior layer has no tasks to run + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "mcp-probe-flow", + "declares a dead MCP server", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "dead-server", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp")), null); + + ValidationRun run = runService.startRun(draft.getId(), USER, null); + orchestrator.submit(run.getId()); + ValidationRun finished = awaitTerminal(run.getId()); + + assertThat(finished.getStatus()).isEqualTo(ValidationRunStatus.FAILED); + assertThat(runService.listFindings(run.getId())) + .extracting(ValidationFinding::getRuleCode) + .contains("SPEC_MISSING", "MCP_CONNECT_FAILED"); + } + + @Test + void securityPolicyRejectsUnsafeBindingsAtSaveTime() { + SkillDraft draft = draftService.createDraft(NS_SLUG, USER, "security-flow", + "exercises the binding security policy", null); + draftService.saveFile(draft.getId(), USER, "SKILL.md", validSkillMd(), "text/markdown", null, null); + + // cloud metadata endpoints are always blocked — even under the test + // profile where loopback/RFC1918 endpoints are deliberately allowed + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "metadata", "transport", "http", + "endpoint", "http://169.254.169.254/latest/meta-data/")), null), + "link-local"); + + // envRefs outside the configured allowlist are rejected: bindings must + // not be able to read arbitrary server environment variables + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-hog", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("HOME"))), null), + "env-allowlist"); + + // the LLM surface is guarded too — those requests carry the server API key + assertBindingRejected(() -> bindingService.saveBinding(draft.getId(), USER, "openai-compatible", + Map.of("endpoint", "http://169.254.169.254/v1", "model", "test-model"), null, null, null), + "link-local"); + + // the allowlisted env ref saves fine (loopback is allowed under the test profile) + bindingService.saveBinding(draft.getId(), USER, "local-script", + Map.of("interpreter", "sh"), null, + List.of(Map.of("name", "env-ok", "transport", "http", + "endpoint", "http://127.0.0.1:9/mcp", + "envRefs", List.of("TEST_ALLOWED_MCP_VAR"))), null); + } + + // ---------------------------------------------------------------- helpers + + /** Save-time rejection: a 400 whose first message arg explains the reason. */ + 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 " + runId + " did not reach a terminal state in time"); + return null; + } + + private static byte[] validSkillMd() { + return """ + --- + name: demo-flow + description: summarizes documents for the demo + --- + + # Demo flow + + ## Overview + + summarizes documents for the demo + """.getBytes(StandardCharsets.UTF_8); + } + + private static String validationYaml() { + return """ + version: 1 + tasks: + - name: smoke + description: script runs and prints OK + type: script + script: scripts/check.sh + args: [] + timeoutMs: 10000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: smoke OK + """; + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java new file mode 100644 index 000000000..7d74b8f73 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/DraftSubmitService.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +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.shared.exception.DomainNotFoundException; +import com.iflytek.skillhub.domain.skill.SkillVisibility; +import com.iflytek.skillhub.domain.skill.service.SkillPublishService; +import com.iflytek.skillhub.domain.skill.validation.PackageEntry; +import java.time.Clock; +import java.util.Set; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Submits a validated draft into the existing SkillHub publish pipeline. The gate is + * strict: the draft's current revision must be bound to a validation run that + * succeeded with zero errors, so content can never be published without the exact + * bytes that were validated. Publishing reuses {@link SkillPublishService}, which + * re-validates the package, triggers the security scan, and opens the review flow. + */ +@Service +public class DraftSubmitService { + + private static final Logger log = LoggerFactory.getLogger(DraftSubmitService.class); + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + public record SubmitOutcome(Long skillId, Long versionId, String slug, String version, + SkillPublishService.PublishResult publishResult) {} + + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + private final ValidationRunService runService; + private final SkillPublishService publishService; + private final Clock clock; + + public DraftSubmitService(SkillDraftRepository draftRepository, + SkillDraftService draftService, + ValidationRunService runService, + SkillPublishService publishService, + Clock clock) { + this.draftRepository = draftRepository; + this.draftService = draftService; + this.runService = runService; + this.publishService = publishService; + this.clock = clock; + } + + /** + * Publishes the draft's current revision as a new skill version. Warnings that the + * validation run already surfaced to the author are confirmed here; errors were a + * hard gate and cannot reach this point. + */ + @Transactional + public SubmitOutcome submit(Long draftId, String userId, SkillVisibility visibility, + Set platformRoles) { + SkillDraft draft = draftRepository.findById(draftId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.draft.notFound", draftId)); + assertOwner(draft, userId, platformRoles); + + if (runService.listRuns(draftId).stream().anyMatch(ValidationRun::isActive)) { + throw new DomainConflictException("error.authoring.submit.runActive", draftId); + } + ValidationRun passingRun = runService.requirePassingRunForRevision( + draftId, draft.getRevision()); + + String namespaceSlug = draftService.resolveNamespaceSlug(draft); + java.util.List entries = draftService.materializeEntries(draftId); + + SkillPublishService.PublishResult result = publishService.publishFromEntries( + namespaceSlug, entries, userId, visibility, platformRoles, true); + + draft.markSubmitted(result.skillId(), result.version().getId(), clock.instant()); + draftRepository.save(draft); + log.info("Draft {} revision {} submitted as skill {} version {} (validation run {})", + draftId, draft.getRevision(), result.skillId(), + result.version().getVersion(), passingRun.getId()); + return new SubmitOutcome(result.skillId(), result.version().getId(), result.slug(), + result.version().getVersion(), result); + } + + private void assertOwner(SkillDraft draft, String userId, Set platformRoles) { + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return; + } + throw new com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException( + "error.authoring.draft.forbidden", draft.getId()); + } +} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java new file mode 100644 index 000000000..8bc9a2c57 --- /dev/null +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/authoring/service/FindingFixService.java @@ -0,0 +1,94 @@ +package com.iflytek.skillhub.domain.authoring.service; + +import com.iflytek.skillhub.domain.authoring.SkillDraft; +import com.iflytek.skillhub.domain.authoring.SkillDraftRepository; +import com.iflytek.skillhub.domain.authoring.validation.FindingStatus; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFinding; +import com.iflytek.skillhub.domain.authoring.validation.ValidationFindingRepository; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRun; +import com.iflytek.skillhub.domain.authoring.validation.ValidationRunRepository; +import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; +import com.iflytek.skillhub.domain.shared.exception.DomainConflictException; +import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.util.Set; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closes the fix loop for validation findings: an author previews a finding's + * machine-applicable suggestion, then confirms it. Applying the patch produces a new + * draft revision through {@link SkillDraftService#applyPatch} (which re-validates all + * sha256 guards), marks the finding APPLIED, and thereby invalidates the previous + * validation verdict — re-validation is always required afterwards. + */ +@Service +public class FindingFixService { + + private static final String SUPER_ADMIN = "SUPER_ADMIN"; + + private final ValidationFindingRepository findingRepository; + private final ValidationRunRepository runRepository; + private final SkillDraftRepository draftRepository; + private final SkillDraftService draftService; + + public FindingFixService(ValidationFindingRepository findingRepository, + ValidationRunRepository runRepository, + SkillDraftRepository draftRepository, + SkillDraftService draftService) { + this.findingRepository = findingRepository; + this.runRepository = runRepository; + this.draftRepository = draftRepository; + this.draftService = draftService; + } + + public record FixOutcome(ValidationFinding finding, SkillDraft draft) {} + + /** + * Applies the finding's fix suggestion to the draft. The whole operation is atomic: + * either every patch lands and the finding is marked APPLIED, or nothing changes. + */ + @Transactional + public FixOutcome applyFix(Long findingId, String userId, Set platformRoles) { + OwnedFinding owned = loadOwnedFinding(findingId, userId, platformRoles); + ValidationFinding finding = owned.finding(); + if (!finding.hasSuggestion()) { + throw new DomainBadRequestException("error.authoring.finding.noSuggestion", findingId); + } + if (finding.getStatus() == FindingStatus.APPLIED) { + throw new DomainConflictException("error.authoring.finding.applied", findingId); + } + + SkillDraft draft = draftService.applyPatch( + owned.draftId(), userId, finding.getSuggestion().safePatches(), null, platformRoles); + finding.markApplied(draft.getRevision()); + findingRepository.save(finding); + return new FixOutcome(finding, draft); + } + + /** Dismisses a finding the author considers not applicable. */ + @Transactional + public ValidationFinding dismissFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = loadOwnedFinding(findingId, userId, platformRoles).finding(); + finding.markDismissed(); + return findingRepository.save(finding); + } + + private record OwnedFinding(ValidationFinding finding, Long draftId) {} + + private OwnedFinding loadOwnedFinding(Long findingId, String userId, Set platformRoles) { + ValidationFinding finding = findingRepository.findById(findingId) + .orElseThrow(() -> new DomainNotFoundException("error.authoring.finding.notFound", findingId)); + ValidationRun run = runRepository.findById(finding.getRunId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.run.notFound", finding.getRunId())); + SkillDraft draft = draftRepository.findById(run.getDraftId()) + .orElseThrow(() -> new DomainNotFoundException( + "error.authoring.draft.notFound", run.getDraftId())); + if (draft.getOwnerId().equals(userId) + || (platformRoles != null && platformRoles.contains(SUPER_ADMIN))) { + return new OwnedFinding(finding, draft.getId()); + } + throw new DomainForbiddenException("error.authoring.draft.forbidden", draft.getId()); + } +} From 51d3deb1ec15756815e903e9cab48911e9411901 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Mon, 12 Oct 2026 11:27:30 +0800 Subject: [PATCH 7/9] chore(authoring): regenerate the API schema for the fix/publish layer Adds the finding apply/dismiss and draft submit endpoints on top of the runtime-layer schema. Signed-off-by: zjncs <18910855655@163.com> --- web/src/api/generated/schema.d.ts | 332 +++++++++++++++++++++++++++--- 1 file changed, 304 insertions(+), 28 deletions(-) diff --git a/web/src/api/generated/schema.d.ts b/web/src/api/generated/schema.d.ts index 61b72971d..799be7f51 100644 --- a/web/src/api/generated/schema.d.ts +++ b/web/src/api/generated/schema.d.ts @@ -2873,6 +2873,74 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/dismiss": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Dismiss a finding as not applicable */ + post: operations["dismissFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/web/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/runs/{runId}/findings/{findingId}/apply": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Apply a finding's fix suggestion to the draft */ + post: operations["applyFindingFix_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/authoring/runs/{runId}/cancel": { parameters: { query?: never; @@ -2907,6 +2975,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/web/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/authoring/drafts/{draftId}/submit": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Submit a validated draft into the publish pipeline */ + post: operations["submitValidatedDraft_1"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/authoring/drafts/{draftId}/runs": { parameters: { query?: never; @@ -8580,6 +8682,43 @@ export interface components { createdAt?: string; readAt?: string; }; + ApiResponseValidationFindingResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["ValidationFindingResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + FilePatch: { + filePath?: string; + oldSha256?: string; + oldValue?: string; + newValue?: string; + }; + FixSuggestion: { + patches?: components["schemas"]["FilePatch"][]; + description?: string; + }; + ValidationFindingResponse: { + /** Format: int64 */ + id?: number; + /** Format: int64 */ + runId?: number; + layer?: string; + ruleCode?: string; + severity?: string; + filePath?: string; + location?: string; + message?: string; + suggestion?: components["schemas"]["FixSuggestion"]; + status?: string; + /** Format: int32 */ + appliedRevision?: number; + /** Format: date-time */ + createdAt?: string; + }; ApiResponseValidationRunResponse: { /** Format: int32 */ code?: number; @@ -8615,6 +8754,27 @@ export interface components { [key: string]: Record; }; }; + SubmitDraftRequest: { + visibility?: string; + platformRoles?: string[]; + }; + ApiResponseSubmitDraftResponse: { + /** Format: int32 */ + code?: number; + msg?: string; + data?: components["schemas"]["SubmitDraftResponse"]; + /** Format: date-time */ + timestamp?: string; + requestId?: string; + }; + SubmitDraftResponse: { + /** Format: int64 */ + skillId?: number; + /** Format: int64 */ + versionId?: number; + slug?: string; + version?: string; + }; CreateDraftRequest: { namespaceSlug: string; name: string; @@ -10422,34 +10582,6 @@ export interface components { timestamp?: string; requestId?: string; }; - FilePatch: { - filePath?: string; - oldSha256?: string; - oldValue?: string; - newValue?: string; - }; - FixSuggestion: { - patches?: components["schemas"]["FilePatch"][]; - description?: string; - }; - ValidationFindingResponse: { - /** Format: int64 */ - id?: number; - /** Format: int64 */ - runId?: number; - layer?: string; - ruleCode?: string; - severity?: string; - filePath?: string; - location?: string; - message?: string; - suggestion?: components["schemas"]["FixSuggestion"]; - status?: string; - /** Format: int32 */ - appliedRevision?: number; - /** Format: date-time */ - createdAt?: string; - }; SseEmitter: { /** Format: int64 */ timeout?: number; @@ -17501,6 +17633,98 @@ export interface operations { }; }; }; + dismissFindingFix: { + parameters: { + query?: never; + header?: never; + path: { + runId: number; + findingId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; + }; + }; + }; + }; + dismissFindingFix_1: { + parameters: { + query?: never; + header?: never; + path: { + runId: number; + findingId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; + }; + }; + }; + }; + applyFindingFix: { + parameters: { + query?: never; + header?: never; + path: { + runId: number; + findingId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; + }; + }; + }; + }; + applyFindingFix_1: { + parameters: { + query?: never; + header?: never; + path: { + runId: number; + findingId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseValidationFindingResponse"]; + }; + }; + }; + }; cancelValidationRun: { parameters: { query?: never; @@ -17545,6 +17769,58 @@ export interface operations { }; }; }; + submitValidatedDraft: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; + }; + }; + }; + }; + submitValidatedDraft_1: { + parameters: { + query?: never; + header?: never; + path: { + draftId: number; + }; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["SubmitDraftRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["ApiResponseSubmitDraftResponse"]; + }; + }; + }; + }; listValidationRuns: { parameters: { query?: never; From 4b63b5d90cff01860fe637601e327b1520518153 Mon Sep 17 00:00:00 2001 From: zjncs <18910855655@163.com> Date: Sat, 10 Oct 2026 14:57:59 +0800 Subject: [PATCH 8/9] feat(web): skill authoring workbench MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer (d) of the authoring platform (#934), stacking on the fix/publish layer: the web workbench under /dashboard/authoring — - draft list and draft detail (files / runtime binding / validation runs) with an editor that tracks unsaved state and surfaces optimistic- concurrency conflicts - runtime binding form switching fields by agent type (interpreter vs endpoint/model), tool allowlist and MCP server declarations with immediate validation - run detail: status card, live event console (SSE with a 2s polling fallback and cursor replay), finding cards with severity, location, suggested-fix diff preview and one-click apply/dismiss - submit dialog handing validated drafts to the publish pipeline - copy in en/zh/ru; Playwright e2e for the full authoring flow (e2e/authoring-flow.spec.ts) Verified: typecheck clean; 922 web unit tests pass (the only failures in the suite are the two pre-existing upstream theme tests, which fail identically on plain main). Signed-off-by: zjncs <18910855655@163.com> --- web/e2e/authoring-flow.spec.ts | 370 ++++++++++++++++++ web/src/api/client.ts | 168 ++++++++ web/src/api/types.ts | 120 ++++++ web/src/app/router.tsx | 35 ++ .../features/authoring/binary-file.test.ts | 27 ++ web/src/features/authoring/binary-file.ts | 56 +++ .../features/authoring/draft-file-editor.tsx | 352 +++++++++++++++++ web/src/features/authoring/event-console.tsx | 72 ++++ web/src/features/authoring/finding-card.tsx | 142 +++++++ .../features/authoring/merge-events.test.ts | 45 +++ web/src/features/authoring/merge-events.ts | 40 ++ .../features/authoring/preview-patch.test.ts | 38 ++ web/src/features/authoring/preview-patch.ts | 66 ++++ .../features/authoring/run-status-badge.tsx | 25 ++ .../authoring/runtime-binding-form.tsx | 193 +++++++++ .../authoring/use-validation-stream.test.ts | 149 +++++++ .../authoring/use-validation-stream.ts | 128 ++++++ web/src/i18n/locales/en.json | 151 ++++++- web/src/i18n/locales/ru.json | 151 ++++++- web/src/i18n/locales/zh.json | 151 ++++++- web/src/pages/authoring/draft-detail.tsx | 289 ++++++++++++++ web/src/pages/authoring/drafts.tsx | 281 +++++++++++++ web/src/pages/authoring/run-detail.tsx | 208 ++++++++++ web/src/pages/dashboard.tsx | 3 +- web/vite.config.ts | 5 +- 25 files changed, 3259 insertions(+), 6 deletions(-) create mode 100644 web/e2e/authoring-flow.spec.ts create mode 100644 web/src/features/authoring/binary-file.test.ts create mode 100644 web/src/features/authoring/binary-file.ts create mode 100644 web/src/features/authoring/draft-file-editor.tsx create mode 100644 web/src/features/authoring/event-console.tsx create mode 100644 web/src/features/authoring/finding-card.tsx create mode 100644 web/src/features/authoring/merge-events.test.ts create mode 100644 web/src/features/authoring/merge-events.ts create mode 100644 web/src/features/authoring/preview-patch.test.ts create mode 100644 web/src/features/authoring/preview-patch.ts create mode 100644 web/src/features/authoring/run-status-badge.tsx create mode 100644 web/src/features/authoring/runtime-binding-form.tsx create mode 100644 web/src/features/authoring/use-validation-stream.test.ts create mode 100644 web/src/features/authoring/use-validation-stream.ts create mode 100644 web/src/pages/authoring/draft-detail.tsx create mode 100644 web/src/pages/authoring/drafts.tsx create mode 100644 web/src/pages/authoring/run-detail.tsx diff --git a/web/e2e/authoring-flow.spec.ts b/web/e2e/authoring-flow.spec.ts new file mode 100644 index 000000000..86ce2c662 --- /dev/null +++ b/web/e2e/authoring-flow.spec.ts @@ -0,0 +1,370 @@ +import crypto from 'node:crypto' +import { expect, test, type APIResponse, type Page } from '@playwright/test' +import { setEnglishLocale } from './helpers/auth-fixtures' +import { registerSession } from './helpers/session' +import { E2eTestDataBuilder } from './helpers/test-data-builder' +import { csrfHeaders } from './helpers/csrf' + +/** + * End-to-end coverage for the skill authoring workbench against the real API: + * draft creation, file editing (text + binary assets), runtime binding, + * validation runs with the fix-preview loop, and the submit gate. Assertions + * ride on durable UI state (button enablement, badges, response bodies) rather + * than toasts so they do not race the toast auto-dismiss. + */ + +interface ApiEnvelope { + code: number + msg?: string + data: T +} + +interface DraftSummary { + id: number + name: string +} + +interface DraftFileSummaryPayload { + path: string + sha256: string + size: number + contentType?: string +} + +// Minimal 1×1 transparent PNG used for the binary-asset upload test. +const PNG_BUFFER = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', + 'base64', +) + +function uniqueSkillName(prefix: string): string { + return `${prefix}-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` +} + +function validSkillMd(name: string): string { + return `--- +name: ${name} +description: greets the caller with a fixed message +--- + +# ${name} + +## Overview + +greets the caller with a fixed message +` +} + +function brokenSkillMd(name: string): string { + return `--- +name: ${name} +--- + +# ${name} +` +} + +const GREET_SCRIPT = 'echo hello from authoring e2e\n' + +const VALIDATION_YAML = `version: 1 +tasks: + - name: greet + description: script prints the greeting + type: script + script: scripts/greet.sh + args: [] + timeoutMs: 30000 + assertions: + - type: exit_code + equals: 0 + - type: stdout_contains + value: hello from authoring e2e +` + +async function createDraftViaApi(page: Page, namespaceSlug: string, name: string): Promise { + const response = await page.context().request.post('/api/web/authoring/drafts', { + data: { namespaceSlug, name }, + headers: await csrfHeaders(page), + }) + const body = await response.json() as ApiEnvelope + expect(body.code, `draft creation failed: ${body.msg ?? 'unknown error'}`).toBe(0) + return body.data.id +} + +async function deleteDraftViaApi(page: Page, draftId: number): Promise { + try { + await page.context().request.delete(`/api/web/authoring/drafts/${draftId}`, { + headers: await csrfHeaders(page), + }) + } catch { + // Best-effort cleanup for E2E environments. + } +} + +function waitForDraftFileRead(page: Page, path: string, draftId?: number): Promise { + // draftId is optional so the waiter can be registered BEFORE navigating to + // the draft page — the editor fetches the selected file on mount, and a + // waiter registered after navigation can miss the response entirely. + return page.waitForResponse( + (response) => { + const url = response.url() + return response.request().method() === 'GET' + && url.includes('/api/web/authoring/drafts/') + && url.includes('/files/content') + && url.includes(`path=${encodeURIComponent(path)}`) + && (draftId === undefined + || url.includes(`/api/web/authoring/drafts/${draftId}/files/content`)) + }, + ) +} + +function waitForDraftFileSave(page: Page, draftId: number): Promise { + return page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/files`) + && !response.url().includes('/runtime'), + ) +} + +/** Creates a text file through the workbench UI and fills it with `content`. */ +async function createFileViaUi(page: Page, draftId: number, path: string, content: string): Promise { + await page.locator('input[placeholder="scripts/run.py"]').fill(path) + // register before the click: the editor swap triggers a content read of the + // new (empty) file once the create PUT resolves, and waiting for that read + // keeps the fill below from being overwritten by the fetch resolving late. + const newFileRead = waitForDraftFileRead(page, path, draftId) + await page.getByRole('button', { name: 'Create file' }).click() + await newFileRead + await expect(page.getByTestId('draft-file-list')).toContainText(path) + const editor = page.getByTestId('file-editor') + await editor.fill(content) + const saveResponse = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await saveResponse).status()).toBe(200) +} + +test.describe('Authoring Flow UI (Real API)', () => { + test.beforeEach(async ({ page }, testInfo) => { + await setEnglishLocale(page) + await registerSession(page, testInfo) + }) + + test('create → edit → bind → validate → submit through the workbench UI', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-ui') + + // --- create the draft from the workbench list + await page.goto('/dashboard/authoring') + await expect(page.getByRole('heading', { name: 'Skill authoring' })).toBeVisible() + await page.getByTestId('create-draft').click() + const createDialog = page.getByRole('dialog') + await expect(createDialog).toBeVisible() + await createDialog.getByTestId('draft-namespace').click() + const namespaceOption = page.getByRole('option', { name: namespace.displayName }).first() + await namespaceOption.evaluate((element) => { + element.scrollIntoView({ block: 'center' }) + element.click() + }) + await expect(createDialog.getByTestId('draft-namespace')).toContainText(namespace.displayName) + await createDialog.getByTestId('draft-name').fill(skillName) + await createDialog.getByTestId('submit-create-draft').click() + + const draftLink = page.getByRole('link', { name: skillName, exact: true }) + await expect(draftLink).toBeVisible({ timeout: 15_000 }) + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md') + await draftLink.click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + draftId = Number(page.url().split('/').pop()) + expect(Number.isFinite(draftId)).toBeTruthy() + + // --- files tab: replace the scaffold, add the script and validation spec + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + // the freshly created draft serves its scaffold immediately (regression + // guard: creation must persist content, not just the metadata row) + expect((await skillMdRead).status()).toBe(200) + await expect(editor).toHaveValue(/name: /) + await editor.fill(validSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + await createFileViaUi(page, draftId, 'scripts/greet.sh', GREET_SCRIPT) + await createFileViaUi(page, draftId, 'validation.yaml', VALIDATION_YAML) + await expect(page.getByTestId('draft-file-list')).toContainText('SKILL.md') + + // --- runtime binding tab: keep the default local-script/sh binding + await page.getByRole('tab', { name: 'Runtime binding' }).click() + await expect(page.getByTestId('agent-type')).toBeVisible() + await expect(page.locator('#interpreter')).toHaveValue('sh') + const bindingSave = page.waitForResponse( + (response) => + response.request().method() === 'PUT' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runtime`), + ) + await page.getByTestId('save-binding').click() + expect((await bindingSave).status()).toBe(200) + + // --- validate: the run should pass all three layers + await page.getByRole('tab', { name: 'Files' }).click() + const runStart = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/runs`), + ) + await page.getByTestId('start-run').click() + expect((await runStart).status()).toBe(200) + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + await expect(page.getByTestId('task-count')).toHaveText('1') + // the behavior task's stdout lands in the event console + await expect(page.getByText('hello from authoring e2e')).toBeVisible() + + // --- back to the draft and submit through the gated dialog + await page.getByRole('link', { name: 'Back to draft' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await expect(page.getByText('Validated at revision')).toBeVisible({ timeout: 15_000 }) + const submitButton = page.getByTestId('submit-draft') + await expect(submitButton).toBeEnabled() + await submitButton.click() + const submitDialog = page.getByTestId('submit-dialog') + await expect(submitDialog).toBeVisible() + const submitResponsePromise = page.waitForResponse( + (response) => + response.request().method() === 'POST' + && response.url().includes(`/api/web/authoring/drafts/${draftId}/submit`), + { timeout: 120_000 }, + ) + await submitDialog.getByRole('button', { name: 'Submit', exact: true }).click() + const submitResponse = await submitResponsePromise + expect(submitResponse.status()).toBe(200) + const submitBody = await submitResponse.json() as ApiEnvelope<{ skillId: number }> + expect(submitBody.code, `submit failed: ${submitBody.msg ?? 'unknown error'}`).toBe(0) + expect(submitBody.data.skillId).toBeTruthy() + await expect(page.getByTestId('submit-draft')).toBeDisabled() + await expect(page.getByTestId('submit-draft')).toContainText('Submitted') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('failing validation shows a previewable fix; apply → re-validate succeeds', async ({ page }, testInfo) => { + test.setTimeout(180_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-fix') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + // break the scaffold: drop the required description field + const skillMdRead = waitForDraftFileRead(page, 'SKILL.md', draftId) + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + const editor = page.getByTestId('file-editor') + await expect(editor).toBeVisible() + await skillMdRead + await editor.fill(brokenSkillMd(skillName)) + const skillMdSave = waitForDraftFileSave(page, draftId) + await page.getByTestId('save-file').click() + expect((await skillMdSave).status()).toBe(200) + + // validate → fails with a finding that carries a suggested fix + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Failed', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).not.toHaveText('0') + const finding = page.locator('[data-testid^="finding-"]', { + hasText: 'FRONTMATTER_FIELD_MISSING', + }).first() + await expect(finding).toBeVisible({ timeout: 15_000 }) + + // preview the suggested patch before applying it + await finding.getByTestId('suggestion-toggle').click() + const diffPreview = finding.locator('pre') + await expect(diffPreview).toBeVisible() + await expect(diffPreview).toContainText('description:') + + // two-step confirm: apply, then watch the finding flip to Applied + await finding.getByTestId('apply-start').click() + await finding.getByTestId('apply-confirm').click() + await expect(finding.getByTestId('finding-status')).toContainText('Applied', { timeout: 15_000 }) + + // one click back to the workbench, re-validate, and the run passes + await page.getByRole('button', { name: 'Fix and re-validate' }).click() + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + await page.getByTestId('start-run').click() + await expect(page.getByTestId('run-status')).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('run-status')).toHaveText('Succeeded', { timeout: 90_000 }) + await expect(page.getByTestId('error-count')).toHaveText('0') + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) + + test('uploads a binary asset that shows read-only with byte-accurate metadata', async ({ page }, testInfo) => { + test.setTimeout(120_000) + const builder = new E2eTestDataBuilder(page, testInfo) + await builder.init() + let draftId: number | undefined + try { + const namespace = await builder.ensureWritableNamespace() + const skillName = uniqueSkillName('authoring-binary') + draftId = await createDraftViaApi(page, namespace.slug, skillName) + + await page.goto(`/dashboard/authoring/${draftId}`) + await expect(page.getByRole('heading', { name: skillName })).toBeVisible() + + await page.getByTestId('upload-file-button').click() + const uploadDialog = page.getByRole('dialog') + await expect(uploadDialog).toBeVisible() + await uploadDialog.getByTestId('upload-file-input').setInputFiles({ + name: 'logo.png', + mimeType: 'image/png', + buffer: PNG_BUFFER, + }) + // choosing a file prefills the target draft path + await expect(uploadDialog.getByTestId('upload-path-input')).toHaveValue('assets/logo.png') + const uploadSave = waitForDraftFileSave(page, draftId) + await uploadDialog.getByTestId('confirm-upload').click() + expect((await uploadSave).status()).toBe(200) + + // the asset appears in the file list and opens the read-only binary panel + await page.getByTestId('draft-file-list').getByRole('button', { name: 'assets/logo.png', exact: true }).click() + const panel = page.getByTestId('binary-file-panel') + await expect(panel).toBeVisible() + await expect(panel).toContainText('assets/logo.png') + await expect(panel).toContainText('image/png') + await expect(panel).toContainText(`${PNG_BUFFER.length} B`) + await expect(panel.getByTestId('replace-file')).toBeVisible() + await expect(panel.getByTestId('delete-file')).toBeVisible() + + // the stored bytes round-trip byte-accurately (sha256 over the API-reported content) + const filesResponse = await page.context().request.get(`/api/web/authoring/drafts/${draftId}/files`) + const files = await filesResponse.json() as ApiEnvelope + const uploaded = files.data.find((file) => file.path === 'assets/logo.png') + expect(uploaded?.size).toBe(PNG_BUFFER.length) + expect(uploaded?.contentType).toBe('image/png') + expect(uploaded?.sha256).toBe(crypto.createHash('sha256').update(PNG_BUFFER).digest('hex')) + } finally { + if (draftId !== undefined) { + await deleteDraftViaApi(page, draftId) + } + await builder.cleanup() + } + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f92af8763..da2c755d5 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -62,6 +62,15 @@ import type { BatchMemberResponse, SkillSuite, SkillSuiteDraftInput, + AuthoringDraft, + DraftFileSummary, + DraftFileContent, + SaveDraftFileOutcome, + RuntimeBindingInfo, + ValidationRunInfo, + ValidationEventInfo, + ValidationFindingInfo, + SubmitDraftOutcome, } from './types' import { ApiError } from '@/shared/lib/api-error' import i18n from '@/i18n/config' @@ -2281,3 +2290,162 @@ export const notificationApi = { }) }, } + +export const authoringApi = { + async listDrafts(): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`) + }, + + async createDraft(request: { namespaceSlug: string, name: string, requirement?: string }): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify(request), + }) + }, + + async getDraft(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`) + }, + + async deleteDraft(draftId: number): Promise { + await fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}`, { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }) + }, + + async listFiles(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`) + }, + + async readFile(draftId: number, path: string): Promise { + const query = new URLSearchParams({ path }) + return fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files/content?${query.toString()}`, + ) + }, + + async saveFile( + draftId: number, + request: { path: string, content: string, contentType?: string, expectedRevision?: number, base64?: boolean }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/files`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + path: request.path, + content: request.content, + encoding: request.base64 ? 'base64' : undefined, + contentType: request.contentType, + expectedRevision: request.expectedRevision, + }), + }) + }, + + async deleteFile(draftId: number, path: string, expectedRevision?: number): Promise { + const query = new URLSearchParams({ path }) + if (expectedRevision !== undefined) { + query.set('expectedRevision', String(expectedRevision)) + } + await fetchJson( + `${WEB_API_PREFIX}/authoring/drafts/${draftId}/files?${query.toString()}`, + { + method: 'DELETE', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async getRuntimeBinding(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`) + }, + + async saveRuntimeBinding( + draftId: number, + request: { agentType: string, config?: Record, toolAllowlist?: string[], mcpServers?: Record[] }, + ): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runtime`, { + method: 'PUT', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ + agentType: request.agentType, + config: request.config ?? {}, + toolAllowlist: request.toolAllowlist ?? [], + mcpServers: request.mcpServers ?? [], + }), + }) + }, + + async startRun(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listRuns(draftId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/runs`) + }, + + async getRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}`) + }, + + async cancelRun(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/cancel`, { + method: 'POST', + headers: await ensureCsrfHeaders(), + }) + }, + + async listEvents(runId: number, afterSeq?: number): Promise { + const query = afterSeq === undefined ? '' : `?afterSeq=${afterSeq}` + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/events${query}`) + }, + + async listFindings(runId: number): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/runs/${runId}/findings`) + }, + + async applyFix(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/apply`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async dismissFinding(runId: number, findingId: number): Promise { + return fetchJson( + `${WEB_API_PREFIX}/authoring/runs/${runId}/findings/${findingId}/dismiss`, + { + method: 'POST', + headers: await ensureCsrfHeaders(), + }, + ) + }, + + async submitDraft(draftId: number, visibility: 'PRIVATE' | 'PUBLIC' = 'PRIVATE'): Promise { + return fetchJson(`${WEB_API_PREFIX}/authoring/drafts/${draftId}/submit`, { + method: 'POST', + headers: await ensureCsrfHeaders({ + 'Content-Type': 'application/json', + }), + body: JSON.stringify({ visibility }), + }) + }, + + /** Absolute URL for the SSE stream; EventSource resumes from the browser-managed Last-Event-ID. */ + validationStreamUrl(runId: number): string { + return buildApiUrl(`${WEB_API_PREFIX}/authoring/runs/${runId}/events/stream`) + }, +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 7b95767b0..8f18389e3 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -883,3 +883,123 @@ export type NamespaceInvitationHistory = Required, 'items'> & { items: NamespaceInvitationHistory[] } + +// Authoring workbench types (draft create/edit/validate/submit flow) +export interface AuthoringDraft { + id: number + namespaceId: number + name: string + requirement?: string + revision: number + contentDigest: string + validated: boolean + validatedRevision?: number + validatedRunId?: number + submittedSkillId?: number + submittedVersionId?: number + submittedAt?: string + createdAt: string + updatedAt: string +} + +export interface DraftFileSummary { + id?: number + path: string + sha256: string + size: number + contentType?: string + updatedAt?: string +} + +export interface DraftFileContent { + path: string + sha256: string + size: number + contentType?: string + content: string +} + +export interface SaveDraftFileOutcome { + draft: AuthoringDraft + file: DraftFileSummary + created: boolean + revisionAdvanced: boolean +} + +export interface RuntimeBindingInfo { + // null while the draft has no saved binding yet (the form falls back to + // its local-script default) + agentType: string | null + config: Record + toolAllowlist: string[] + mcpServers: Record[] + updatedAt?: string +} + +export type ValidationRunStatus = + | 'QUEUED' + | 'PREPARING' + | 'RUNNING' + | 'SUCCEEDED' + | 'FAILED' + | 'CANCELLED' + | 'TIMED_OUT' + +export interface ValidationRunInfo { + id: number + draftId: number + draftRevision: number + status: ValidationRunStatus + cancelRequested: boolean + active: boolean + terminal: boolean + errorCount: number + warningCount: number + triggeredBy: string + startedAt?: string + finishedAt?: string + createdAt: string + summary: Record +} + +export interface ValidationEventInfo { + seq: number + type: string + phase?: string + payload: Record + createdAt?: string +} + +export interface FilePatch { + filePath: string + oldSha256?: string + oldValue?: string + newValue?: string +} + +export interface FixSuggestionInfo { + description?: string + patches?: FilePatch[] +} + +export interface ValidationFindingInfo { + id: number + runId: number + layer: 'STRUCTURE' | 'CONFIG' | 'BEHAVIOR' + ruleCode: string + severity: 'ERROR' | 'WARNING' + filePath?: string + location?: string + message: string + suggestion?: FixSuggestionInfo | null + status: 'OPEN' | 'APPLIED' | 'DISMISSED' + appliedRevision?: number + createdAt: string +} + +export interface SubmitDraftOutcome { + skillId: number + versionId: number + slug: string + version: string +} diff --git a/web/src/app/router.tsx b/web/src/app/router.tsx index d3b216493..4efeaa703 100644 --- a/web/src/app/router.tsx +++ b/web/src/app/router.tsx @@ -193,6 +193,17 @@ const MyStarsPage = createLazyRouteComponent(() => import('@/pages/dashboard/sta const MySubscriptionsPage = createLazyRouteComponent(() => import('@/pages/dashboard/subscriptions'), 'MySubscriptionsPage', dashboardRouteOptions) const NotificationsPage = createLazyRouteComponent(() => import('@/pages/notifications'), 'NotificationsPage') const TokensPage = createLazyRouteComponent(() => import('@/pages/dashboard/tokens'), 'TokensPage', dashboardRouteOptions) +const AuthoringDraftsPage = createLazyRouteComponent(() => import('@/pages/authoring/drafts'), 'DraftsPage', dashboardRouteOptions) +const AuthoringDraftDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/draft-detail'), + 'DraftDetailPage', + dashboardRouteOptions, +) +const AuthoringRunDetailPage = createLazyRouteComponent( + () => import('@/pages/authoring/run-detail'), + 'RunDetailPage', + dashboardRouteOptions, +) const CliAuthPage = createLazyRouteComponent(() => import('@/pages/cli-auth'), 'CliAuthPage') const SecuritySettingsPage = createLazyRouteComponent( () => import('@/pages/settings/security'), @@ -686,6 +697,27 @@ const dashboardTokensRoute = createRoute({ component: TokensPage, }) +const authoringDraftsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring', + beforeLoad: requireAuth, + component: AuthoringDraftsPage, +}) + +const authoringDraftDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId', + beforeLoad: requireAuth, + component: AuthoringDraftDetailPage, +}) + +const authoringRunDetailRoute = createRoute({ + getParentRoute: () => rootRoute, + path: 'dashboard/authoring/$draftId/runs/$runId', + beforeLoad: requireAuth, + component: AuthoringRunDetailPage, +}) + const cliAuthRoute = createRoute({ getParentRoute: () => rootRoute, path: 'cli/auth', @@ -816,6 +848,9 @@ const routeTree = rootRoute.addChildren([ dashboardSubscriptionsRoute, dashboardNotificationsRoute, dashboardTokensRoute, + authoringDraftsRoute, + authoringDraftDetailRoute, + authoringRunDetailRoute, cliAuthRoute, deviceAuthRoute, settingsSecurityRoute, diff --git a/web/src/features/authoring/binary-file.test.ts b/web/src/features/authoring/binary-file.test.ts new file mode 100644 index 000000000..9e076707d --- /dev/null +++ b/web/src/features/authoring/binary-file.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { formatBytes, isBinaryDraftPath } from './binary-file' + +describe('isBinaryDraftPath', () => { + it('marks image and office extensions as binary', () => { + expect(isBinaryDraftPath('assets/logo.png')).toBe(true) + expect(isBinaryDraftPath('assets/logo.PNG')).toBe(true) + expect(isBinaryDraftPath('docs/report.pdf')).toBe(true) + expect(isBinaryDraftPath('assets/sheet.xlsx')).toBe(true) + }) + + it('keeps text formats editable', () => { + expect(isBinaryDraftPath('SKILL.md')).toBe(false) + expect(isBinaryDraftPath('references/metrics.md')).toBe(false) + expect(isBinaryDraftPath('assets/diagram.svg')).toBe(false) + expect(isBinaryDraftPath('scripts/greet.sh')).toBe(false) + expect(isBinaryDraftPath('validation.yaml')).toBe(false) + }) +}) + +describe('formatBytes', () => { + it('formats byte sizes for humans', () => { + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(2048)).toBe('2.0 KB') + expect(formatBytes(3 * 1024 * 1024)).toBe('3.0 MB') + }) +}) diff --git a/web/src/features/authoring/binary-file.ts b/web/src/features/authoring/binary-file.ts new file mode 100644 index 000000000..5cb9b65b0 --- /dev/null +++ b/web/src/features/authoring/binary-file.ts @@ -0,0 +1,56 @@ +/** + * Binary draft files (images, office documents, PDFs) round-trip through the + * API as base64 and cannot be edited in the plain-text editor; the workbench + * shows them read-only with an upload-to-replace action instead. + */ + +const BINARY_EXTENSIONS = new Set([ + '.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', + '.pdf', '.doc', '.xls', '.ppt', '.docx', '.xlsx', '.pptx', +]) + +export function isBinaryDraftPath(path: string): boolean { + const lower = path.toLowerCase() + for (const extension of BINARY_EXTENSIONS) { + if (lower.endsWith(extension)) { + return true + } + } + return false +} + +export function formatBytes(size: number): string { + if (size < 1024) { + return `${size} B` + } + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(1)} KB` + } + return `${(size / (1024 * 1024)).toFixed(1)} MB` +} + +/** Reads a browser File as base64 (no data: prefix) for the API's base64 encoding. */ +export function fileToBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onerror = () => reject(reader.error ?? new Error('failed to read file')) + reader.onload = () => { + const buffer = reader.result + if (!(buffer instanceof ArrayBuffer)) { + reject(new Error('failed to read file')) + return + } + resolve(bytesToBase64(new Uint8Array(buffer))) + } + reader.readAsArrayBuffer(file) + }) +} + +function bytesToBase64(bytes: Uint8Array): string { + let binary = '' + const chunkSize = 0x8000 + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} diff --git a/web/src/features/authoring/draft-file-editor.tsx b/web/src/features/authoring/draft-file-editor.tsx new file mode 100644 index 000000000..d54cd1b3f --- /dev/null +++ b/web/src/features/authoring/draft-file-editor.tsx @@ -0,0 +1,352 @@ +import { useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { FilePlus2, Save, Trash2, Upload } from 'lucide-react' +import { authoringApi } from '@/api/client' +import type { DraftFileSummary } from '@/api/types' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Textarea } from '@/shared/ui/textarea' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog' +import { toast } from '@/shared/lib/toast' +import { fileToBase64, formatBytes, isBinaryDraftPath } from './binary-file' + +/** + * Draft file editor: a file picker plus a plain-text editor for text formats + * and an upload-to-replace view for binary assets (images, documents). Saves + * go through the draft's optimistic-concurrency revision so concurrent edits + * surface as errors instead of silently overwriting each other. + */ +export function DraftFileEditor({ + draftId, + files, + revision, + onSaved, + onDeleted, +}: { + draftId: number + files: DraftFileSummary[] + revision: number + onSaved: (path: string) => void + onDeleted: (path: string) => void +}) { + const { t } = useTranslation() + const sortedFiles = useMemo( + () => [...files].sort((a, b) => a.path.localeCompare(b.path)), + [files], + ) + const [selectedPath, setSelectedPath] = useState(null) + const [content, setContent] = useState('') + const [dirty, setDirty] = useState(false) + const [saving, setSaving] = useState(false) + const [newPath, setNewPath] = useState('') + const [creating, setCreating] = useState(false) + + const [uploadOpen, setUploadOpen] = useState(false) + const [uploadFile, setUploadFile] = useState(null) + const [uploadPath, setUploadPath] = useState('') + const [uploading, setUploading] = useState(false) + + const selected = sortedFiles.find((file) => file.path === selectedPath) ?? null + const binarySelected = selectedPath != null && isBinaryDraftPath(selectedPath) + + useEffect(() => { + if (sortedFiles.length === 0) { + setSelectedPath(null) + return + } + if (selectedPath == null) { + setSelectedPath(sortedFiles[0].path) + } + }, [sortedFiles, selectedPath]) + + useEffect(() => { + if (selectedPath == null || isBinaryDraftPath(selectedPath)) { + return + } + let cancelled = false + authoringApi + .readFile(draftId, selectedPath) + .then((file) => { + if (!cancelled) { + setContent(file.content) + setDirty(false) + } + }) + .catch((error) => { + if (!cancelled) { + toast.error(error instanceof Error ? error.message : String(error)) + } + }) + return () => { + cancelled = true + } + }, [draftId, selectedPath]) + + const save = async () => { + if (selectedPath == null) { + return + } + setSaving(true) + try { + await authoringApi.saveFile(draftId, { + path: selectedPath, + content, + expectedRevision: revision, + }) + setDirty(false) + onSaved(selectedPath) + toast.success(t('authoring.files.saved')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setSaving(false) + } + } + + const createFile = async () => { + const path = newPath.trim() + if (!path) { + return + } + setCreating(true) + try { + const outcome = await authoringApi.saveFile(draftId, { + path, + content: '', + expectedRevision: revision, + }) + setNewPath('') + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.created') : t('authoring.files.exists'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setCreating(false) + } + } + + const remove = async () => { + if (selectedPath == null) { + return + } + const removedPath = selectedPath + try { + await authoringApi.deleteFile(draftId, removedPath, revision) + // pick the next selection from the files we know remain — the list prop + // is still stale at this point, so the removed path itself must be + // excluded explicitly + const next = sortedFiles.find((file) => file.path !== removedPath)?.path ?? null + onDeleted(removedPath) + setSelectedPath(next) + toast.success(t('authoring.files.deleted')) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } + } + + const openUpload = (prefillPath?: string) => { + setUploadFile(null) + setUploadPath(prefillPath ?? '') + setUploadOpen(true) + } + + const chooseUploadFile = (file: File | null) => { + setUploadFile(file) + if (file && !uploadPath.trim()) { + setUploadPath(`assets/${file.name}`) + } + } + + const upload = async () => { + if (!uploadFile) { + return + } + const path = uploadPath.trim() + if (!path) { + return + } + setUploading(true) + try { + const base64 = await fileToBase64(uploadFile) + const outcome = await authoringApi.saveFile(draftId, { + path, + content: base64, + base64: true, + contentType: uploadFile.type || undefined, + expectedRevision: revision, + }) + setUploadOpen(false) + onSaved(path) + setSelectedPath(path) + toast.success( + outcome.created ? t('authoring.files.uploaded') : t('authoring.files.replaced'), + ) + } catch (error) { + toast.error(error instanceof Error ? error.message : String(error)) + } finally { + setUploading(false) + } + } + + return ( +

+
+
    + {sortedFiles.map((file) => ( +
  • + +
  • + ))} +
+
+ setNewPath(event.target.value)} + placeholder="scripts/run.py" + className="h-8 font-mono text-xs" + onKeyDown={(event) => { + if (event.key === 'Enter') { + void createFile() + } + }} + /> + +
+ +
+ +
+ {selectedPath == null ? ( +

{t('authoring.files.empty')}

+ ) : binarySelected && selected ? ( +
+

{selected.path}

+
+
{t('authoring.files.size')}
+
{formatBytes(selected.size)}
+
sha256
+
+ {selected.sha256.slice(0, 16)}… +
+
{t('authoring.files.contentType')}
+
{selected.contentType || '—'}
+
+

{t('authoring.files.binaryHint')}

+
+ + +
+
+ ) : ( + <> +