mirror of
https://github.com/oneclickvirt/backtrace.git
synced 2026-11-03 16:25:48 +08:00
feat: add maintained Skill and MCP integration [skip ci]
This commit is contained in:
parent
977fab66e7
commit
9636fd5dfd
11
agent/manifest.json
Normal file
11
agent/manifest.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
39
agent/profile.json
Normal file
39
agent/profile.json
Normal file
@ -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 表达式或网段。"
|
||||||
|
]
|
||||||
|
}
|
||||||
349
agent/server.py
Normal file
349
agent/server.py
Normal file
@ -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()
|
||||||
250
scripts/install_agent.py
Normal file
250
scripts/install_agent.py
Normal file
@ -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)
|
||||||
56
skills/oneclickvirt-backtrace/SKILL.md
Normal file
56
skills/oneclickvirt-backtrace/SKILL.md
Normal file
@ -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 <target>`
|
||||||
|
|
||||||
|
`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.
|
||||||
56
skills/oneclickvirt-backtrace/SKILL_ZH.md
Normal file
56
skills/oneclickvirt-backtrace/SKILL_ZH.md
Normal file
@ -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 <target>`
|
||||||
|
|
||||||
|
`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)
|
||||||
|
|
||||||
|
给出操作命令前核对这些源码文件及当前文档,保留现有版本固定、数据路径和宿主机标识。报告测试时区分真实程序执行、隔离测试及未验证的环境。
|
||||||
Loading…
Reference in New Issue
Block a user