mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +08:00
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:
parent
5e188cd77b
commit
aa0e249ce4
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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("不可达");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user