diff --git a/agent/manifest.json b/agent/manifest.json new file mode 100644 index 0000000..f640302 --- /dev/null +++ b/agent/manifest.json @@ -0,0 +1,11 @@ +{ + "repository": "oneclickvirt/backtrace", + "skill_name": "oneclickvirt-backtrace", + "mcp": true, + "files": { + "agent/profile.json": "c0051a351c446d4135642cda3af37d302bc83b9c74ddfafc1dc68545e41249d6", + "skills/oneclickvirt-backtrace/SKILL.md": "6627224a6ddd3637291d3cb03793ae29fe798561d38462674baa075940738213", + "skills/oneclickvirt-backtrace/SKILL_ZH.md": "88e3e6d9561f17c53e1b7af0d601c9c7878341a8c4f54256b56da92087c15472", + "agent/server.py": "d01416e6194095d90bf0d49ea29f7122969f0b74cf4d74ed0b1ce78753423a7d" + } +} diff --git a/agent/profile.json b/agent/profile.json new file mode 100644 index 0000000..6edb3dc --- /dev/null +++ b/agent/profile.json @@ -0,0 +1,39 @@ +{ + "repository": "oneclickvirt/backtrace", + "branch": "main", + "skill_name": "oneclickvirt-backtrace", + "purpose": "Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute.", + "notes": [ + "Read probe_help from the installed binary before assuming flags are supported. MCP returns native JSON when the program provides JSON, otherwise native text.", + "Keep partial/network errors distinct from confirmed test outcomes. Current release assets and their actual architecture are available through release_assets.", + "The ip-info preset requires a literal target IP. Do not pass a host, proxy, shell expression or a network range." + ], + "guide": "https://www.spiritlhl.net/guide/agent_integrations.html", + "releases": true, + "mcp": true, + "binary": "backtrace", + "asset_prefix": "backtrace", + "presets": { + "ip-info": { + "args": [ + "-json", + "-timeout", + "12s" + ], + "target": "-ip", + "timeout": 20 + } + }, + "benchmark": false, + "source_files": [ + "backtrace_install.sh", + "cmd/main.go", + "go.mod" + ], + "purpose_zh": "查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。", + "notes_zh": [ + "先用 probe_help 核对已安装程序的实际帮助。程序支持 JSON 时返回原生 JSON,否则返回原生文本。", + "区分部分结果、网络失败和确定结论;通过 release_assets 查询当前真实产物及架构。", + "ip-info 预设只接受单个真实 IP,不接受域名、代理、Shell 表达式或网段。" + ] +} diff --git a/agent/server.py b/agent/server.py new file mode 100644 index 0000000..704c5a8 --- /dev/null +++ b/agent/server.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +"""OneClickVirt repository MCP adapter. Python 3.9+, stdio, no extra packages.""" +import argparse +import ipaddress +import json +import os +from pathlib import Path +import re +import shutil +import signal +import subprocess +import sys +import tempfile +import urllib.error +import urllib.parse +import urllib.request + +VERSION = "1.0.0" +PROTOCOLS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25") +LIMIT = 1024 * 1024 + + +def schema(properties=None, required=()): + return {"type": "object", "properties": properties or {}, + "required": list(required), "additionalProperties": False} + + +def string(description="", **extra): + return dict(type="string", description=description, **extra) + + +def validate(value, spec, name="arguments"): + expected = spec.get("type") + if expected == "object": + if not isinstance(value, dict): + raise ValueError(name + " must be an object") + props = spec.get("properties", {}) + if set(spec.get("required", [])) - value.keys(): + raise ValueError(name + " has missing required fields") + if not spec.get("additionalProperties", True) and value.keys() - props.keys(): + raise ValueError(name + " has unknown fields") + for key, data in value.items(): + if key in props: + validate(data, props[key], key) + elif expected == "string": + maximum = min(512, spec.get("maxLength", 512)) + if not isinstance(value, str) or len(value) > maximum: + raise ValueError(name + " must be a string of at most " + str(maximum) + " characters") + if "pattern" in spec and not re.fullmatch(spec["pattern"], value): + raise ValueError(name + " has invalid characters") + elif expected == "integer": + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(name + " must be an integer") + if value < spec.get("minimum", value) or value > spec.get("maximum", value): + raise ValueError(name + " is outside the supported range") + if "enum" in spec and value not in spec["enum"]: + raise ValueError(name + " is not a supported choice") + + +def execute(argv, timeout=30, cwd=None, parse_json=True): + """Bound output, kill process groups on timeout, never invoke a shell.""" + if not argv or not shutil.which(argv[0]): + raise ValueError("Required local command is missing: " + (argv[0] if argv else "")) + with tempfile.TemporaryFile() as out, tempfile.TemporaryFile() as err: + proc = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=err, + start_new_session=os.name != "nt", cwd=cwd) + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + if os.name != "nt": + os.killpg(proc.pid, signal.SIGKILL) + else: + subprocess.run(["taskkill", "/PID", str(proc.pid), "/T", "/F"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False) + if proc.poll() is None: + proc.kill() + proc.wait() + raise ValueError("Command exceeded its time limit; no result is claimed") + out.seek(0) + err.seek(0) + stdout, stderr = out.read(LIMIT + 1), err.read(16385) + if proc.returncode: + raise ValueError("Command failed (exit %s): %s" % ( + proc.returncode, stderr[:16384].decode("utf-8", "replace"))) + if len(stdout) > LIMIT: + raise ValueError("Command result exceeds 1 MiB; narrow the request") + text = stdout.decode("utf-8", "replace").strip() + if not parse_json: + return text + try: + return json.loads(text) + except ValueError: + return text + + +def github_json(path): + headers = {"Accept": "application/vnd.github+json", "User-Agent": "oneclickvirt-mcp/" + VERSION} + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token: + headers["Authorization"] = "Bearer " + token + class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, response_headers, newurl): + redirected = super().redirect_request(req, fp, code, msg, response_headers, newurl) + if redirected is not None and urllib.parse.urlsplit(newurl).netloc != "api.github.com": + redirected.remove_header("Authorization") + return redirected + req = urllib.request.Request("https://api.github.com" + path, headers=headers) + try: + with urllib.request.build_opener(SafeRedirect()).open(req, timeout=25) as response: + data = response.read(8 * LIMIT + 1) + except urllib.error.HTTPError as exc: + raise ValueError("GitHub API returned %s; no release or asset is assumed" % exc.code) from None + if len(data) > 8 * LIMIT: + raise ValueError("GitHub response is too large") + return json.loads(data) + + +class Adapter: + def __init__(self, profile_path): + self.path = Path(profile_path).resolve() + self.profile = json.loads(self.path.read_text(encoding="utf-8")) + self.repo = self.profile["repository"] + if not re.fullmatch(r"oneclickvirt/[A-Za-z0-9_.-]+", self.repo): + raise ValueError("Invalid repository profile") + + def binary(self): + return os.environ.get("ONECLICKVIRT_MCP_BINARY") or self.profile.get("binary", "") + + def tools(self): + result = [] + def add(name, description, spec=None, write=False, external=False): + result.append({"name": name, "description": description, "inputSchema": spec or schema(), + "annotations": {"readOnlyHint": not write, "destructiveHint": write, + "idempotentHint": not write, "openWorldHint": external}}) + add("integration_info", "Show repository, supported operations, prerequisites and documentation.") + if self.profile.get("releases"): + add("release_assets", "Resolve a current published release and list its actual assets. No assumed architectures or tags.", + schema({"version": string("Exact release tag, or latest", pattern=r"[A-Za-z0-9_.+/-]{1,128}"), + "filter": string("Optional case-insensitive substring in asset names", maxLength=128)}), external=True) + backend = self.profile.get("runtime") + if backend: + add("list_instances", "List instances on this local runtime using its existing CLI.") + add("instance_status", "Read one instance's state without returning environment variables or cloud-init secrets.", + schema({"id": string(pattern=r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")}, ["id"])) + if os.environ.get("ONECLICKVIRT_MCP_ALLOW_WRITE") == "1": + add("instance_action", "Start, gracefully stop or restart an explicitly named instance. No delete or reinstall operation.", + schema({"id": string(pattern=r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}"), + "action": string(enum=["start", "stop", "restart"])}, ["id", "action"]), write=True) + if self.profile.get("presets"): + add("probe_help", "Read the installed probe's actual help output.") + add("run_probe", "Run one bounded preset with the existing local binary. Check integration_info for its resource and network effects.", + schema({"preset": string(enum=list(self.profile["presets"])), + "target": string("Only for a target-aware preset", pattern=r"[A-Za-z0-9][A-Za-z0-9.:_-]{0,252}")}, ["preset"]), + write=self.profile.get("benchmark", False), external=True) + if self.profile.get("network"): + add("network_status", "Read Linux IPv6 addresses and routes. Does not change network configuration.") + if self.profile.get("counter"): + add("counter_monthly", "Read an existing counter's monthly statistics without incrementing it.", + schema({"counter": string(pattern=r"[A-Za-z0-9_.-]{1,128}")}, ["counter"]), external=True) + return result + + def instances(self): + backend = self.profile["runtime"] + binary = self.binary() + if backend in ("docker", "podman", "containerd"): + raw = execute([binary, "ps", "-a", "--format", "{{json .}}"]) + if isinstance(raw, dict): + rows = [raw] + elif not raw: + rows = [] + else: + rows = [json.loads(line) for line in raw.splitlines()] + result = [] + for row in rows: + item = {key: row.get(key) for key in ("ID", "Names", "Image", "State", "Status")} + if isinstance(item["Names"], list): + item["Names"] = ",".join(item["Names"]) + result.append(item) + return result + if backend in ("incus", "lxd"): + rows = execute([binary, "list", "--format=json"]) + if not isinstance(rows, list): + raise ValueError("Runtime did not return an instance array") + return [{k: row.get(k) for k in ("name", "status", "type", "location")} for row in rows] + if backend == "pve": + return execute([binary, "get", "/cluster/resources", "--type", "vm", "--output-format", "json"]) + if backend == "qemu": + uri = os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system") + if not re.fullmatch(r"qemu:///(system|session)", uri): + raise ValueError("Only local libvirt system/session URIs are supported") + names = execute([binary, "-c", uri, "list", "--all", "--name"], parse_json=False) + return [{"name": name} for name in str(names).splitlines() if name] + if backend == "kubevirt": + namespace = os.environ.get("ONECLICKVIRT_KUBE_NAMESPACE", "default") + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", namespace): + raise ValueError("Invalid Kubernetes namespace") + data = execute([binary, "get", "virtualmachines", "--namespace", namespace, "-o", "json"]) + return [{"name": row["metadata"]["name"], "namespace": namespace, + "status": row.get("status", {})} for row in data["items"]] + raise ValueError("Unsupported runtime") + + def find_instance(self, identifier): + rows = self.instances() + for row in rows: + names = str(row.get("Names", "")).split(",") + if identifier in names + [str(row.get(k, "")) for k in ("name", "vmid", "ID")]: + return row + raise ValueError("Instance not found on the configured runtime") + + def call(self, name, args): + available = {t["name"]: t for t in self.tools()} + if name not in available: + raise ValueError("Unknown or disabled tool") + validate(args, available[name]["inputSchema"]) + if name == "integration_info": + return self.profile + if name == "release_assets": + repo = self.profile.get("release_repository", self.repo) + version = args.get("version", "latest") + suffix = "latest" if version == "latest" else "tags/" + urllib.parse.quote(version, safe="") + value = github_json("/repos/" + repo + "/releases/" + suffix) + if value.get("draft") or (version == "latest" and value.get("prerelease")): + raise ValueError("Requested release is not a stable published release") + match = args.get("filter", "").lower() + return {"repository": repo, "version": value["tag_name"], "url": value["html_url"], + "published_at": value["published_at"], "assets": [ + {k: a.get(k) for k in ("name", "size", "digest", "browser_download_url")} + for a in value["assets"] if match in a["name"].lower()]} + if name == "list_instances": + return self.instances() + if name == "instance_status": + row = self.find_instance(args["id"]) + if self.profile["runtime"] == "qemu": + uri = os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system") + row["state"] = execute([self.binary(), "-c", uri, "domstate", args["id"]]) + return row + if name == "instance_action": + row = self.find_instance(args["id"]) + backend, action, identifier = self.profile["runtime"], args["action"], args["id"] + binary = self.binary() + if backend in ("docker", "containerd", "podman", "incus", "lxd"): + argv = [binary, action, identifier] + elif backend == "qemu": + argv = [binary, "-c", os.environ.get("ONECLICKVIRT_LIBVIRT_URI", "qemu:///system"), + {"start": "start", "stop": "shutdown", "restart": "reboot"}[action], identifier] + elif backend == "pve": + node = row.get("node", "") + kind = row.get("type", "") + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_.-]*", node) or kind not in ("qemu", "lxc") or not identifier.isdecimal(): + raise ValueError("Invalid PVE resource identity") + endpoint = "/nodes/%s/%s/%s/status/%s" % (node, kind, identifier, {"stop": "shutdown", "restart": "reboot"}.get(action, action)) + argv = [binary, "create", endpoint] + else: + namespace = row["namespace"] + argv = ["virtctl", action, identifier, "--namespace", namespace] + return {"id": identifier, "action": action, "output": execute(argv, timeout=90), "accepted": True} + if name == "probe_help": + return execute([self.binary()] + self.profile.get("help_args", ["-h"]), timeout=15) + if name == "run_probe": + preset = self.profile["presets"][args["preset"]] + if self.profile.get("benchmark") and os.environ.get("ONECLICKVIRT_MCP_ALLOW_BENCHMARKS") != "1": + raise ValueError("Benchmark execution is disabled; set ONECLICKVIRT_MCP_ALLOW_BENCHMARKS=1 on an intended test host") + if bool(preset.get("target")) != bool(args.get("target")): + raise ValueError("This preset %s a target" % ("requires" if preset.get("target") else "does not accept")) + argv = [self.binary()] + preset["args"] + if preset.get("target"): + if self.repo == "oneclickvirt/backtrace": + ipaddress.ip_address(args["target"]) + argv += [preset["target"], args["target"]] + with tempfile.TemporaryDirectory(prefix="oneclickvirt-probe-") as directory: + argv = [a.replace("{scratch}", directory) for a in argv] + result = execute(argv, timeout=preset.get("timeout", 45), cwd=directory) + return {"preset": args["preset"], "result": result} + if name == "network_status": + return {"addresses": execute(["ip", "-j", "-6", "addr", "show"]), + "routes": execute(["ip", "-j", "-6", "route", "show"])} + if name == "counter_monthly": + base = os.environ.get("ONECLICKVIRT_COUNTER_URL", "https://hits.spiritlhl.net").rstrip("/") + parsed = urllib.parse.urlsplit(base) + if parsed.scheme != "https" or not parsed.netloc or parsed.username or parsed.query or parsed.fragment: + raise ValueError("Counter endpoint must be an HTTPS base URL") + url = base + "/api/monthly?" + urllib.parse.urlencode({"counter": args["counter"]}) + request = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "oneclickvirt-mcp/" + VERSION}) + with urllib.request.urlopen(request, timeout=20) as response: + content = response.read(LIMIT + 1) + if len(content) > LIMIT: + raise ValueError("Counter response exceeds 1 MiB") + return json.loads(content) + raise ValueError("Unsupported operation") + + +def serve(adapter, input_stream=sys.stdin, output_stream=sys.stdout): + for line in input_stream: + if len(line) > LIMIT: + error = {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Message exceeds 1 MiB"}} + output_stream.write(json.dumps(error) + "\n") + output_stream.flush() + continue + try: + message = json.loads(line) + except ValueError: + output_stream.write(json.dumps({"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "Parse error"}}) + "\n") + output_stream.flush() + continue + if not isinstance(message, dict) or message.get("jsonrpc") != "2.0" or not isinstance(message.get("method"), str): + output_stream.write(json.dumps({"jsonrpc": "2.0", "id": message.get("id") if isinstance(message, dict) else None, + "error": {"code": -32600, "message": "Invalid request"}}) + "\n") + output_stream.flush() + continue + if "id" not in message: + continue + reply = {"jsonrpc": "2.0", "id": message["id"]} + method = message["method"] + params = message.get("params", {}) + try: + if not isinstance(params, dict): + raise ValueError("params must be an object") + if method == "initialize": + requested = params.get("protocolVersion") + reply["result"] = {"protocolVersion": requested if requested in PROTOCOLS else PROTOCOLS[-1], + "capabilities": {"tools": {}}, + "serverInfo": {"name": adapter.profile["skill_name"], "version": VERSION}} + elif method == "ping": + reply["result"] = {} + elif method == "tools/list": + reply["result"] = {"tools": adapter.tools()} + elif method == "tools/call": + try: + value = adapter.call(params.get("name"), params.get("arguments", {})) + reply["result"] = {"content": [{"type": "text", "text": json.dumps(value, ensure_ascii=False)}], "isError": False} + except (ValueError, OSError, KeyError, TypeError, urllib.error.URLError) as exc: + reply["result"] = {"content": [{"type": "text", "text": str(exc)}], "isError": True} + else: + reply["error"] = {"code": -32601, "message": "Method not found"} + except (ValueError, TypeError) as exc: + reply["error"] = {"code": -32602, "message": str(exc)} + output_stream.write(json.dumps(reply, ensure_ascii=False) + "\n") + output_stream.flush() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--profile", type=Path, default=Path(__file__).with_name("profile.json")) + parser.add_argument("--version", action="version", version=VERSION) + args = parser.parse_args() + serve(Adapter(args.profile)) + + +if __name__ == "__main__": + main() diff --git a/scripts/install_agent.py b/scripts/install_agent.py new file mode 100644 index 0000000..fd9253f --- /dev/null +++ b/scripts/install_agent.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Install/update this repository's Skill and optional Python stdio MCP adapter.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import platform +import re +import subprocess +import sys +import tempfile +import io +import zipfile +import urllib.parse +import urllib.request + +REPOSITORY = "oneclickvirt/backtrace" +DEFAULT_REF = "main" +SKILL_NAME = "oneclickvirt-backtrace" +MAX_FILE = 2 * 1024 * 1024 + + +class SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + redirected = super().redirect_request(req, fp, code, msg, headers, newurl) + if redirected is not None and urllib.parse.urlsplit(req.full_url).netloc != urllib.parse.urlsplit(newurl).netloc: + redirected.remove_header("Authorization") + return redirected + + +def fetch(url, limit=MAX_FILE): + headers = {"User-Agent": "oneclickvirt-agent-installer/1.0"} + token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN") + if token and urllib.parse.urlsplit(url).netloc == "api.github.com": + headers["Authorization"] = "Bearer " + token + req = urllib.request.Request(url, headers=headers) + with urllib.request.build_opener(SafeRedirect()).open(req, timeout=40) as response: + data = response.read(limit + 1) + if len(data) > limit: + raise ValueError("Integration file exceeds the size limit") + return data + + +def release_binary(profile, version): + if not profile.get("asset_prefix"): + raise ValueError("This integration does not distribute a standalone binary") + if not re.fullmatch(r"[A-Za-z0-9_.+/-]{1,128}", version): + raise ValueError("Invalid release tag") + repo = profile.get("release_repository", REPOSITORY) + suffix = "latest" if version == "latest" else "tags/" + urllib.parse.quote(version, safe="") + release = json.loads(fetch("https://api.github.com/repos/" + repo + "/releases/" + suffix)) + if release.get("draft") or (version == "latest" and release.get("prerelease")): + raise ValueError("Release is not a stable published release") + system = platform.system().lower() + machine = platform.machine().lower() + arch = {"x86_64": "amd64", "amd64": "amd64", "aarch64": "arm64", "arm64": "arm64", + "i386": "386", "i686": "386", "armv7l": "arm"}.get(machine, machine) + separator = profile.get("asset_separator", "-") + stem = separator.join([profile["asset_prefix"], system, arch]) + candidates = [a for a in release["assets"] if a["name"] in (stem, stem + ".exe", stem + ".zip")] + if len(candidates) != 1: + raise ValueError("Release has no unique binary for " + system + "/" + arch + "; use --binary with an existing build") + asset = candidates[0] + url = asset["browser_download_url"] + parsed = urllib.parse.urlsplit(url) + expected_prefix = "https://github.com/" + repo + "/releases/download/" + if not url.startswith(expected_prefix) or parsed.scheme != "https": + raise ValueError("Unexpected binary download URL") + content = fetch(url, limit=256 * 1024 * 1024) + digest = asset.get("digest") or "" + checksum = hashlib.sha256(content).hexdigest() + if digest and digest != "sha256:" + checksum: + raise ValueError("Published binary digest does not match the download") + if not digest: + checksum_assets = [a for a in release["assets"] if a["name"] in ("checksums.txt", "SHA256SUMS")] + verified = False + for entry in checksum_assets: + checksum_url = entry["browser_download_url"] + if not checksum_url.startswith(expected_prefix): + raise ValueError("Unexpected checksum download URL") + for line in fetch(checksum_url).decode().splitlines(): + match = re.fullmatch(r"([a-fA-F0-9]{64})\s+\*?(.+)", line) + if match and match[2] == asset["name"] and match[1].lower() == checksum: + verified = True + if not verified: + raise ValueError("Binary has no matching SHA-256 digest/checksum; existing installations were not changed") + if asset["name"].endswith(".zip"): + with zipfile.ZipFile(io.BytesIO(content)) as archive: + entries = [f for f in archive.infolist() if Path(f.filename).name in (profile["binary"], profile["binary"] + ".exe") and not f.is_dir()] + if len(entries) != 1 or entries[0].file_size > 256 * 1024 * 1024: + raise ValueError("Archive does not contain one expected executable") + content = archive.read(entries[0]) + return content, {"version": release["tag_name"], "asset": asset["name"], "archive_sha256": checksum, + "binary_sha256": hashlib.sha256(content).hexdigest()} + + +def resolve_source(source, ref): + if not re.fullmatch(r"[A-Za-z0-9_.+/-]{1,160}", ref) or ".." in ref: + raise ValueError("Invalid source ref") + if source == "github": + info = json.loads(fetch("https://api.github.com/repos/" + REPOSITORY + "/commits/" + urllib.parse.quote(ref, safe=""))) + commit = info["sha"] + base = "https://raw.githubusercontent.com/" + REPOSITORY + "/" + commit + "/" + else: + # Public Git refs require no credentials. Pin the CNB snapshot itself, + # whose commit differs from the history-free GitHub source mirror. + result = subprocess.check_output(["git", "-c", "credential.helper=", "ls-remote", "https://cnb.cool/" + REPOSITORY, + ref if re.fullmatch(r"[a-f0-9]{40}", ref) else "refs/heads/" + ref, + "refs/tags/" + ref, "refs/tags/" + ref + "^{}"], text=True, timeout=45) + rows = [line.split() for line in result.splitlines()] + if re.fullmatch(r"[a-f0-9]{40}", ref): + commit = ref + elif rows: + peeled = [row[0] for row in rows if row[1].endswith("^{}")] + commit = (peeled or [row[0] for row in rows])[0] + else: + raise ValueError("CNB source ref does not exist") + base = "https://cnb.cool/" + REPOSITORY + "/-/git/raw/" + commit + "/" + if not re.fullmatch(r"[a-f0-9]{40}", commit): + raise ValueError("Source did not resolve to an immutable commit") + return commit, lambda path: fetch(base + path) + + +def load_payload(reader): + manifest = json.loads(reader("agent/manifest.json")) + if manifest.get("repository") != REPOSITORY or manifest.get("skill_name") != SKILL_NAME: + raise ValueError("Manifest does not identify the requested integration") + expected = {"agent/profile.json", "skills/" + SKILL_NAME + "/SKILL.md", "skills/" + SKILL_NAME + "/SKILL_ZH.md"} + if manifest.get("mcp"): + expected.add("agent/server.py") + if set(manifest.get("files", {})) != expected: + raise ValueError("Manifest contains missing or unexpected file paths") + data = {} + for path, digest in manifest["files"].items(): + content = reader(path) + if not re.fullmatch(r"[0-9a-f]{64}", digest) or hashlib.sha256(content).hexdigest() != digest: + raise ValueError("SHA-256 verification failed for " + path) + data[path] = content + profile = json.loads(data["agent/profile.json"]) + if profile.get("repository") != REPOSITORY or profile.get("skill_name") != SKILL_NAME: + raise ValueError("Profile does not match the requested repository") + return manifest, data + + +def atomic_write(target, content): + target.parent.mkdir(parents=True, exist_ok=True) + if target.is_symlink(): + raise ValueError("Refusing to replace a symlink: " + str(target)) + handle, temporary = tempfile.mkstemp(prefix=".oneclickvirt-", dir=target.parent) + try: + with os.fdopen(handle, "wb") as stream: + stream.write(content) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def install(manifest, payload, skill_dir, mcp_dir, record, skill_only=False, binary=None, binary_content=None): + skill_dir = Path(skill_dir).expanduser().absolute() + mcp_dir = Path(mcp_dir).expanduser().absolute() + if skill_dir.is_symlink() or mcp_dir.is_symlink(): + raise ValueError("The integration destination itself must not be a symlink") + has_mcp = manifest.get("mcp") and not skill_only + writes = {} + if binary_content is not None: + writes[Path(binary)] = binary_content + for filename in ("SKILL.md", "SKILL_ZH.md"): + writes[skill_dir / filename] = payload["skills/" + SKILL_NAME + "/" + filename] + if has_mcp: + writes[mcp_dir / "server.py"] = payload["agent/server.py"] + writes[mcp_dir / "profile.json"] = payload["agent/profile.json"] + env = {} + if binary: + env["ONECLICKVIRT_MCP_BINARY"] = str(Path(binary).expanduser().resolve()) + config = {"mcpServers": {SKILL_NAME: {"command": sys.executable, + "args": [str(mcp_dir / "server.py")], "env": env}}} + writes[skill_dir / "mcp.example.json"] = (json.dumps(config, ensure_ascii=False, indent=2) + "\n").encode() + record = dict(record, repository=REPOSITORY, skill_name=SKILL_NAME, mcp_installed=bool(has_mcp), + files=manifest["files"]) + writes[skill_dir / "install.json"] = (json.dumps(record, indent=2) + "\n").encode() + # Verify the entire payload and every destination before replacing anything. + for path in writes: + if path.is_symlink(): + raise ValueError("Symlink installation file is not supported: " + str(path)) + if path.exists() and not path.is_file(): + raise ValueError("Destination is not a file: " + str(path)) + for path, content in writes.items(): + atomic_write(path, content) + if binary_content is not None and path == Path(binary) and os.name != "nt": + path.chmod(0o755) + return {"skill_dir": str(skill_dir), "mcp_dir": str(mcp_dir) if has_mcp else None, + "source_commit": record["commit"], "mcp_installed": bool(has_mcp)} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--client", choices=["claude", "codex", "openclaw"], default="claude") + parser.add_argument("--source", choices=["github", "cnb"], default="github") + parser.add_argument("--ref", default=DEFAULT_REF, help="Source branch, tag or immutable commit") + parser.add_argument("--skill-dir", type=Path) + parser.add_argument("--mcp-dir", type=Path, default=Path.home() / ".local/share/oneclickvirt-mcp" / SKILL_NAME) + parser.add_argument("--skill-only", action="store_true") + parser.add_argument("--binary", help="Absolute path to an already installed runtime/probe binary") + parser.add_argument("--install-binary", action="store_true", help="Install the verified published probe alongside the adapter") + parser.add_argument("--version", default="latest", help="Probe release tag; independent of the integration source --ref") + parser.add_argument("--bin-dir", type=Path, default=Path.home() / ".local/bin") + parser.add_argument("--source-dir", type=Path, help="Install from a verified local source checkout") + args = parser.parse_args() + if args.source_dir: + root = args.source_dir.resolve() + commit = subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip() + reader = lambda path: (root / path).read_bytes() + else: + commit, reader = resolve_source(args.source, args.ref) + manifest, payload = load_payload(reader) + if args.binary and args.install_binary: + raise ValueError("Choose --binary or --install-binary") + binary_content = None + binary_record = {} + binary = args.binary + if binary: + candidate = Path(binary).expanduser() + if not candidate.is_absolute() or not candidate.is_file() or (os.name != "nt" and not os.access(candidate, os.X_OK)): + raise ValueError("--binary must name an existing executable by its absolute path") + if args.install_binary: + if args.skill_only or not manifest.get("mcp"): + raise ValueError("Binary installation requires an MCP-enabled integration") + profile = json.loads(payload["agent/profile.json"]) + binary_content, binary_record = release_binary(profile, args.version) + binary = str(args.bin_dir.expanduser().absolute() / (profile["binary"] + (".exe" if os.name == "nt" else ""))) + parents = {"claude": Path.home() / ".claude/skills", "codex": Path.home() / ".agents/skills", + "openclaw": Path.home() / ".openclaw/skills"} + result = install(manifest, payload, args.skill_dir or parents[args.client] / SKILL_NAME, + args.mcp_dir, {"source": args.source, "ref": args.ref, "commit": commit, "binary": binary_record}, + args.skill_only, binary, binary_content) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result["mcp_installed"]: + print("Merge mcp.example.json into your client's MCP settings. Existing settings and tokens were not modified.") + else: + print("Installed Skill files. This repository does not install an MCP server in this mode.") + + +if __name__ == "__main__": + try: + main() + except (ValueError, OSError, subprocess.SubprocessError) as exc: + print("Installation failed: " + str(exc), file=sys.stderr) + sys.exit(1) diff --git a/skills/oneclickvirt-backtrace/SKILL.md b/skills/oneclickvirt-backtrace/SKILL.md new file mode 100644 index 0000000..13e7cc6 --- /dev/null +++ b/skills/oneclickvirt-backtrace/SKILL.md @@ -0,0 +1,56 @@ +--- +name: oneclickvirt-backtrace +description: "Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute." +metadata: + repository: oneclickvirt/backtrace + integration-version: "1.0.0" +--- + +# backtrace + +## Scope + +Query RDAP/BGP metadata for an explicit IP; distinguish lookup data from an actual traceroute. + +- Read probe_help from the installed binary before assuming flags are supported. MCP returns native JSON when the program provides JSON, otherwise native text. +- Keep partial/network errors distinct from confirmed test outcomes. Current release assets and their actual architecture are available through release_assets. +- The ip-info preset requires a literal target IP. Do not pass a host, proxy, shell expression or a network range. + +## Install and update + +```bash +curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py +python3 install_agent.py --client codex +``` + +The installer is also mirrored at `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`; add `--source cnb` to install from an immutable CNB snapshot (requires Git). + +Use `--client claude` or `--client openclaw` for another client. Download and run again to update; `--ref` pins a source tag/commit. The installer verifies all SHA-256 checksums before replacing its files, preserves extra local files/client settings, and records the source commit in `install.json`. + +## MCP + +Requires Python 3.9+. The adapter uses existing local commands and their existing permissions; it does not install a virtualization runtime. Merge the installed `mcp.example.json` into the client configuration. For a remote host, run its adapter over noninteractive SSH. + +```bash +python3 install_agent.py --client codex --install-binary +``` + +`--version` selects the probe release tag; `--ref` selects Skill/MCP source independently. The default resolves the latest stable GitHub release once, selects a real asset for this OS/architecture, and verifies SHA-256. Missing assets fail without guessing. Reuse an existing executable with `--binary /absolute/path/to/program`. + +Call `integration_info` first for capabilities and prerequisites. + +Available probe presets: + +- `ip-info`: `backtrace -json -timeout 12s -ip ` + +`release_assets` returns the actual version, filenames, sizes, digests and download URLs. Pass an exact tag for a pinned release. After a product update, verify help and an actual call rather than treating file presence as a successful test. + +## Source and documentation + +- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh) +- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go) +- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod) + +- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html) + +Check the relevant source and current documentation before giving commands. Preserve requested version pins, data paths and native host identifiers. Distinguish actual program execution, isolated tests and unverified environments. diff --git a/skills/oneclickvirt-backtrace/SKILL_ZH.md b/skills/oneclickvirt-backtrace/SKILL_ZH.md new file mode 100644 index 0000000..325687d --- /dev/null +++ b/skills/oneclickvirt-backtrace/SKILL_ZH.md @@ -0,0 +1,56 @@ +--- +name: oneclickvirt-backtrace +description: "查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。" +metadata: + repository: oneclickvirt/backtrace + integration-version: "1.0.0" +--- + +# backtrace + +## 使用范围 + +查询指定 IP 的 RDAP/BGP 信息,区分数据库查询与真实路由探测。 + +- 先用 probe_help 核对已安装程序的实际帮助。程序支持 JSON 时返回原生 JSON,否则返回原生文本。 +- 区分部分结果、网络失败和确定结论;通过 release_assets 查询当前真实产物及架构。 +- ip-info 预设只接受单个真实 IP,不接受域名、代理、Shell 表达式或网段。 + +## 安装和更新 + +```bash +curl -fL --retry 3 https://raw.githubusercontent.com/oneclickvirt/backtrace/main/scripts/install_agent.py -o install_agent.py +python3 install_agent.py --client codex +``` + +可将下载地址改为 `https://cnb.cool/oneclickvirt/backtrace/-/git/raw/main/scripts/install_agent.py`,并使用 `--source cnb` 从 CNB 的固定提交安装;此模式另需 Git。 + +用 `--client claude` 或 `--client openclaw` 选择其他客户端。重新下载并执行可更新;`--ref` 固定源码标签或提交。安装前校验全部 SHA-256,保留额外本地文件及已有客户端设置,`install.json` 记录来源提交。 + +## MCP + +运行需要 Python 3.9 以上版本;MCP 使用本机已有程序及其权限,不自动安装虚拟化环境。将安装目录内 `mcp.example.json` 合并到客户端配置。远端宿主机应通过无交互 SSH 运行该主机上的适配器。 + +```bash +python3 install_agent.py --client codex --install-binary +``` + +`--version` 可指定检测程序的正式 Release 标签,`--ref` 则指定 Skill/MCP 源码版本,两者独立。默认从 GitHub 解析一次最新正式版,选择本机系统/架构的真实产物并校验 SHA-256;没有匹配产物时停止。已有程序可用 `--binary /完整路径/程序` 复用。 + +先调用 `integration_info` 查看能力和运行要求。 + +可用检测预设: + +- `ip-info`: `backtrace -json -timeout 12s -ip ` + +`release_assets` 返回实际版本、文件名、大小、摘要及下载地址;需要固定版本时传完整标签。更新产物后应重新安装并验证帮助和实际调用,不能只因文件存在便判定可用。 + +## 对应源码与说明 + +- [`backtrace_install.sh`](https://github.com/oneclickvirt/backtrace/blob/main/backtrace_install.sh) +- [`cmd/main.go`](https://github.com/oneclickvirt/backtrace/blob/main/cmd/main.go) +- [`go.mod`](https://github.com/oneclickvirt/backtrace/blob/main/go.mod) + +- [集成安装说明 / Integration guide](https://www.spiritlhl.net/guide/agent_integrations.html) + +给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。