feat(skill): endpoint reachability checks in the skill requirement gate

Skills backed by a network service could show ready while the service
was unreachable from the current deployment (intranet-only address,
wrong network segment) — the failure only surfaced mid-task.

- endpoint requirement type: TCP-connect probe (1.5s timeout) of the
  declared service address; accepts http(s)://host[:port][/path],
  host:port, and bare-host forms
- URL-shaped check targets infer the endpoint type without an explicit
  declaration; unparseable targets report UNKNOWN instead of missing
- probe results cached 60s per host:port so refresh passes stay cheap
  and a VPN connect is picked up within a minute
- unreachable endpoints surface as setup-needed on the skill card,
  pre-flight requirement rows, and the agent-facing catalog
This commit is contained in:
matevip 2026-07-22 14:38:37 +08:00
parent 5e188cd77b
commit aa0e249ce4
3 changed files with 264 additions and 4 deletions

View File

@ -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

View File

@ -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<String, Boolean> 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:
* <ul>
* <li>{@code http(s)://host[:port][/path]} port defaults to the
* scheme's standard port when omitted</li>
* <li>{@code host:port}</li>
* <li>{@code host} port defaults to 80</li>
* </ul>
* 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;
}

View File

@ -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("不可达");
}
}