diff --git a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java index db83b392..749a4259 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/manifest/SkillManifest.java @@ -140,9 +140,13 @@ public class SkillManifest { public static class RequirementDef { /** Required: stable identifier referenced by {@code features[*].requires}. */ private String key; - /** binary | env_var | api_key */ + /** binary | env_var | api_key | endpoint */ private String type; - /** Probe target — for binary, the executable name; for env_var, the env name. */ + /** + * Probe target — for binary, the executable name; for env_var, the + * env name; for endpoint, the service address to TCP-probe + * ({@code http(s)://host[:port][/path]} or {@code host[:port]}). + */ private String check; /** Optional means it only blocks features that reference it explicitly. */ @Builder.Default diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java index eb8ec366..3f746514 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillDependencyChecker.java @@ -16,6 +16,9 @@ import vip.mate.tool.repository.ToolMapper; import java.io.BufferedReader; import java.io.InputStreamReader; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.URI; import java.time.Duration; import java.util.ArrayList; import java.util.List; @@ -26,7 +29,7 @@ import java.util.concurrent.TimeUnit; /** * 技能依赖检查器 - * 检查 commands / env / tools / platforms 依赖是否满足 + * 检查 commands / env / tools / platforms / endpoints 依赖是否满足 */ @Slf4j @Service @@ -59,6 +62,23 @@ public class SkillDependencyChecker { .maximumSize(256) .build(); + /** Timeout for a single endpoint reachability probe (TCP connect). */ + private static final int ENDPOINT_PROBE_TIMEOUT_MS = 1500; + + /** + * 60s cache for endpoint reachability probes, keyed by {@code host:port}. + * Same rhythm as {@link #commandAvailability}: every active-skills + * refresh re-evaluates each feature requirement, and without caching, + * N skills declaring the same service address would each open a socket + * (blocking up to the probe timeout) per pass. Network state is also + * the most volatile requirement class, so a short TTL keeps the + * "service unreachable" verdict from going stale after a VPN connect. + */ + private final Cache endpointReachability = Caffeine.newBuilder() + .expireAfterWrite(Duration.ofSeconds(60)) + .maximumSize(256) + .build(); + /** * 检查依赖 */ @@ -195,6 +215,71 @@ public class SkillDependencyChecker { } } + // ==================== Endpoint reachability ==================== + + /** Parsed {@code host:port} target of an endpoint requirement. */ + record EndpointTarget(String host, int port) {} + + /** + * Parse an endpoint requirement's check target into {@code host:port}. + * Accepted forms: + * + * Returns {@code null} when the target is blank or unparseable, which + * the caller maps to {@code UNKNOWN} — a misdeclared manifest must not + * flip a skill to setup-needed. + */ + static EndpointTarget parseEndpointTarget(String target) { + if (target == null || target.isBlank()) return null; + String t = target.strip(); + try { + if (t.contains("://")) { + URI uri = URI.create(t); + String host = uri.getHost(); + if (host == null || host.isBlank()) return null; + int port = uri.getPort(); + if (port <= 0) { + port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; + } + return new EndpointTarget(host, port); + } + int colon = t.lastIndexOf(':'); + if (colon > 0 && colon < t.length() - 1) { + String maybePort = t.substring(colon + 1); + if (!maybePort.isEmpty() && maybePort.chars().allMatch(Character::isDigit)) { + return new EndpointTarget(t.substring(0, colon), Integer.parseInt(maybePort)); + } + } + if (t.contains("/") || t.contains(":")) return null; + return new EndpointTarget(t, 80); + } catch (Exception e) { + return null; + } + } + + private boolean isEndpointReachable(EndpointTarget target) { + String key = target.host() + ":" + target.port(); + Boolean cached = endpointReachability.getIfPresent(key); + if (cached != null) return cached; + boolean reachable = probeEndpoint(target); + endpointReachability.put(key, reachable); + return reachable; + } + + private boolean probeEndpoint(EndpointTarget target) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(target.host(), target.port()), ENDPOINT_PROBE_TIMEOUT_MS); + return true; + } catch (Exception e) { + log.debug("Endpoint probe failed for {}:{} — {}", target.host(), target.port(), e.getMessage()); + return false; + } + } + private static boolean isWindows() { return CURRENT_OS.equals("windows"); } @@ -221,7 +306,7 @@ public class SkillDependencyChecker { * probe regardless of how the manifest expressed it. {@code ANY} is the * fallback when the manifest doesn't declare a type — we infer. */ - public enum RequirementType { BINARY, ENV_VAR, API_KEY, ANY } + public enum RequirementType { BINARY, ENV_VAR, API_KEY, ENDPOINT, ANY } /** * Status for a single requirement after probing. @@ -263,6 +348,16 @@ public class SkillDependencyChecker { String value = System.getenv(target); yield (value != null && !value.isBlank()) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; } + case ENDPOINT -> { + // Reachability, not liveness: a TCP connect proves the + // current deployment can route to the service (intranet + // address + wrong network segment is the classic failure), + // without depending on the service answering a particular + // HTTP verb on its base path. + EndpointTarget ep = parseEndpointTarget(target); + if (ep == null) yield RequirementStatus.UNKNOWN; + yield isEndpointReachable(ep) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING; + } case ANY -> RequirementStatus.UNKNOWN; }; } catch (Exception e) { @@ -278,6 +373,7 @@ public class SkillDependencyChecker { case "binary" -> RequirementType.BINARY; case "env_var", "env" -> RequirementType.ENV_VAR; case "api_key", "key" -> RequirementType.API_KEY; + case "endpoint", "url", "service" -> RequirementType.ENDPOINT; default -> RequirementType.ANY; }; } @@ -289,6 +385,11 @@ public class SkillDependencyChecker { if (k.startsWith("env:")) return RequirementType.ENV_VAR; if (k.endsWith("_api_key") || k.endsWith("_key")) return RequirementType.API_KEY; } + // A check target that looks like a URL is an endpoint probe even + // without a declared type. + if (req.getCheck() != null && req.getCheck().contains("://")) { + return RequirementType.ENDPOINT; + } return RequirementType.ANY; } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillDependencyCheckerEndpointTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillDependencyCheckerEndpointTest.java new file mode 100644 index 00000000..07670950 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillDependencyCheckerEndpointTest.java @@ -0,0 +1,155 @@ +package vip.mate.skill.runtime; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import vip.mate.skill.manifest.SkillManifest; +import vip.mate.skill.runtime.SkillDependencyChecker.EndpointTarget; +import vip.mate.skill.runtime.SkillDependencyChecker.RequirementStatus; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Endpoint-type requirement: TCP reachability probes surface + * "service unreachable from this deployment" before an agent burns + * rounds discovering it mid-task. + */ +class SkillDependencyCheckerEndpointTest { + + private SkillDependencyChecker checker; + private ServerSocket listening; + + @BeforeEach + void setUp() { + // checkRequirement's ENDPOINT path never touches the mapper/registry. + checker = new SkillDependencyChecker(null, null); + } + + @AfterEach + void tearDown() throws IOException { + if (listening != null && !listening.isClosed()) listening.close(); + } + + private int openLocalPort() throws IOException { + listening = new ServerSocket(0, 1, InetAddress.getLoopbackAddress()); + return listening.getLocalPort(); + } + + /** A loopback port that is (almost certainly) closed: bind, read, release. */ + private int closedLocalPort() throws IOException { + try (ServerSocket s = new ServerSocket(0, 1, InetAddress.getLoopbackAddress())) { + return s.getLocalPort(); + } + } + + private SkillManifest.RequirementDef endpointReq(String check) { + return SkillManifest.RequirementDef.builder() + .key("meeting-api") + .type("endpoint") + .check(check) + .build(); + } + + // ==================== target parsing ==================== + + @Test + void parsesUrlWithExplicitPort() { + assertThat(SkillDependencyChecker.parseEndpointTarget("http://192.168.1.11:8181/DCMeeting/api/v2")) + .isEqualTo(new EndpointTarget("192.168.1.11", 8181)); + } + + @Test + void parsesUrlWithSchemeDefaultPorts() { + assertThat(SkillDependencyChecker.parseEndpointTarget("https://meeting.example.com/api")) + .isEqualTo(new EndpointTarget("meeting.example.com", 443)); + assertThat(SkillDependencyChecker.parseEndpointTarget("http://meeting.example.com")) + .isEqualTo(new EndpointTarget("meeting.example.com", 80)); + } + + @Test + void parsesHostPortAndBareHostShorthand() { + assertThat(SkillDependencyChecker.parseEndpointTarget("192.168.1.11:8181")) + .isEqualTo(new EndpointTarget("192.168.1.11", 8181)); + assertThat(SkillDependencyChecker.parseEndpointTarget("meeting.example.com")) + .isEqualTo(new EndpointTarget("meeting.example.com", 80)); + } + + @Test + void unparseableTargetsReturnNull() { + assertThat(SkillDependencyChecker.parseEndpointTarget(null)).isNull(); + assertThat(SkillDependencyChecker.parseEndpointTarget(" ")).isNull(); + assertThat(SkillDependencyChecker.parseEndpointTarget("host:notaport")).isNull(); + assertThat(SkillDependencyChecker.parseEndpointTarget("http://")).isNull(); + } + + // ==================== requirement probing ==================== + + @Test + void reachableEndpointIsSatisfied() throws IOException { + int port = openLocalPort(); + RequirementStatus st = checker.checkRequirement( + endpointReq("http://127.0.0.1:" + port + "/api/v2")); + assertThat(st).isEqualTo(RequirementStatus.SATISFIED); + } + + @Test + void unreachableEndpointIsMissing() throws IOException { + int port = closedLocalPort(); + RequirementStatus st = checker.checkRequirement( + endpointReq("http://127.0.0.1:" + port)); + assertThat(st).isEqualTo(RequirementStatus.MISSING); + } + + @Test + void unparseableEndpointIsUnknownNotMissing() { + // A misdeclared manifest must not flip the skill to setup-needed. + assertThat(checker.checkRequirement(endpointReq("http://"))) + .isEqualTo(RequirementStatus.UNKNOWN); + } + + @Test + void urlShapedCheckInfersEndpointTypeWithoutDeclaredType() throws IOException { + int port = closedLocalPort(); + SkillManifest.RequirementDef req = SkillManifest.RequirementDef.builder() + .key("meeting-api") + .check("http://127.0.0.1:" + port) + .build(); + // ANY would yield UNKNOWN; the URL-shaped check must probe and miss. + assertThat(checker.checkRequirement(req)).isEqualTo(RequirementStatus.MISSING); + } + + @Test + void probeResultIsCachedWithinTtl() throws IOException { + int port = openLocalPort(); + SkillManifest.RequirementDef req = endpointReq("http://127.0.0.1:" + port); + assertThat(checker.checkRequirement(req)).isEqualTo(RequirementStatus.SATISFIED); + listening.close(); + // Served from the 60s cache — no fresh probe against the closed port. + assertThat(checker.checkRequirement(req)).isEqualTo(RequirementStatus.SATISFIED); + } + + // ==================== feature gate integration ==================== + + @Test + void featureRequiringUnreachableEndpointIsSetupNeeded() throws IOException { + int port = closedLocalPort(); + SkillManifest.RequirementDef req = endpointReq("http://127.0.0.1:" + port); + SkillManifest.FeatureDef feature = SkillManifest.FeatureDef.builder() + .id("default") + .requires(List.of("meeting-api")) + .fallbackMessage("会议系统服务不可达,请检查网络/VPN") + .build(); + + var result = checker.checkFeature(feature, Map.of("meeting-api", req)); + + assertThat(result.getStatus()).isEqualTo("SETUP_NEEDED"); + assertThat(result.getMissing()).containsExactly("meeting-api"); + assertThat(result.getReason()).contains("不可达"); + } +}