openclaw-diag-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,292 @@
1
+ #!/usr/bin/env python3
2
+ """模块 2:基础环境(版本一致性、Gateway 进程、端口、环境变量)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from ocdiag import cli, output, paths
19
+ from ocdiag.sensitive import safe_val
20
+
21
+
22
+ def run(cmd, timeout=5):
23
+ try:
24
+ r = subprocess.run(
25
+ cmd, shell=isinstance(cmd, str), capture_output=True,
26
+ text=True, timeout=timeout, check=False,
27
+ )
28
+ return r.returncode, r.stdout, r.stderr
29
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
30
+ return 1, "", ""
31
+
32
+
33
+ def detect_oc_version() -> Optional[str]:
34
+ rc, stdout, _ = run(["openclaw", "--version"])
35
+ if rc == 0 and stdout:
36
+ return stdout.splitlines()[0].strip()
37
+
38
+ home = os.path.expanduser("~")
39
+ pnpm_root = os.path.join(home, ".local", "share", "pnpm")
40
+ if os.path.isdir(pnpm_root):
41
+ for root, _, files in os.walk(pnpm_root):
42
+ if "package.json" in files and root.endswith(os.sep + "openclaw"):
43
+ try:
44
+ with open(os.path.join(root, "package.json")) as f:
45
+ return json.load(f).get("version")
46
+ except Exception:
47
+ pass
48
+
49
+ pkg = "/usr/lib/node_modules/openclaw/package.json"
50
+ if os.path.isfile(pkg):
51
+ try:
52
+ with open(pkg) as f:
53
+ return json.load(f).get("version")
54
+ except Exception:
55
+ pass
56
+ return None
57
+
58
+
59
+ def detect_node_version() -> Optional[str]:
60
+ rc, stdout, _ = run(["node", "--version"])
61
+ if rc == 0 and stdout:
62
+ return stdout.strip()
63
+ return None
64
+
65
+
66
+ def gateway_systemctl_status() -> str:
67
+ _, stdout, _ = run(["systemctl", "--user", "status", "openclaw-gateway"])
68
+ return stdout
69
+
70
+
71
+ def gateway_pid() -> Optional[str]:
72
+ _, stdout, _ = run(["pgrep", "-f", "openclaw.*gateway"])
73
+ pid = stdout.splitlines()[0].strip() if stdout else ""
74
+ if pid:
75
+ return pid
76
+ rc, stdout, _ = run(["systemctl", "--user", "show", "openclaw-gateway.service",
77
+ "--property=MainPID"])
78
+ if rc == 0 and "=" in stdout:
79
+ v = stdout.strip().split("=", 1)[1]
80
+ if v and v != "0":
81
+ return v
82
+ return None
83
+
84
+
85
+ def parse_proc_environ(pid: str) -> Optional[list]:
86
+ p = f"/proc/{pid}/environ"
87
+ if not os.access(p, os.R_OK):
88
+ return None
89
+ try:
90
+ with open(p, "rb") as f:
91
+ raw = f.read()
92
+ except (PermissionError, FileNotFoundError, OSError):
93
+ return None
94
+ pairs = []
95
+ for entry in raw.split(b"\x00"):
96
+ if not entry:
97
+ continue
98
+ try:
99
+ s = entry.decode("utf-8", errors="replace")
100
+ except Exception:
101
+ continue
102
+ eq = s.find("=")
103
+ if eq <= 0:
104
+ continue
105
+ k, v = s[:eq], s[eq + 1:]
106
+ pairs.append((k, safe_val(k, v)))
107
+ return sorted(pairs)
108
+
109
+
110
+ def main() -> int:
111
+ parser = cli.build_common_parser(
112
+ description="模块 2:采集 OpenClaw 基础环境",
113
+ prog="02_environment",
114
+ )
115
+ args = parser.parse_args()
116
+ out = output.init("environment", json_mode=args.json, no_color=args.no_color)
117
+ out.section("模块 2:基础环境")
118
+
119
+ oc_version = detect_oc_version()
120
+ if oc_version:
121
+ out.item(f"ArkClaw 版本: {oc_version}")
122
+ else:
123
+ out.item("ArkClaw 版本: 无法确定")
124
+ out.evidence("openclaw --version", "命令未找到或无输出")
125
+ out.set_data("oc_version", oc_version)
126
+
127
+ service_file = paths.SERVICE_FILE
128
+ svc_version = None
129
+ if oc_version and os.path.isfile(service_file):
130
+ try:
131
+ with open(service_file) as f:
132
+ m = re.search(r"openclaw@([0-9]+\.[0-9]+\.[0-9]+)", f.read())
133
+ if m:
134
+ svc_version = m.group(1)
135
+ except OSError:
136
+ pass
137
+ if svc_version:
138
+ cli_clean = re.search(r"[0-9]+\.[0-9]+\.[0-9]+", oc_version)
139
+ cli_clean = cli_clean.group(0) if cli_clean else ""
140
+ if cli_clean and cli_clean != svc_version:
141
+ out.item(f"版本不一致: CLI={cli_clean} vs Gateway service={svc_version}")
142
+ out.item(" 原因: pnpm 升级后 service 文件未重生,Gateway 实际跑的是旧版本")
143
+ out.item(" 修复: 在目标机器上执行 `openclaw gateway install --force` 然后 `openclaw gateway restart`")
144
+ else:
145
+ out.item(f"版本一致: CLI={cli_clean} = service={svc_version}")
146
+ out.set_data("service_version", svc_version)
147
+
148
+ node_ver = detect_node_version()
149
+ if node_ver:
150
+ major = node_ver.lstrip("v").split(".", 1)[0]
151
+ out.item(f"Node.js 版本: {node_ver} (major: {major})")
152
+ else:
153
+ out.item("Node.js: 未找到")
154
+ out.evidence("node --version", "命令未找到")
155
+ out.set_data("node_version", node_ver)
156
+
157
+ rc, stdout, _ = run(["free", "-m"])
158
+ mem_avail = ""
159
+ if rc == 0:
160
+ for line in stdout.splitlines():
161
+ if line.startswith("Mem:"):
162
+ parts = line.split()
163
+ if len(parts) >= 7:
164
+ mem_avail = parts[6]
165
+ break
166
+ if mem_avail:
167
+ out.item(f"可用内存: {mem_avail} MB")
168
+ out.set_data("memory_available_mb", mem_avail)
169
+
170
+ rc, stdout, _ = run(["df", "-m", paths.OPENCLAW_HOME])
171
+ disk_avail = ""
172
+ if rc == 0:
173
+ lines = stdout.splitlines()
174
+ if len(lines) >= 2:
175
+ parts = lines[1].split()
176
+ if len(parts) >= 4:
177
+ disk_avail = parts[3]
178
+ if disk_avail:
179
+ out.item(f"磁盘可用 ({paths.OPENCLAW_HOME}): {disk_avail} MB")
180
+ out.set_data("disk_available_mb", disk_avail)
181
+
182
+ gw_status = gateway_systemctl_status()
183
+ if gw_status:
184
+ active_state = ""
185
+ main_pid = ""
186
+ since = ""
187
+ m = re.search(r"Active:\s+(\S+)", gw_status)
188
+ if m:
189
+ active_state = m.group(1)
190
+ m = re.search(r"Main PID:\s+(\d+)", gw_status)
191
+ if m:
192
+ main_pid = m.group(1)
193
+ m = re.search(r"since\s+(.*)", gw_status)
194
+ if m:
195
+ since = m.group(1).splitlines()[0].strip()
196
+ if active_state == "active":
197
+ out.item(f"Gateway 服务: 运行中 (PID {main_pid or '?'}, since {since or '?'})")
198
+ else:
199
+ out.item(f"Gateway 服务: {active_state or 'unknown'}")
200
+ out.evidence("systemctl --user status openclaw-gateway", "\n".join(gw_status.splitlines()[:5]))
201
+ out.set_data("gateway_state", active_state)
202
+ out.set_data("gateway_main_pid", main_pid)
203
+ else:
204
+ _, pids, _ = run(["pgrep", "-f", "openclaw-gatewa"])
205
+ pids_clean = " ".join(pids.splitlines()[:5]) if pids else ""
206
+ if pids_clean:
207
+ out.item(f"Gateway 进程: 已发现 (PIDs: {pids_clean})")
208
+ else:
209
+ out.item("Gateway 进程: 未通过 systemctl 或 pgrep 检测到")
210
+ out.evidence("pgrep -f openclaw-gatewa", "无输出")
211
+ out.set_data("gateway_pids", pids_clean.split() if pids_clean else [])
212
+
213
+ port = 18789
214
+ if os.path.isfile(args.config):
215
+ try:
216
+ with open(args.config) as f:
217
+ cfg = json.load(f)
218
+ cfg_port = cfg.get("gateway", {}).get("port")
219
+ if cfg_port:
220
+ port = int(cfg_port)
221
+ except Exception:
222
+ pass
223
+ rc, stdout, _ = run(["ss", "-tlnp"])
224
+ listening = any(f":{port} " in ln for ln in stdout.splitlines()) if rc == 0 else False
225
+ if listening:
226
+ out.item(f"端口 {port}: 监听中")
227
+ else:
228
+ out.item(f"端口 {port}: 未监听")
229
+ out.evidence(f"ss -tlnp | grep :{port}", "<无输出>")
230
+ out.set_data("port", port)
231
+ out.set_data("port_listening", listening)
232
+
233
+ out.line("")
234
+ out.line(" ── Gateway 进程环境变量(OpenClaw 实际运行环境) ──")
235
+ out.line("")
236
+ pid = gateway_pid()
237
+ env_pairs = parse_proc_environ(pid) if pid else None
238
+ if pid and env_pairs is not None:
239
+ out.item(f"Gateway PID: {pid}")
240
+ out.line("")
241
+ out.item(f"共 {len(env_pairs)} 个环境变量")
242
+ out.line("")
243
+ for k, v in env_pairs:
244
+ out.item(f"{k} = {v}")
245
+ out.set_data("gateway_env", [{"key": k, "value": v} for k, v in env_pairs])
246
+ elif pid:
247
+ out.item(f"无法读取 /proc/{pid}/environ(权限不足?)")
248
+ else:
249
+ out.item("Gateway 进程未运行,跳过")
250
+
251
+ if os.path.isfile(paths.SERVICE_ENV_FILE):
252
+ out.line("")
253
+ out.line(f" ── Systemd 服务环境变量配置 ({paths.SERVICE_ENV_FILE}) ──")
254
+ out.line("")
255
+ try:
256
+ with open(paths.SERVICE_ENV_FILE) as f:
257
+ for line in f:
258
+ line = line.strip()
259
+ if not line or line.startswith("#") or line.startswith("["):
260
+ continue
261
+ if line.startswith("Environment="):
262
+ raw = line[len("Environment="):].strip()
263
+ # systemd 支持单行多键:Environment="A=1" "B=2" 或 Environment=A=1 B=2
264
+ try:
265
+ tokens = shlex.split(raw, posix=True)
266
+ except ValueError:
267
+ tokens = [raw]
268
+ for tok in tokens:
269
+ if "=" not in tok:
270
+ out.item(tok)
271
+ continue
272
+ key, val = tok.split("=", 1)
273
+ out.item(f"{key} = {safe_val(key, val)}")
274
+ except OSError as e:
275
+ out.item(f"读取失败: {e}")
276
+
277
+ if os.path.isfile(service_file):
278
+ out.line("")
279
+ out.line(f" ── Systemd 服务文件 ({service_file}) ──")
280
+ out.line("")
281
+ try:
282
+ with open(service_file) as f:
283
+ for line in f:
284
+ out.item(line.rstrip("\n"))
285
+ except OSError:
286
+ pass
287
+
288
+ return out.done()
289
+
290
+
291
+ if __name__ == "__main__":
292
+ sys.exit(main())
@@ -0,0 +1,131 @@
1
+ #!/usr/bin/env python3
2
+ """模块 3:采集 openclaw.json 配置(含敏感字段脱敏,模型 providers 折叠)。"""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
+
14
+ from ocdiag import cli, output
15
+ from ocdiag.sensitive import is_sensitive_key, mask
16
+
17
+
18
+ _INSTALLS_RE = re.compile(r"^plugins\.installs\.[^.]+\.([^.]+)$")
19
+
20
+
21
+ def format_value(val):
22
+ if isinstance(val, str):
23
+ return val if val else '""'
24
+ if isinstance(val, bool):
25
+ return "true" if val else "false"
26
+ if val is None:
27
+ return "null"
28
+ return str(val)
29
+
30
+
31
+ def emit_config(out: output.Output, data: list, obj, prefix: str = "") -> None:
32
+ if isinstance(obj, dict):
33
+ if not obj:
34
+ data.append(f"{prefix} = {{}}")
35
+ return
36
+ for k, v in obj.items():
37
+ new_key = f"{prefix}.{k}" if prefix else k
38
+ m = _INSTALLS_RE.match(new_key)
39
+ if m and m.group(1) not in ("source", "version", "installPath"):
40
+ continue
41
+ if new_key.startswith("models.providers.") and k == "models" and isinstance(v, list):
42
+ for md in v:
43
+ if isinstance(md, dict):
44
+ mid = md.get("id", "?")
45
+ cw = md.get("contextWindow", "?")
46
+ mt = md.get("maxTokens", "?")
47
+ r_str = "true" if md.get("reasoning", False) else "false"
48
+ data.append(
49
+ f"{new_key}.{mid} = (contextWindow={cw}, "
50
+ f"maxTokens={mt}, reasoning={r_str})"
51
+ )
52
+ continue
53
+ emit_config(out, data, v, new_key)
54
+ elif isinstance(obj, list):
55
+ if not obj:
56
+ data.append(f"{prefix} = []")
57
+ return
58
+ all_scalar = all(isinstance(x, (str, int, float, bool)) for x in obj)
59
+ if all_scalar:
60
+ n = len(obj)
61
+ if n <= 5:
62
+ data.append(f"{prefix} = [{n} 项]: {', '.join(str(x) for x in obj)}")
63
+ else:
64
+ data.append(f"{prefix} = [{n} 项]: {', '.join(str(x) for x in obj[:3])}, ... (完整列表省略)")
65
+ else:
66
+ for i, v in enumerate(obj):
67
+ emit_config(out, data, v, f"{prefix}[{i}]")
68
+ else:
69
+ if is_sensitive_key(prefix):
70
+ if isinstance(obj, str) and obj:
71
+ display = mask(obj)
72
+ elif isinstance(obj, (int, float, bool)):
73
+ display = str(obj)
74
+ elif obj is None:
75
+ display = "null"
76
+ else:
77
+ display = "****"
78
+ else:
79
+ display = format_value(obj)
80
+ if len(str(display)) > 500:
81
+ display = str(display)[:500] + "..."
82
+ data.append(f"{prefix} = {display}")
83
+
84
+
85
+ def main() -> int:
86
+ parser = cli.build_common_parser(
87
+ description="模块 3:采集 OpenClaw 配置(含敏感字段脱敏)",
88
+ prog="03_configuration",
89
+ )
90
+ args = parser.parse_args()
91
+
92
+ out = output.init("configuration", json_mode=args.json, no_color=args.no_color)
93
+ out.section("模块 3:配置")
94
+
95
+ config_path = args.config
96
+ if not os.path.isfile(config_path):
97
+ out.item(f"配置文件未找到: {config_path}")
98
+ out.evidence(config_path, "<文件缺失>")
99
+ out.set_data("config_path", config_path)
100
+ out.set_data("found", False)
101
+ out.fail("配置文件未找到")
102
+ return out.done()
103
+
104
+ out.set_data("config_path", config_path)
105
+ out.set_data("found", True)
106
+
107
+ try:
108
+ with open(config_path) as f:
109
+ config = json.load(f)
110
+ out.item("JSON 语法: 有效")
111
+ out.set_data("json_valid", True)
112
+ except (json.JSONDecodeError, OSError) as e:
113
+ out.item("JSON 语法: 无效")
114
+ out.evidence(config_path, str(e))
115
+ out.set_data("json_valid", False)
116
+ out.set_data("parse_error", str(e))
117
+ out.fail("配置 JSON 无效")
118
+ return out.done()
119
+
120
+ out.line("")
121
+ flat: list = []
122
+ emit_config(out, flat, config)
123
+ for line in flat:
124
+ out.item(line)
125
+ out.set_data("flattened", flat)
126
+
127
+ return out.done()
128
+
129
+
130
+ if __name__ == "__main__":
131
+ sys.exit(main())