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.
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/bin/ocdiag +14 -0
- package/bin/openclaw-diag.js +275 -0
- package/diag/01_sys_health.py +443 -0
- package/diag/02_environment.py +292 -0
- package/diag/03_configuration.py +131 -0
- package/diag/04_gateway.py +651 -0
- package/diag/05_recent_errors.py +246 -0
- package/diag/06_cron_jobs.py +694 -0
- package/diag/07_performance.py +687 -0
- package/diag/08_sessions.py +518 -0
- package/diag/09_plugin_diag.py +535 -0
- package/diag/10_shell_history.py +121 -0
- package/diag/__init__.py +0 -0
- package/lib/bundle.py +204 -0
- package/ocdiag/__init__.py +3 -0
- package/ocdiag/cli.py +39 -0
- package/ocdiag/dispatcher.py +137 -0
- package/ocdiag/jsonlog.py +65 -0
- package/ocdiag/output.py +131 -0
- package/ocdiag/paths.py +48 -0
- package/ocdiag/recent_logs.py +53 -0
- package/ocdiag/sensitive.py +41 -0
- package/ocdiag/timeutil.py +77 -0
- package/ocdiag/tokens.py +46 -0
- package/package.json +42 -0
- package/tools/__init__.py +0 -0
- package/tools/oc_session_extract.py +254 -0
- package/tools/oc_session_trace.py +715 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""模块 9:插件诊断(一致性 + ERROR/WARN + Hook + Channel + 外部依赖 DNS)。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import glob
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import socket
|
|
11
|
+
import sys
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
17
|
+
|
|
18
|
+
from ocdiag import cli, output
|
|
19
|
+
from ocdiag.jsonlog import parse_name
|
|
20
|
+
from ocdiag.timeutil import fmt_hms
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
READY_RE = re.compile(r"ready\s*\((\d+)\s+plugins?:\s*([^;]+);\s*([^)]+)\)")
|
|
24
|
+
|
|
25
|
+
HOOK_HANDLER_FAIL_RE = re.compile(
|
|
26
|
+
r"\[hooks\]\s+(\S+)\s+handler from\s+(\S+)\s+failed:\s+(.*)", re.DOTALL
|
|
27
|
+
)
|
|
28
|
+
HOOK_ASYNC_VIOLATION_RE = re.compile(
|
|
29
|
+
r"(\S+)\s+handler from\s+(\S+)\s+returned a Promise"
|
|
30
|
+
)
|
|
31
|
+
HOOK_AFTER_TOOL_RE = re.compile(
|
|
32
|
+
r"(\w+)\s+hook failed:\s+(?:tool=\S+\s+)?error=(.*)"
|
|
33
|
+
)
|
|
34
|
+
HOOK_INTERNAL_RE = re.compile(
|
|
35
|
+
r"Hook error \[([^\]]+)\]:\s+(.*)"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
CHANNEL_FAIL_RE = re.compile(
|
|
39
|
+
r"\[([^\]]+)\]\s+channel startup failed:\s+(.*)", re.IGNORECASE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_PM_BRACKET_RE = re.compile(r"^\[([^\]]+)\]\s*(.*)", re.DOTALL)
|
|
43
|
+
_PM_EXCLUDED_PREFIXES = frozenset([
|
|
44
|
+
"plugins", "hooks", "ConfigManager", "plugin-manager", "PluginManager",
|
|
45
|
+
])
|
|
46
|
+
_PM_BODY_PLUGIN_RE = re.compile(r"^([a-z0-9@/_.-]+)\s+failed", re.I)
|
|
47
|
+
_PM_PAREN_PLUGIN_RE = re.compile(
|
|
48
|
+
r"\((?:load|register|init|start|stop):\s*([a-z0-9@/_.-]+)\)", re.I
|
|
49
|
+
)
|
|
50
|
+
_PM_PLUGIN_EQ_RE = re.compile(r"\bplugin=([a-z0-9@/_.-]+)", re.I)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def msg_text(obj):
|
|
54
|
+
v = obj.get("1", "")
|
|
55
|
+
if isinstance(v, str) and v:
|
|
56
|
+
return v
|
|
57
|
+
for k in ("0", "2", "msg", "message"):
|
|
58
|
+
v = obj.get(k, "")
|
|
59
|
+
if isinstance(v, str) and v:
|
|
60
|
+
return v
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_ts(ts):
|
|
65
|
+
if not ts:
|
|
66
|
+
return None
|
|
67
|
+
try:
|
|
68
|
+
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def dedup_messages(samples, max_unique=5):
|
|
74
|
+
buckets = {}
|
|
75
|
+
for ts, lvl, text in samples:
|
|
76
|
+
key = text[:60]
|
|
77
|
+
if key not in buckets or (ts or "") > (buckets[key][0] or ""):
|
|
78
|
+
buckets[key] = (ts, lvl, text)
|
|
79
|
+
return sorted(buckets.values(), key=lambda x: x[0] or "", reverse=True)[:max_unique]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def extract_plugin_from_pm_message(text):
|
|
83
|
+
m = _PM_BRACKET_RE.match(text)
|
|
84
|
+
if not m:
|
|
85
|
+
return None
|
|
86
|
+
prefix, body = m.group(1), m.group(2)
|
|
87
|
+
if prefix not in _PM_EXCLUDED_PREFIXES:
|
|
88
|
+
return prefix
|
|
89
|
+
m2 = _PM_BODY_PLUGIN_RE.match(body)
|
|
90
|
+
if m2:
|
|
91
|
+
return m2.group(1)
|
|
92
|
+
m3 = _PM_PAREN_PLUGIN_RE.search(body)
|
|
93
|
+
if m3:
|
|
94
|
+
return m3.group(1)
|
|
95
|
+
m4 = _PM_PLUGIN_EQ_RE.search(text)
|
|
96
|
+
if m4:
|
|
97
|
+
return m4.group(1)
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def scan_logs(today_logs):
|
|
102
|
+
plugin_level_counts = defaultdict(Counter)
|
|
103
|
+
plugin_error_samples = defaultdict(list)
|
|
104
|
+
gateway_starts = []
|
|
105
|
+
hook_errors = []
|
|
106
|
+
subsystem_level_counts = defaultdict(Counter)
|
|
107
|
+
subsystem_error_samples = defaultdict(list)
|
|
108
|
+
plugin_diag_messages = []
|
|
109
|
+
|
|
110
|
+
for logf in today_logs:
|
|
111
|
+
try:
|
|
112
|
+
fh = open(logf, "r", errors="replace")
|
|
113
|
+
except Exception:
|
|
114
|
+
continue
|
|
115
|
+
with fh:
|
|
116
|
+
for line in fh:
|
|
117
|
+
try:
|
|
118
|
+
o = json.loads(line)
|
|
119
|
+
except Exception:
|
|
120
|
+
continue
|
|
121
|
+
plugin, sub = parse_name(o)
|
|
122
|
+
lvl = o.get("_meta", {}).get("logLevelName", "")
|
|
123
|
+
ts = o.get("time") or o.get("_meta", {}).get("date", "")
|
|
124
|
+
text = msg_text(o)
|
|
125
|
+
|
|
126
|
+
if plugin:
|
|
127
|
+
plugin_level_counts[plugin][lvl] += 1
|
|
128
|
+
if lvl in ("WARN", "ERROR", "FATAL"):
|
|
129
|
+
plugin_error_samples[plugin].append((ts, lvl, text))
|
|
130
|
+
|
|
131
|
+
if sub:
|
|
132
|
+
if sub == "gateway":
|
|
133
|
+
m = READY_RE.search(text)
|
|
134
|
+
if m:
|
|
135
|
+
gateway_starts.append((
|
|
136
|
+
parse_ts(ts), ts, m.group(2).strip(), m.group(3).strip(),
|
|
137
|
+
))
|
|
138
|
+
elif sub == "plugins":
|
|
139
|
+
plugin_diag_messages.append((ts, lvl, text))
|
|
140
|
+
target = extract_plugin_from_pm_message(text)
|
|
141
|
+
if target:
|
|
142
|
+
plugin_level_counts[target][lvl] += 1
|
|
143
|
+
if lvl in ("WARN", "ERROR", "FATAL"):
|
|
144
|
+
plugin_error_samples[target].append((ts, lvl, text))
|
|
145
|
+
elif "/" in sub and not sub.startswith(("gateway", "agent", "skills")):
|
|
146
|
+
subsystem_level_counts[sub][lvl] += 1
|
|
147
|
+
if lvl in ("WARN", "ERROR", "FATAL"):
|
|
148
|
+
subsystem_error_samples[sub].append((ts, lvl, text))
|
|
149
|
+
|
|
150
|
+
m = CHANNEL_FAIL_RE.search(text)
|
|
151
|
+
if m:
|
|
152
|
+
pid = m.group(1)
|
|
153
|
+
plugin_error_samples[pid].append(
|
|
154
|
+
(ts, "ERROR", f"channel startup failed: {m.group(2)}")
|
|
155
|
+
)
|
|
156
|
+
plugin_level_counts[pid]["ERROR"] += 1
|
|
157
|
+
|
|
158
|
+
if sub != "plugins" and not plugin:
|
|
159
|
+
target = extract_plugin_from_pm_message(text)
|
|
160
|
+
if target:
|
|
161
|
+
plugin_level_counts[target][lvl] += 1
|
|
162
|
+
if lvl in ("WARN", "ERROR", "FATAL"):
|
|
163
|
+
plugin_error_samples[target].append((ts, lvl, text))
|
|
164
|
+
|
|
165
|
+
m = HOOK_HANDLER_FAIL_RE.search(text)
|
|
166
|
+
if m:
|
|
167
|
+
hook_errors.append((ts, m.group(2), m.group(1), m.group(3).strip()[:200]))
|
|
168
|
+
continue
|
|
169
|
+
m = HOOK_ASYNC_VIOLATION_RE.search(text)
|
|
170
|
+
if m:
|
|
171
|
+
hook_errors.append((ts, m.group(2), m.group(1), "returned Promise in sync hook"))
|
|
172
|
+
continue
|
|
173
|
+
m = HOOK_AFTER_TOOL_RE.search(text)
|
|
174
|
+
if m:
|
|
175
|
+
hook_errors.append((ts, "(unknown)", m.group(1), m.group(2).strip()[:200]))
|
|
176
|
+
continue
|
|
177
|
+
m = HOOK_INTERNAL_RE.search(text)
|
|
178
|
+
if m:
|
|
179
|
+
hook_errors.append((ts, "(internal)", m.group(1), m.group(2).strip()[:200]))
|
|
180
|
+
|
|
181
|
+
return dict(
|
|
182
|
+
plugin_level_counts=plugin_level_counts,
|
|
183
|
+
plugin_error_samples=plugin_error_samples,
|
|
184
|
+
gateway_starts=gateway_starts,
|
|
185
|
+
hook_errors=hook_errors,
|
|
186
|
+
subsystem_level_counts=subsystem_level_counts,
|
|
187
|
+
subsystem_error_samples=subsystem_error_samples,
|
|
188
|
+
plugin_diag_messages=plugin_diag_messages,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_configured(config_path):
|
|
193
|
+
configured = {}
|
|
194
|
+
if config_path and os.path.isfile(config_path):
|
|
195
|
+
try:
|
|
196
|
+
with open(config_path) as f:
|
|
197
|
+
cfg = json.load(f)
|
|
198
|
+
entries = cfg.get("plugins", {}).get("entries", {}) or {}
|
|
199
|
+
for k, v in entries.items():
|
|
200
|
+
if isinstance(v, dict):
|
|
201
|
+
configured[k] = bool(v.get("enabled", False))
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
return configured
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def load_extensions(oc_home):
|
|
208
|
+
extensions = []
|
|
209
|
+
ext_dir = os.path.join(oc_home, "extensions") if oc_home else ""
|
|
210
|
+
if not (ext_dir and os.path.isdir(ext_dir)):
|
|
211
|
+
return extensions
|
|
212
|
+
for d in sorted(os.listdir(ext_dir)):
|
|
213
|
+
full = os.path.join(ext_dir, d)
|
|
214
|
+
if not os.path.isdir(full):
|
|
215
|
+
continue
|
|
216
|
+
pkg = os.path.join(full, "package.json")
|
|
217
|
+
ver = "?"
|
|
218
|
+
if os.path.isfile(pkg):
|
|
219
|
+
try:
|
|
220
|
+
with open(pkg) as f:
|
|
221
|
+
ver = json.load(f).get("version", "?")
|
|
222
|
+
except Exception:
|
|
223
|
+
pass
|
|
224
|
+
extensions.append((d, ver))
|
|
225
|
+
return extensions
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def section_state(out, scan, configured, extensions):
|
|
229
|
+
out.subsection("9.1 插件状态一致性")
|
|
230
|
+
gateway_starts = scan["gateway_starts"]
|
|
231
|
+
latest_start = gateway_starts[-1] if gateway_starts else None
|
|
232
|
+
loaded_set = set()
|
|
233
|
+
if latest_start:
|
|
234
|
+
_, _, names_str, dur_str = latest_start
|
|
235
|
+
loaded_list = [n.strip() for n in names_str.split(",")]
|
|
236
|
+
loaded_set = set(loaded_list)
|
|
237
|
+
out.item(f"已加载: {names_str} (启动 {dur_str})")
|
|
238
|
+
else:
|
|
239
|
+
out.item("今日日志中未发现 Gateway ready 行(今日未重启,一致性检查跳过)")
|
|
240
|
+
|
|
241
|
+
config_enabled = set(k for k, v in configured.items() if v)
|
|
242
|
+
config_disabled = set(k for k, v in configured.items() if not v)
|
|
243
|
+
|
|
244
|
+
missing: set = set()
|
|
245
|
+
extra: set = set()
|
|
246
|
+
if loaded_set:
|
|
247
|
+
missing = config_enabled - loaded_set
|
|
248
|
+
extra = loaded_set - config_enabled - config_disabled
|
|
249
|
+
if missing:
|
|
250
|
+
for p in sorted(missing):
|
|
251
|
+
out.item(f"{p}: 配置已启用但未出现在 loaded 列表")
|
|
252
|
+
if extra:
|
|
253
|
+
for p in sorted(extra):
|
|
254
|
+
out.item(f"{p}: 已加载但非显式配置(非显式配置)")
|
|
255
|
+
if config_disabled:
|
|
256
|
+
out.item(f"已禁用: {', '.join(sorted(config_disabled))}")
|
|
257
|
+
if not missing and not extra and not config_disabled:
|
|
258
|
+
out.item("配置与实际加载状态一致")
|
|
259
|
+
else:
|
|
260
|
+
if config_enabled:
|
|
261
|
+
out.item(f"配置已启用: {', '.join(sorted(config_enabled))}")
|
|
262
|
+
if config_disabled:
|
|
263
|
+
out.item(f"配置已禁用: {', '.join(sorted(config_disabled))}")
|
|
264
|
+
|
|
265
|
+
if extensions:
|
|
266
|
+
out.item(f"外部扩展: {', '.join(f'{n} ({v})' for n, v in extensions)}")
|
|
267
|
+
|
|
268
|
+
out.set_data("state", {
|
|
269
|
+
"loaded": sorted(loaded_set),
|
|
270
|
+
"missing": sorted(missing),
|
|
271
|
+
"extra": sorted(extra),
|
|
272
|
+
"disabled": sorted(config_disabled),
|
|
273
|
+
"extensions": [{"name": n, "version": v} for n, v in extensions],
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def section_errors(out, scan, configured):
|
|
278
|
+
out.subsection("9.2 插件错误/警告")
|
|
279
|
+
plugin_level_counts = scan["plugin_level_counts"]
|
|
280
|
+
plugin_error_samples = scan["plugin_error_samples"]
|
|
281
|
+
plugin_diag_messages = scan["plugin_diag_messages"]
|
|
282
|
+
|
|
283
|
+
all_plugins = sorted(set(configured.keys()) | set(plugin_level_counts.keys()))
|
|
284
|
+
if not all_plugins and not plugin_diag_messages:
|
|
285
|
+
out.item("(今日无插件日志数据)")
|
|
286
|
+
out.set_data("plugin_errors", {})
|
|
287
|
+
return
|
|
288
|
+
|
|
289
|
+
errors_payload = {}
|
|
290
|
+
|
|
291
|
+
def rank(p):
|
|
292
|
+
c = plugin_level_counts.get(p, Counter())
|
|
293
|
+
return (-(c.get("ERROR", 0) + c.get("FATAL", 0)), -c.get("WARN", 0), p)
|
|
294
|
+
|
|
295
|
+
has_issues = False
|
|
296
|
+
for p in sorted(all_plugins, key=rank):
|
|
297
|
+
c = plugin_level_counts.get(p, Counter())
|
|
298
|
+
err = c.get("ERROR", 0) + c.get("FATAL", 0)
|
|
299
|
+
warn = c.get("WARN", 0)
|
|
300
|
+
total = sum(c.values())
|
|
301
|
+
if err > 0:
|
|
302
|
+
has_issues = True
|
|
303
|
+
elif warn > 20:
|
|
304
|
+
has_issues = True
|
|
305
|
+
note = ""
|
|
306
|
+
if p in configured and not configured[p]:
|
|
307
|
+
note = " [disabled]"
|
|
308
|
+
elif p not in configured and p in plugin_level_counts:
|
|
309
|
+
note = " [auto/extension]"
|
|
310
|
+
out.item(f"{p}: {err} ERROR, {warn} WARN, {total} total{note}")
|
|
311
|
+
samples = plugin_error_samples.get(p, [])
|
|
312
|
+
sample_payload = []
|
|
313
|
+
if samples:
|
|
314
|
+
for ts, lvl, text in dedup_messages(samples, max_unique=999):
|
|
315
|
+
tag = {"ERROR": "E", "FATAL": "F", "WARN": "W"}.get(lvl, "?")
|
|
316
|
+
snippet = text.replace("\n", " ")
|
|
317
|
+
out.item(f" [{tag}] {fmt_hms(ts)}: {snippet}")
|
|
318
|
+
sample_payload.append({"ts": ts, "level": lvl, "msg": text[:300]})
|
|
319
|
+
if err > 0 or warn > 0 or sample_payload:
|
|
320
|
+
errors_payload[p] = {
|
|
321
|
+
"error_count": err,
|
|
322
|
+
"warn_count": warn,
|
|
323
|
+
"total": total,
|
|
324
|
+
"samples": sample_payload,
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
pm_errors = [(ts, lvl, text) for ts, lvl, text in plugin_diag_messages if lvl in ("ERROR", "FATAL")]
|
|
328
|
+
pm_warns = [(ts, lvl, text) for ts, lvl, text in plugin_diag_messages if lvl == "WARN"]
|
|
329
|
+
if pm_errors or pm_warns:
|
|
330
|
+
has_issues = True
|
|
331
|
+
out.item(f"[plugin-manager]: {len(pm_errors)} ERROR, {len(pm_warns)} WARN, "
|
|
332
|
+
f"{len(plugin_diag_messages)} total")
|
|
333
|
+
for ts, _lvl, text in dedup_messages(pm_errors, max_unique=999):
|
|
334
|
+
out.item(f" [E] {fmt_hms(ts)}: {text.replace(chr(10),' ')}")
|
|
335
|
+
for ts, _lvl, text in dedup_messages(pm_warns, max_unique=999):
|
|
336
|
+
out.item(f" [W] {fmt_hms(ts)}: {text.replace(chr(10),' ')}")
|
|
337
|
+
elif plugin_diag_messages:
|
|
338
|
+
out.item(f"[plugin-manager]: 0 ERROR, 0 WARN, {len(plugin_diag_messages)} total")
|
|
339
|
+
|
|
340
|
+
if not has_issues:
|
|
341
|
+
out.item("所有插件 ERROR=0 且 WARN<=20")
|
|
342
|
+
|
|
343
|
+
out.set_data("plugin_errors", errors_payload)
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def section_hooks(out, scan):
|
|
347
|
+
out.subsection("9.3 Hook 执行状态")
|
|
348
|
+
hook_errors = scan["hook_errors"]
|
|
349
|
+
if not hook_errors:
|
|
350
|
+
out.item("今日无 Hook 执行异常")
|
|
351
|
+
out.set_data("hook_errors", {"total": 0, "by_plugin": {}})
|
|
352
|
+
return
|
|
353
|
+
|
|
354
|
+
by_plugin = defaultdict(list)
|
|
355
|
+
for ts, plugin_id, hook_name, error_msg in hook_errors:
|
|
356
|
+
by_plugin[plugin_id].append((ts, hook_name, error_msg))
|
|
357
|
+
|
|
358
|
+
out.item(f"共 {len(hook_errors)} 次 Hook 执行异常:")
|
|
359
|
+
out.line("")
|
|
360
|
+
by_plugin_payload = {}
|
|
361
|
+
for plugin_id in sorted(by_plugin, key=lambda p: -len(by_plugin[p])):
|
|
362
|
+
entries = by_plugin[plugin_id]
|
|
363
|
+
by_hook = defaultdict(list)
|
|
364
|
+
for ts, hook_name, err in entries:
|
|
365
|
+
by_hook[hook_name].append((ts, err))
|
|
366
|
+
out.item(f" {plugin_id}: {len(entries)} 次")
|
|
367
|
+
hooks_payload = {}
|
|
368
|
+
for hook_name in sorted(by_hook, key=lambda h: -len(by_hook[h])):
|
|
369
|
+
hook_entries = by_hook[hook_name]
|
|
370
|
+
out.item(f" hook={hook_name}: {len(hook_entries)} 次")
|
|
371
|
+
last = hook_entries[-1]
|
|
372
|
+
out.item(f" 最近: {fmt_hms(last[0])} {last[1][:100]}")
|
|
373
|
+
hooks_payload[hook_name] = {
|
|
374
|
+
"count": len(hook_entries),
|
|
375
|
+
"last_ts": last[0],
|
|
376
|
+
"last_msg": last[1][:300],
|
|
377
|
+
}
|
|
378
|
+
by_plugin_payload[plugin_id] = {
|
|
379
|
+
"count": len(entries),
|
|
380
|
+
"hooks": hooks_payload,
|
|
381
|
+
}
|
|
382
|
+
out.set_data("hook_errors", {
|
|
383
|
+
"total": len(hook_errors),
|
|
384
|
+
"by_plugin": by_plugin_payload,
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def section_channels(out, scan):
|
|
389
|
+
out.subsection("9.4 Channel 子系统")
|
|
390
|
+
subsystem_level_counts = scan["subsystem_level_counts"]
|
|
391
|
+
subsystem_error_samples = scan["subsystem_error_samples"]
|
|
392
|
+
if not subsystem_level_counts:
|
|
393
|
+
out.item("(今日无 Channel 子系统日志)")
|
|
394
|
+
out.set_data("channels", {})
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
channel_groups = defaultdict(list)
|
|
398
|
+
for sub in subsystem_level_counts:
|
|
399
|
+
channel_groups[sub.split("/")[0]].append(sub)
|
|
400
|
+
|
|
401
|
+
channels_payload = {}
|
|
402
|
+
for prefix in sorted(channel_groups):
|
|
403
|
+
subs = channel_groups[prefix]
|
|
404
|
+
total_err = sum(
|
|
405
|
+
subsystem_level_counts[s].get("ERROR", 0) + subsystem_level_counts[s].get("FATAL", 0)
|
|
406
|
+
for s in subs
|
|
407
|
+
)
|
|
408
|
+
total_warn = sum(subsystem_level_counts[s].get("WARN", 0) for s in subs)
|
|
409
|
+
total_all = sum(sum(subsystem_level_counts[s].values()) for s in subs)
|
|
410
|
+
out.item(f"{prefix}: {total_err} ERROR, {total_warn} WARN, {total_all} total ({len(subs)} subsystems)")
|
|
411
|
+
if total_err > 0 or total_warn > 20:
|
|
412
|
+
for sub in sorted(subs, key=lambda s: -(subsystem_level_counts[s].get("ERROR", 0))):
|
|
413
|
+
sc = subsystem_level_counts[sub]
|
|
414
|
+
sub_err = sc.get("ERROR", 0) + sc.get("FATAL", 0)
|
|
415
|
+
sub_warn = sc.get("WARN", 0)
|
|
416
|
+
if sub_err > 0 or sub_warn > 10:
|
|
417
|
+
short_sub = sub.split("/", 1)[1] if "/" in sub else sub
|
|
418
|
+
out.item(f" {short_sub}: {sub_err}E {sub_warn}W")
|
|
419
|
+
samples = subsystem_error_samples.get(sub, [])
|
|
420
|
+
if samples:
|
|
421
|
+
for ts, _lvl, text in dedup_messages(samples, max_unique=2):
|
|
422
|
+
out.item(f" [{fmt_hms(ts)}] {text.replace(chr(10),' ')}")
|
|
423
|
+
channels_payload[prefix] = {
|
|
424
|
+
"error_count": total_err,
|
|
425
|
+
"warn_count": total_warn,
|
|
426
|
+
"total": total_all,
|
|
427
|
+
"subsystems": sorted(subs),
|
|
428
|
+
}
|
|
429
|
+
out.set_data("channels", channels_payload)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
_URL_IN_VAL_RE = re.compile(r"https?://([A-Za-z0-9][A-Za-z0-9.\-]*)(?::\d+)?")
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def walk_urls(val, out_set):
|
|
436
|
+
if isinstance(val, str):
|
|
437
|
+
for m in _URL_IN_VAL_RE.finditer(val):
|
|
438
|
+
out_set.add(m.group(1).lower())
|
|
439
|
+
elif isinstance(val, dict):
|
|
440
|
+
for v in val.values():
|
|
441
|
+
walk_urls(v, out_set)
|
|
442
|
+
elif isinstance(val, list):
|
|
443
|
+
for v in val:
|
|
444
|
+
walk_urls(v, out_set)
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def section_deps(out, config_path):
|
|
448
|
+
out.subsection("9.5 插件外部依赖")
|
|
449
|
+
plugin_deps = {}
|
|
450
|
+
if not (config_path and os.path.isfile(config_path)):
|
|
451
|
+
out.item("未发现已启用插件的外部依赖配置")
|
|
452
|
+
out.set_data("plugin_deps", {})
|
|
453
|
+
return
|
|
454
|
+
try:
|
|
455
|
+
with open(config_path) as f:
|
|
456
|
+
cfg_all = json.load(f)
|
|
457
|
+
entries = cfg_all.get("plugins", {}).get("entries", {}) or {}
|
|
458
|
+
for pid, pconf in entries.items():
|
|
459
|
+
if not isinstance(pconf, dict):
|
|
460
|
+
continue
|
|
461
|
+
if not pconf.get("enabled", False):
|
|
462
|
+
continue
|
|
463
|
+
hosts = set()
|
|
464
|
+
walk_urls(pconf, hosts)
|
|
465
|
+
hosts = {h for h in hosts if not h.startswith(("127.", "localhost", "0.0.0.0"))}
|
|
466
|
+
plugin_deps[pid] = hosts
|
|
467
|
+
except Exception:
|
|
468
|
+
pass
|
|
469
|
+
|
|
470
|
+
if not plugin_deps:
|
|
471
|
+
out.item("未发现已启用插件的外部依赖配置")
|
|
472
|
+
out.set_data("plugin_deps", {})
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
out.item(f"扫描: {len(plugin_deps)} 个已启用插件的配置")
|
|
476
|
+
deps_payload: dict = {}
|
|
477
|
+
found_any = False
|
|
478
|
+
no_dep = []
|
|
479
|
+
dep_lines = []
|
|
480
|
+
for pid in sorted(plugin_deps):
|
|
481
|
+
hosts = plugin_deps[pid]
|
|
482
|
+
host_results = []
|
|
483
|
+
if not hosts:
|
|
484
|
+
no_dep.append(pid)
|
|
485
|
+
deps_payload[pid] = {"hosts": []}
|
|
486
|
+
continue
|
|
487
|
+
found_any = True
|
|
488
|
+
for host in sorted(hosts):
|
|
489
|
+
start = datetime.now()
|
|
490
|
+
try:
|
|
491
|
+
socket.setdefaulttimeout(3)
|
|
492
|
+
socket.gethostbyname(host)
|
|
493
|
+
elapsed = (datetime.now() - start).total_seconds() * 1000
|
|
494
|
+
dep_lines.append(f" {pid} → {host}: 可达 ({elapsed:.0f}ms)")
|
|
495
|
+
host_results.append({"host": host, "reachable": True, "elapsed_ms": round(elapsed, 1)})
|
|
496
|
+
except Exception:
|
|
497
|
+
dep_lines.append(f" {pid} → {host}: FAILED (DNS 解析失败)")
|
|
498
|
+
host_results.append({"host": host, "reachable": False, "elapsed_ms": None})
|
|
499
|
+
deps_payload[pid] = {"hosts": host_results}
|
|
500
|
+
if found_any:
|
|
501
|
+
out.item("发现外部端点:")
|
|
502
|
+
for ln in dep_lines:
|
|
503
|
+
out.item(ln)
|
|
504
|
+
if no_dep:
|
|
505
|
+
out.item(f"无外部依赖的插件: {', '.join(no_dep)}")
|
|
506
|
+
out.set_data("plugin_deps", deps_payload)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def main() -> int:
|
|
510
|
+
parser = cli.build_common_parser(
|
|
511
|
+
description="模块 9:插件诊断",
|
|
512
|
+
prog="09_plugin_diag",
|
|
513
|
+
)
|
|
514
|
+
args = parser.parse_args()
|
|
515
|
+
out = output.init("plugin_diag", json_mode=args.json, no_color=args.no_color)
|
|
516
|
+
out.section("模块 9:插件诊断")
|
|
517
|
+
|
|
518
|
+
today = datetime.now().strftime("%Y-%m-%d")
|
|
519
|
+
today_logs = sorted(glob.glob(os.path.join(args.log_dir, f"openclaw-{today}.log")))
|
|
520
|
+
|
|
521
|
+
scan = scan_logs(today_logs)
|
|
522
|
+
configured = load_configured(args.config)
|
|
523
|
+
extensions = load_extensions(args.openclaw_home)
|
|
524
|
+
|
|
525
|
+
section_state(out, scan, configured, extensions)
|
|
526
|
+
section_errors(out, scan, configured)
|
|
527
|
+
section_hooks(out, scan)
|
|
528
|
+
section_channels(out, scan)
|
|
529
|
+
section_deps(out, args.config)
|
|
530
|
+
|
|
531
|
+
return out.done()
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
if __name__ == "__main__":
|
|
535
|
+
sys.exit(main())
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""模块 10:采集 shell 历史(高危命令、openclaw 命令、最近 20 条)。"""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Tuple
|
|
11
|
+
|
|
12
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
13
|
+
|
|
14
|
+
from ocdiag import cli, output
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
DANGEROUS_RE = re.compile(
|
|
18
|
+
r"rm\s+.*-rf|kill\s+.*-9|shutdown|reboot|mkfs|dd\s+if=|>\s*/dev/sd|"
|
|
19
|
+
r"pkill|killall|iptables\s+-F|ufw\s+disable|chmod\s+777",
|
|
20
|
+
re.IGNORECASE,
|
|
21
|
+
)
|
|
22
|
+
SCTL_DANGEROUS_RE = re.compile(r"systemctl\s+(stop|disable)", re.IGNORECASE)
|
|
23
|
+
OC_RE = re.compile(r"openclaw|oc ", re.IGNORECASE)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def list_history_files() -> List[str]:
|
|
27
|
+
home = os.path.expanduser("~")
|
|
28
|
+
candidates = [
|
|
29
|
+
os.path.join(home, ".bash_history"),
|
|
30
|
+
os.path.join(home, ".zsh_history"),
|
|
31
|
+
]
|
|
32
|
+
if home != "/root":
|
|
33
|
+
candidates.append("/root/.bash_history")
|
|
34
|
+
return [c for c in candidates if os.path.isfile(c)]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read_lines(path: str) -> List[Tuple[int, str]]:
|
|
38
|
+
out: List[Tuple[int, str]] = []
|
|
39
|
+
try:
|
|
40
|
+
with open(path, "r", errors="replace") as f:
|
|
41
|
+
for i, line in enumerate(f, 1):
|
|
42
|
+
out.append((i, line.rstrip("\n")))
|
|
43
|
+
except OSError:
|
|
44
|
+
pass
|
|
45
|
+
return out
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main() -> int:
|
|
49
|
+
parser = cli.build_common_parser(
|
|
50
|
+
description="模块 10:采集 shell 历史",
|
|
51
|
+
prog="10_shell_history",
|
|
52
|
+
)
|
|
53
|
+
args = parser.parse_args()
|
|
54
|
+
|
|
55
|
+
out = output.init("shell_history", json_mode=args.json, no_color=args.no_color)
|
|
56
|
+
out.section("模块 10:命令执行历史")
|
|
57
|
+
out.line(" 系统 shell 历史记录,用于判断是否有人或脚本执行过高危命令"
|
|
58
|
+
"(rm -rf、kill、systemctl stop 等)。")
|
|
59
|
+
out.line("")
|
|
60
|
+
|
|
61
|
+
history_files = list_history_files()
|
|
62
|
+
if not history_files:
|
|
63
|
+
out.item("未找到 shell 历史文件 (.bash_history / .zsh_history)")
|
|
64
|
+
out.set_data("history_files", [])
|
|
65
|
+
return out.done()
|
|
66
|
+
|
|
67
|
+
files_data = []
|
|
68
|
+
for hfile in history_files:
|
|
69
|
+
lines = read_lines(hfile)
|
|
70
|
+
total = len(lines)
|
|
71
|
+
out.item(f"{os.path.basename(hfile)} — 共 {total} 条记录")
|
|
72
|
+
|
|
73
|
+
dangerous: List[Tuple[int, str]] = []
|
|
74
|
+
for n, ln in lines:
|
|
75
|
+
if DANGEROUS_RE.search(ln) and "openclaw" not in ln.lower():
|
|
76
|
+
dangerous.append((n, ln))
|
|
77
|
+
elif SCTL_DANGEROUS_RE.search(ln) and "openclaw" not in ln.lower():
|
|
78
|
+
dangerous.append((n, ln))
|
|
79
|
+
|
|
80
|
+
if dangerous:
|
|
81
|
+
out.item(f" 高危命令: {len(dangerous)} 条 ")
|
|
82
|
+
ev = "\n".join(f"{n}: {ln}" for n, ln in dangerous)
|
|
83
|
+
out.evidence(f"{hfile} (高危)", ev)
|
|
84
|
+
else:
|
|
85
|
+
out.item(" 高危命令: 0 条")
|
|
86
|
+
|
|
87
|
+
oc_all = [(n, ln) for n, ln in lines if OC_RE.search(ln)]
|
|
88
|
+
oc_total = len(oc_all)
|
|
89
|
+
oc_cmds = oc_all[-30:]
|
|
90
|
+
if oc_total:
|
|
91
|
+
out.item(
|
|
92
|
+
f" ArkClaw 相关命令: 全文 {oc_total} 条,最近 30 条采样 {len(oc_cmds)} 条 — "
|
|
93
|
+
"用户手动执行的 openclaw 命令"
|
|
94
|
+
)
|
|
95
|
+
ev = "\n".join(f"{n}: {ln}" for n, ln in oc_cmds)
|
|
96
|
+
out.evidence(f"{hfile} (openclaw)", ev)
|
|
97
|
+
else:
|
|
98
|
+
out.item(" ArkClaw 相关命令: 0 条")
|
|
99
|
+
|
|
100
|
+
recent = lines[-20:]
|
|
101
|
+
if recent:
|
|
102
|
+
out.item(" 最近 20 条命令:")
|
|
103
|
+
ev = "\n".join(ln for _, ln in recent)
|
|
104
|
+
out.evidence(f"{hfile} (最近)", ev)
|
|
105
|
+
|
|
106
|
+
files_data.append({
|
|
107
|
+
"path": hfile,
|
|
108
|
+
"total_lines": total,
|
|
109
|
+
"dangerous_count": len(dangerous),
|
|
110
|
+
"dangerous": [{"line": n, "cmd": ln} for n, ln in dangerous],
|
|
111
|
+
"openclaw_count_total": oc_total,
|
|
112
|
+
"openclaw_count_sample_30": len(oc_cmds),
|
|
113
|
+
"recent_count": len(recent),
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
out.set_data("history_files", files_data)
|
|
117
|
+
return out.done()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|
package/diag/__init__.py
ADDED
|
File without changes
|