mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 19:23:42 +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 {
|
public static class RequirementDef {
|
||||||
/** Required: stable identifier referenced by {@code features[*].requires}. */
|
/** Required: stable identifier referenced by {@code features[*].requires}. */
|
||||||
private String key;
|
private String key;
|
||||||
/** binary | env_var | api_key */
|
/** binary | env_var | api_key | endpoint */
|
||||||
private String type;
|
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;
|
private String check;
|
||||||
/** Optional means it only blocks features that reference it explicitly. */
|
/** Optional means it only blocks features that reference it explicitly. */
|
||||||
@Builder.Default
|
@Builder.Default
|
||||||
|
|||||||
@ -16,6 +16,9 @@ import vip.mate.tool.repository.ToolMapper;
|
|||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.URI;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@ -26,7 +29,7 @@ import java.util.concurrent.TimeUnit;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 技能依赖检查器
|
* 技能依赖检查器
|
||||||
* 检查 commands / env / tools / platforms 依赖是否满足
|
* 检查 commands / env / tools / platforms / endpoints 依赖是否满足
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@ -59,6 +62,23 @@ public class SkillDependencyChecker {
|
|||||||
.maximumSize(256)
|
.maximumSize(256)
|
||||||
.build();
|
.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() {
|
private static boolean isWindows() {
|
||||||
return CURRENT_OS.equals("windows");
|
return CURRENT_OS.equals("windows");
|
||||||
}
|
}
|
||||||
@ -221,7 +306,7 @@ public class SkillDependencyChecker {
|
|||||||
* probe regardless of how the manifest expressed it. {@code ANY} is the
|
* probe regardless of how the manifest expressed it. {@code ANY} is the
|
||||||
* fallback when the manifest doesn't declare a type — we infer.
|
* 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.
|
* Status for a single requirement after probing.
|
||||||
@ -263,6 +348,16 @@ public class SkillDependencyChecker {
|
|||||||
String value = System.getenv(target);
|
String value = System.getenv(target);
|
||||||
yield (value != null && !value.isBlank()) ? RequirementStatus.SATISFIED : RequirementStatus.MISSING;
|
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;
|
case ANY -> RequirementStatus.UNKNOWN;
|
||||||
};
|
};
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@ -278,6 +373,7 @@ public class SkillDependencyChecker {
|
|||||||
case "binary" -> RequirementType.BINARY;
|
case "binary" -> RequirementType.BINARY;
|
||||||
case "env_var", "env" -> RequirementType.ENV_VAR;
|
case "env_var", "env" -> RequirementType.ENV_VAR;
|
||||||
case "api_key", "key" -> RequirementType.API_KEY;
|
case "api_key", "key" -> RequirementType.API_KEY;
|
||||||
|
case "endpoint", "url", "service" -> RequirementType.ENDPOINT;
|
||||||
default -> RequirementType.ANY;
|
default -> RequirementType.ANY;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -289,6 +385,11 @@ public class SkillDependencyChecker {
|
|||||||
if (k.startsWith("env:")) return RequirementType.ENV_VAR;
|
if (k.startsWith("env:")) return RequirementType.ENV_VAR;
|
||||||
if (k.endsWith("_api_key") || k.endsWith("_key")) return RequirementType.API_KEY;
|
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;
|
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