@lark-apaas/openclaw-scripts-diagnose-cli 0.1.4-alpha.1 → 0.1.4-alpha.2
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/dist/index.cjs +727 -182
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -23,12 +23,12 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
23
23
|
//#endregion
|
|
24
24
|
let node_process = require("node:process");
|
|
25
25
|
node_process = __toESM(node_process);
|
|
26
|
+
let node_path = require("node:path");
|
|
27
|
+
node_path = __toESM(node_path);
|
|
26
28
|
let json5 = require("json5");
|
|
27
29
|
json5 = __toESM(json5);
|
|
28
30
|
let node_fs = require("node:fs");
|
|
29
31
|
node_fs = __toESM(node_fs);
|
|
30
|
-
let node_path = require("node:path");
|
|
31
|
-
node_path = __toESM(node_path);
|
|
32
32
|
let node_child_process = require("node:child_process");
|
|
33
33
|
let node_crypto = require("node:crypto");
|
|
34
34
|
node_crypto = __toESM(node_crypto);
|
|
@@ -53,7 +53,7 @@ let json_diff = require("json-diff");
|
|
|
53
53
|
* it terse and parseable.
|
|
54
54
|
*/
|
|
55
55
|
function getVersion() {
|
|
56
|
-
return "0.1.4-alpha.
|
|
56
|
+
return "0.1.4-alpha.2";
|
|
57
57
|
}
|
|
58
58
|
//#endregion
|
|
59
59
|
//#region src/rule-engine/base.ts
|
|
@@ -105,6 +105,9 @@ function topoSort(rules) {
|
|
|
105
105
|
}
|
|
106
106
|
//#endregion
|
|
107
107
|
//#region src/utils.ts
|
|
108
|
+
function getExtensionsDir(configPath) {
|
|
109
|
+
return node_path.default.join(node_path.default.dirname(configPath), "extensions");
|
|
110
|
+
}
|
|
108
111
|
/**
|
|
109
112
|
* Canonical provider-ref for the feishu app secret. Both
|
|
110
113
|
* `feishu_default_account` (multi-agent path) and `feishu_channel`
|
|
@@ -262,6 +265,32 @@ function shell(cmd, timeoutMs = 6e4) {
|
|
|
262
265
|
}).trim();
|
|
263
266
|
}
|
|
264
267
|
//#endregion
|
|
268
|
+
//#region src/oc-runtime.ts
|
|
269
|
+
/**
|
|
270
|
+
* 探测 openclaw 运行时版本。能成功跑出版本号 = 已安装且可用。
|
|
271
|
+
*
|
|
272
|
+
* 比单纯 fs.existsSync 路径更可靠:能挡住 dangling symlink、依赖丢失、
|
|
273
|
+
* bin 是错误版本等"看似装好但跑不起来"的状态。
|
|
274
|
+
*
|
|
275
|
+
* 失败(命令缺失 / 解析失败 / 超时)返回 null。
|
|
276
|
+
*/
|
|
277
|
+
function readOpenclawRuntimeVersion() {
|
|
278
|
+
try {
|
|
279
|
+
const lines = (0, node_child_process.execSync)("openclaw --version", {
|
|
280
|
+
encoding: "utf-8",
|
|
281
|
+
timeout: 5e3,
|
|
282
|
+
stdio: [
|
|
283
|
+
"ignore",
|
|
284
|
+
"pipe",
|
|
285
|
+
"ignore"
|
|
286
|
+
]
|
|
287
|
+
}).split("\n").map((l) => l.trim()).filter(Boolean);
|
|
288
|
+
return (lines[lines.length - 1]?.match(/(?:OpenClaw\s+)?(\d+\.\d+\.\d+)/))?.[1] ?? null;
|
|
289
|
+
} catch {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
//#endregion
|
|
265
294
|
//#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
|
|
266
295
|
function __decorate(decorators, target, key, desc) {
|
|
267
296
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -270,6 +299,33 @@ function __decorate(decorators, target, key, desc) {
|
|
|
270
299
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
271
300
|
}
|
|
272
301
|
//#endregion
|
|
302
|
+
//#region src/rules/openclaw-runtime-missing.ts
|
|
303
|
+
/**
|
|
304
|
+
* doctor 入口最先执行:检查 openclaw 是否已安装且可用(执行 `openclaw --version`)。
|
|
305
|
+
*
|
|
306
|
+
* 触发条件:openclaw 不可用 AND openclaw.json 存在。
|
|
307
|
+
* 配合 `config_file_missing`(reset)形成完整覆盖:
|
|
308
|
+
* - oc 可用 + config 存在 → 都 pass,进业务规则
|
|
309
|
+
* - oc 可用 + config 缺 → 仅 config_file_missing fail(reset)
|
|
310
|
+
* - oc 缺 + config 存在 → 仅本规则 fail(user_confirm: install_openclaw)
|
|
311
|
+
* - oc 缺 + config 缺 → 仅 config_file_missing fail(reset 同时装 oc + 初始化 config)
|
|
312
|
+
*/
|
|
313
|
+
let OpenclawRuntimeMissingRule = class OpenclawRuntimeMissingRule extends DiagnoseRule {
|
|
314
|
+
validate(ctx) {
|
|
315
|
+
if (readOpenclawRuntimeVersion() != null) return { pass: true };
|
|
316
|
+
if (!node_fs.default.existsSync(ctx.configPath)) return { pass: true };
|
|
317
|
+
return {
|
|
318
|
+
pass: false,
|
|
319
|
+
action: "install_openclaw",
|
|
320
|
+
message: `openclaw 不可用(无法执行 'openclaw --version'),但 ${ctx.configPath} 已存在;请重新安装 openclaw`
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
};
|
|
324
|
+
OpenclawRuntimeMissingRule = __decorate([Rule({
|
|
325
|
+
key: "openclaw_runtime_missing",
|
|
326
|
+
repairMode: "user-confirm"
|
|
327
|
+
})], OpenclawRuntimeMissingRule);
|
|
328
|
+
//#endregion
|
|
273
329
|
//#region src/rules/multi-process-detect.ts
|
|
274
330
|
let ProcessStatusRule = class ProcessStatusRule extends DiagnoseRule {
|
|
275
331
|
validate(_ctx) {
|
|
@@ -1312,9 +1368,6 @@ function getPluginMaps(config) {
|
|
|
1312
1368
|
allow: Array.isArray(rawAllow) ? rawAllow : void 0
|
|
1313
1369
|
};
|
|
1314
1370
|
}
|
|
1315
|
-
function getExtensionsDir(configPath) {
|
|
1316
|
-
return node_path.default.join(node_path.default.dirname(configPath), "extensions");
|
|
1317
|
-
}
|
|
1318
1371
|
function hasNewMiaoda({ entries, installs, allow }) {
|
|
1319
1372
|
return asRecord(entries?.[NEW_MIAODA]) != null || asRecord(installs?.[NEW_MIAODA]) != null || (allow?.includes(NEW_MIAODA) ?? false);
|
|
1320
1373
|
}
|
|
@@ -1404,6 +1457,588 @@ function getAllow(config) {
|
|
|
1404
1457
|
return allow.filter((e) => typeof e === "string");
|
|
1405
1458
|
}
|
|
1406
1459
|
//#endregion
|
|
1460
|
+
//#region src/fs-utils.ts
|
|
1461
|
+
/**
|
|
1462
|
+
* Rename src → dst, falling back to `mv` (which handles cross-device copy)
|
|
1463
|
+
* when the kernel returns EXDEV.
|
|
1464
|
+
*
|
|
1465
|
+
* Sandbox filesystems can put sibling paths on different "devices" from
|
|
1466
|
+
* rename(2)'s point of view: bind mounts, overlayfs copy-up, and
|
|
1467
|
+
* mount-point children inside a single directory all trip EXDEV. Seen in
|
|
1468
|
+
* production when reset's atomic swap did
|
|
1469
|
+
* /home/gem/.npm-global/lib/node_modules/openclaw → openclaw.bak
|
|
1470
|
+
* and the openclaw subdir was a bind-mounted volume.
|
|
1471
|
+
*
|
|
1472
|
+
* Behavior:
|
|
1473
|
+
* - Happy path hits rename(2) — atomic, single syscall, microseconds.
|
|
1474
|
+
* - EXDEV path shells out to `mv`, which does rename() then copy+unlink
|
|
1475
|
+
* on failure. Non-atomic but correct; callers already have rollback
|
|
1476
|
+
* logic (install-openclaw restores from .bak) so loss of atomicity
|
|
1477
|
+
* only matters if the process dies mid-copy, and that's survivable.
|
|
1478
|
+
* - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
|
|
1479
|
+
* see the real problem instead of a misleading `mv` fallback failure.
|
|
1480
|
+
*/
|
|
1481
|
+
function moveSafe(src, dst) {
|
|
1482
|
+
try {
|
|
1483
|
+
node_fs.default.renameSync(src, dst);
|
|
1484
|
+
} catch (e) {
|
|
1485
|
+
if (e?.code !== "EXDEV") throw e;
|
|
1486
|
+
execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
/**
|
|
1490
|
+
* Run a shell command, re-throwing with stderr attached on failure.
|
|
1491
|
+
*
|
|
1492
|
+
* Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
|
|
1493
|
+
* callers only see "Command failed: <cmd>" with no hint of the real error
|
|
1494
|
+
* (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
|
|
1495
|
+
* sandboxed boxes is painful without the underlying message, so we pipe
|
|
1496
|
+
* stderr, capture it, and embed it in the thrown Error. stdout stays
|
|
1497
|
+
* suppressed because the commands we run here (tar/mv) are silent on
|
|
1498
|
+
* success.
|
|
1499
|
+
*/
|
|
1500
|
+
function execCaptureErr(cmd) {
|
|
1501
|
+
try {
|
|
1502
|
+
(0, node_child_process.execSync)(cmd, { stdio: [
|
|
1503
|
+
"ignore",
|
|
1504
|
+
"ignore",
|
|
1505
|
+
"pipe"
|
|
1506
|
+
] });
|
|
1507
|
+
} catch (e) {
|
|
1508
|
+
const stderr = e?.stderr;
|
|
1509
|
+
const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
|
|
1510
|
+
const base = e?.message ?? "command failed";
|
|
1511
|
+
throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
/** POSIX single-quote shell escape. Paths with embedded quotes are rare but
|
|
1515
|
+
* the token-file path conventions in sandboxes don't guarantee cleanliness. */
|
|
1516
|
+
function shellQuote(s) {
|
|
1517
|
+
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Recursively remove a path, retrying on transient overlayfs races.
|
|
1521
|
+
*
|
|
1522
|
+
* `fs.rmSync(..., { recursive: true, force: true })` walks the tree
|
|
1523
|
+
* issuing rmdir() per directory. On overlayfs (sandbox containers) a
|
|
1524
|
+
* just-emptied subdir can return EAGAIN/EBUSY/ENOTEMPTY for a short
|
|
1525
|
+
* window before whiteout propagation finishes — even though every
|
|
1526
|
+
* child has been unlinked. Same family of races as the tar/rename
|
|
1527
|
+
* issue handled by extractTarballTolerant + the install-openclaw
|
|
1528
|
+
* tmpfs swap.
|
|
1529
|
+
*
|
|
1530
|
+
* Retries with linear backoff (50 → 250 → 750 ms). Any non-transient
|
|
1531
|
+
* error (EACCES, EROFS, ENOSPC...) bubbles immediately. Returns true
|
|
1532
|
+
* if the path is absent at the end (success or never existed).
|
|
1533
|
+
*/
|
|
1534
|
+
function rmrfTolerant(target) {
|
|
1535
|
+
if (!node_fs.default.existsSync(target)) return true;
|
|
1536
|
+
const transient = new Set([
|
|
1537
|
+
"EAGAIN",
|
|
1538
|
+
"EBUSY",
|
|
1539
|
+
"ENOTEMPTY"
|
|
1540
|
+
]);
|
|
1541
|
+
const delaysMs = [
|
|
1542
|
+
50,
|
|
1543
|
+
250,
|
|
1544
|
+
750
|
|
1545
|
+
];
|
|
1546
|
+
for (let attempt = 0; attempt <= delaysMs.length; attempt++) try {
|
|
1547
|
+
node_fs.default.rmSync(target, {
|
|
1548
|
+
recursive: true,
|
|
1549
|
+
force: true
|
|
1550
|
+
});
|
|
1551
|
+
return true;
|
|
1552
|
+
} catch (e) {
|
|
1553
|
+
const code = e.code ?? "";
|
|
1554
|
+
if (!transient.has(code) || attempt === delaysMs.length) throw e;
|
|
1555
|
+
(0, node_child_process.execSync)(`sleep ${delaysMs[attempt] / 1e3}`);
|
|
1556
|
+
}
|
|
1557
|
+
return !node_fs.default.existsSync(target);
|
|
1558
|
+
}
|
|
1559
|
+
/**
|
|
1560
|
+
* Snapshot the current `openclaw.json` to `<configPath>.<YYYYMMDD_HHMMSS>.bak`
|
|
1561
|
+
* before a repair / fix flow rewrites it. Local-time stamp matches the
|
|
1562
|
+
* shell-style backup convention (`cp openclaw.json openclaw.json.$(date
|
|
1563
|
+
* +%Y%m%d_%H%M%S).bak`) so operators triaging on the sandbox see the
|
|
1564
|
+
* familiar filename pattern.
|
|
1565
|
+
*
|
|
1566
|
+
* Returns the absolute backup path on success, or `null` when:
|
|
1567
|
+
* - source doesn't exist (first-time setup; nothing to back up)
|
|
1568
|
+
* - copy fails for any reason (disk full, permission denied, etc.)
|
|
1569
|
+
*
|
|
1570
|
+
* Failure is logged to cli.log via console.error and swallowed — the
|
|
1571
|
+
* caller's repair/fix work goes ahead either way. The backup is a safety
|
|
1572
|
+
* net, not a correctness gate; better to do the repair than refuse over
|
|
1573
|
+
* a missing rollback file.
|
|
1574
|
+
*/
|
|
1575
|
+
function backupConfigSync(configPath) {
|
|
1576
|
+
if (!node_fs.default.existsSync(configPath)) {
|
|
1577
|
+
console.error(`backupConfigSync: skip — source missing path=${configPath}`);
|
|
1578
|
+
return null;
|
|
1579
|
+
}
|
|
1580
|
+
const d = /* @__PURE__ */ new Date();
|
|
1581
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
1582
|
+
const backupPath = `${configPath}.${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}.bak`;
|
|
1583
|
+
try {
|
|
1584
|
+
node_fs.default.copyFileSync(configPath, backupPath);
|
|
1585
|
+
const bytes = node_fs.default.statSync(backupPath).size;
|
|
1586
|
+
console.error(`backupConfigSync: ok src=${configPath} dst=${backupPath} bytes=${bytes}`);
|
|
1587
|
+
return backupPath;
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
|
|
1590
|
+
return null;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
/**
|
|
1594
|
+
* Extract an npm-packed gzipped tarball.
|
|
1595
|
+
*
|
|
1596
|
+
* ## The problem this works around
|
|
1597
|
+
*
|
|
1598
|
+
* Some tarballs (openclaw's among them — they're not packed by vanilla
|
|
1599
|
+
* `npm pack`) include relative symlinks inside nested .bin/ dirs whose
|
|
1600
|
+
* targets contain `..`, e.g.
|
|
1601
|
+
* node_modules/<pkg>/node_modules/.bin/foo -> ../foo/bin/cli.js
|
|
1602
|
+
*
|
|
1603
|
+
* GNU tar classifies any symlink target with `..` or a leading `/` as
|
|
1604
|
+
* "dangerous" and defers its extraction to a post-files pass, while also
|
|
1605
|
+
* needing a post-files pass to restore directory permissions/mtimes. The
|
|
1606
|
+
* two passes race: the deferred-symlink handling mutates parent-dir inodes,
|
|
1607
|
+
* then the directory stat-restore pass does `fstatat()` and the recorded
|
|
1608
|
+
* inode doesn't match, firing
|
|
1609
|
+
*
|
|
1610
|
+
* tar: <path>: Directory renamed before its status could be extracted
|
|
1611
|
+
*
|
|
1612
|
+
* from `apply_nonancestor_delayed_set_stat()` in extract.c. This is an
|
|
1613
|
+
* `ERROR` (hard-fail, exit 2) — the `--warning=no-rename-directory`
|
|
1614
|
+
* keyword controls a different, incremental-archive code path and does
|
|
1615
|
+
* NOT silence this. Reference: Paul Eggert, bug-tar 2004-04:
|
|
1616
|
+
* https://lists.gnu.org/archive/html/bug-tar/2004-04/msg00021.html
|
|
1617
|
+
*
|
|
1618
|
+
* ## The fix
|
|
1619
|
+
*
|
|
1620
|
+
* Pass `--absolute-names` (aka `-P`). Per GNU tar docs, this disables the
|
|
1621
|
+
* "normalize dangerous names" logic — including the deferred-symlink pass
|
|
1622
|
+
* that's racing us. Also stops stripping leading `/`, but our tarballs
|
|
1623
|
+
* only contain relative (`./node_modules/...`) paths so there's nothing
|
|
1624
|
+
* to strip. Safe because:
|
|
1625
|
+
* - The tarball is sha512-verified upstream (downloadWithCache)
|
|
1626
|
+
* - All entry paths are relative, no absolute-path escape risk
|
|
1627
|
+
* - All dangerous symlink targets resolve within the extracted tree
|
|
1628
|
+
*
|
|
1629
|
+
* ## Belt-and-suspenders
|
|
1630
|
+
*
|
|
1631
|
+
* If some tar variant still emits the error despite -P, we fall through
|
|
1632
|
+
* to checking the stderr pattern: if every error line is the benign
|
|
1633
|
+
* "Directory renamed …" text (no real failures like ENOSPC/EACCES/gzip
|
|
1634
|
+
* CRC/etc.), swallow exit 2. Callers MUST still verify extraction
|
|
1635
|
+
* (e.g. `fs.existsSync(path.join(dest, 'package.json'))`) — tar's
|
|
1636
|
+
* `skip_this_one = 1` after the error means some dirs missed their
|
|
1637
|
+
* mtime/mode restore, but content was written.
|
|
1638
|
+
*/
|
|
1639
|
+
function extractTarballTolerant(tarball, destDir, opts = {}) {
|
|
1640
|
+
const strip = opts.stripComponents ?? 0;
|
|
1641
|
+
const stripFlag = strip > 0 ? ` --strip-components=${strip}` : "";
|
|
1642
|
+
const cmd = `tar -xzf ${shellQuote(tarball)} -C ${shellQuote(destDir)}${stripFlag} -P`;
|
|
1643
|
+
try {
|
|
1644
|
+
execCaptureErr(cmd);
|
|
1645
|
+
return;
|
|
1646
|
+
} catch (e) {
|
|
1647
|
+
const msg = e?.message ?? "";
|
|
1648
|
+
const hasFalseAlarm = msg.includes("Directory renamed before its status could be extracted");
|
|
1649
|
+
const hasFatal = [
|
|
1650
|
+
/Cannot open/i,
|
|
1651
|
+
/Cannot mkdir/i,
|
|
1652
|
+
/Cannot create/i,
|
|
1653
|
+
/No space left on device/i,
|
|
1654
|
+
/Disk quota exceeded/i,
|
|
1655
|
+
/Permission denied/i,
|
|
1656
|
+
/Read-only file system/i,
|
|
1657
|
+
/unrecognized option/i,
|
|
1658
|
+
/gzip:/i,
|
|
1659
|
+
/Unexpected EOF/i,
|
|
1660
|
+
/Invalid argument/i
|
|
1661
|
+
].some((r) => r.test(msg));
|
|
1662
|
+
if (!hasFalseAlarm || hasFatal) throw e;
|
|
1663
|
+
console.error(`[tar] -P did not suppress "Directory renamed" on ${tarball}; tolerating (content must be verified by caller)`);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
//#endregion
|
|
1667
|
+
//#region src/rules/feishu-plugin-state-normalize.ts
|
|
1668
|
+
const PLUGIN_NAME$1 = "openclaw-lark";
|
|
1669
|
+
const BUILTIN_FEISHU = "feishu";
|
|
1670
|
+
const LEGACY_PLUGIN_NAME = "feishu-openclaw-plugin";
|
|
1671
|
+
const LEGACY_DIRS_TO_REMOVE = [LEGACY_PLUGIN_NAME, BUILTIN_FEISHU];
|
|
1672
|
+
const FEISHU_TOOLS = Object.freeze([
|
|
1673
|
+
"feishu_bitable_app",
|
|
1674
|
+
"feishu_bitable_app_table",
|
|
1675
|
+
"feishu_bitable_app_table_field",
|
|
1676
|
+
"feishu_bitable_app_table_record",
|
|
1677
|
+
"feishu_bitable_app_table_view",
|
|
1678
|
+
"feishu_calendar_calendar",
|
|
1679
|
+
"feishu_calendar_event",
|
|
1680
|
+
"feishu_calendar_event_attendee",
|
|
1681
|
+
"feishu_calendar_freebusy",
|
|
1682
|
+
"feishu_chat",
|
|
1683
|
+
"feishu_chat_members",
|
|
1684
|
+
"feishu_create_doc",
|
|
1685
|
+
"feishu_doc_comments",
|
|
1686
|
+
"feishu_doc_media",
|
|
1687
|
+
"feishu_drive_file",
|
|
1688
|
+
"feishu_fetch_doc",
|
|
1689
|
+
"feishu_get_user",
|
|
1690
|
+
"feishu_im_bot_image",
|
|
1691
|
+
"feishu_im_user_fetch_resource",
|
|
1692
|
+
"feishu_im_user_get_messages",
|
|
1693
|
+
"feishu_im_user_get_thread_messages",
|
|
1694
|
+
"feishu_im_user_message",
|
|
1695
|
+
"feishu_im_user_search_messages",
|
|
1696
|
+
"feishu_oauth",
|
|
1697
|
+
"feishu_oauth_batch_auth",
|
|
1698
|
+
"feishu_search_doc_wiki",
|
|
1699
|
+
"feishu_search_user",
|
|
1700
|
+
"feishu_sheet",
|
|
1701
|
+
"feishu_task_comment",
|
|
1702
|
+
"feishu_task_subtask",
|
|
1703
|
+
"feishu_task_task",
|
|
1704
|
+
"feishu_task_tasklist",
|
|
1705
|
+
"feishu_update_doc",
|
|
1706
|
+
"feishu_wiki_space",
|
|
1707
|
+
"feishu_wiki_space_node"
|
|
1708
|
+
]);
|
|
1709
|
+
/**
|
|
1710
|
+
* 飞书插件状态规范化:fs 上 <extDir>/openclaw-lark/ 已落盘后统一收尾环境。
|
|
1711
|
+
* 各 fail 与 repair 一一对应,详见 fails.push(...) 字符串与 repair() 调用。
|
|
1712
|
+
*/
|
|
1713
|
+
let FeishuPluginStateNormalizeRule = class FeishuPluginStateNormalizeRule extends DiagnoseRule {
|
|
1714
|
+
validate(ctx) {
|
|
1715
|
+
if (!isPluginInstalled(ctx)) return { pass: true };
|
|
1716
|
+
const fails = [];
|
|
1717
|
+
if (!isNewPluginEnabled(ctx.config)) fails.push(`plugins.entries["${PLUGIN_NAME$1}"].enabled !== true(应启用)`);
|
|
1718
|
+
if (isBuiltinFeishuEnabled(ctx.config)) fails.push("plugins.entries.feishu.enabled === true(应禁用)");
|
|
1719
|
+
if (isTopLevelMissingFeishuTools(ctx.config)) fails.push("tools.alsoAllow 缺 feishu_* tools");
|
|
1720
|
+
const legacyResiduals = findLegacyResiduals(ctx);
|
|
1721
|
+
if (legacyResiduals.length > 0) fails.push(`legacy 飞书插件残留:${legacyResiduals.join(", ")}`);
|
|
1722
|
+
if (fails.length === 0) return { pass: true };
|
|
1723
|
+
return {
|
|
1724
|
+
pass: false,
|
|
1725
|
+
message: fails.join(";")
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
repair(ctx) {
|
|
1729
|
+
setEntryEnabled(ctx.config, PLUGIN_NAME$1, true);
|
|
1730
|
+
setEntryEnabled(ctx.config, BUILTIN_FEISHU, false);
|
|
1731
|
+
ensureFeishuTools(ctx.config);
|
|
1732
|
+
cleanupLegacyResiduals(ctx);
|
|
1733
|
+
}
|
|
1734
|
+
};
|
|
1735
|
+
FeishuPluginStateNormalizeRule = __decorate([Rule({
|
|
1736
|
+
key: "feishu_plugin_state_normalize",
|
|
1737
|
+
dependsOn: ["config_syntax_check"],
|
|
1738
|
+
repairMode: "standard"
|
|
1739
|
+
})], FeishuPluginStateNormalizeRule);
|
|
1740
|
+
function isPluginInstalled(ctx) {
|
|
1741
|
+
return node_fs.default.existsSync(node_path.default.join(getExtensionsDir(ctx.configPath), PLUGIN_NAME$1));
|
|
1742
|
+
}
|
|
1743
|
+
function isNewPluginEnabled(config) {
|
|
1744
|
+
return asRecord(getNestedMap(config, "plugins", "entries")?.[PLUGIN_NAME$1])?.enabled === true;
|
|
1745
|
+
}
|
|
1746
|
+
function isBuiltinFeishuEnabled(config) {
|
|
1747
|
+
return asRecord(getNestedMap(config, "plugins", "entries")?.[BUILTIN_FEISHU])?.enabled === true;
|
|
1748
|
+
}
|
|
1749
|
+
/** 仅看顶层 tools.alsoAllow——agent 级 alsoAllow 是用户对单 agent 的精细化授权,doctor 不动。
|
|
1750
|
+
* 含 "*" 或任一 feishu_* 即视为已配。 */
|
|
1751
|
+
function isTopLevelMissingFeishuTools(config) {
|
|
1752
|
+
return !hasFeishuTool(readAlsoAllow(config));
|
|
1753
|
+
}
|
|
1754
|
+
function readAlsoAllow(host) {
|
|
1755
|
+
const tools = asRecord(host)?.tools;
|
|
1756
|
+
const arr = asRecord(tools)?.alsoAllow;
|
|
1757
|
+
if (!Array.isArray(arr)) return [];
|
|
1758
|
+
return arr.filter((e) => typeof e === "string");
|
|
1759
|
+
}
|
|
1760
|
+
function hasFeishuTool(alsoAllow) {
|
|
1761
|
+
if (alsoAllow.includes("*")) return true;
|
|
1762
|
+
return alsoAllow.some((t) => FEISHU_TOOLS.includes(t));
|
|
1763
|
+
}
|
|
1764
|
+
function findLegacyResiduals(ctx) {
|
|
1765
|
+
const found = [];
|
|
1766
|
+
const plugins = asRecord(ctx.config.plugins);
|
|
1767
|
+
if (asRecord(plugins?.entries)?.[LEGACY_PLUGIN_NAME] != null) found.push("entries[legacy]");
|
|
1768
|
+
const allow = plugins?.allow;
|
|
1769
|
+
if (Array.isArray(allow) && allow.includes(LEGACY_PLUGIN_NAME)) found.push("allow[legacy]");
|
|
1770
|
+
if (asRecord(plugins?.installs)?.[LEGACY_PLUGIN_NAME] != null) found.push("installs[legacy]");
|
|
1771
|
+
const extDir = getExtensionsDir(ctx.configPath);
|
|
1772
|
+
for (const name of LEGACY_DIRS_TO_REMOVE) if (node_fs.default.existsSync(node_path.default.join(extDir, name))) found.push(`fs/${name}`);
|
|
1773
|
+
return found;
|
|
1774
|
+
}
|
|
1775
|
+
function setEntryEnabled(config, key, enabled) {
|
|
1776
|
+
const entries = ensureRecord(ensureRecord(config, "plugins"), "entries");
|
|
1777
|
+
entries[key] = {
|
|
1778
|
+
...asRecord(entries[key]) ?? {},
|
|
1779
|
+
enabled
|
|
1780
|
+
};
|
|
1781
|
+
}
|
|
1782
|
+
function ensureFeishuTools(config) {
|
|
1783
|
+
const alsoAllow = readAlsoAllow(config);
|
|
1784
|
+
if (hasFeishuTool(alsoAllow)) return;
|
|
1785
|
+
ensureRecord(config, "tools").alsoAllow = [...new Set([...alsoAllow, ...FEISHU_TOOLS])];
|
|
1786
|
+
}
|
|
1787
|
+
function cleanupLegacyResiduals(ctx) {
|
|
1788
|
+
const plugins = asRecord(ctx.config.plugins);
|
|
1789
|
+
if (plugins) {
|
|
1790
|
+
const entries = asRecord(plugins.entries);
|
|
1791
|
+
if (entries && LEGACY_PLUGIN_NAME in entries) delete entries[LEGACY_PLUGIN_NAME];
|
|
1792
|
+
const installs = asRecord(plugins.installs);
|
|
1793
|
+
if (installs && LEGACY_PLUGIN_NAME in installs) delete installs[LEGACY_PLUGIN_NAME];
|
|
1794
|
+
const allow = plugins.allow;
|
|
1795
|
+
if (Array.isArray(allow)) {
|
|
1796
|
+
for (let i = allow.length - 1; i >= 0; i--) if (allow[i] === LEGACY_PLUGIN_NAME) allow.splice(i, 1);
|
|
1797
|
+
if (!allow.includes(PLUGIN_NAME$1)) allow.push(PLUGIN_NAME$1);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
const extDir = getExtensionsDir(ctx.configPath);
|
|
1801
|
+
for (const name of LEGACY_DIRS_TO_REMOVE) {
|
|
1802
|
+
const target = node_path.default.join(extDir, name);
|
|
1803
|
+
const rel = node_path.default.relative(extDir, target);
|
|
1804
|
+
if (!rel || rel.startsWith("..") || node_path.default.isAbsolute(rel)) continue;
|
|
1805
|
+
try {
|
|
1806
|
+
rmrfTolerant(target);
|
|
1807
|
+
} catch (e) {
|
|
1808
|
+
console.error(`[feishu_plugin_state_normalize] rmrf ${target} failed: ${e.message}`);
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
function ensureRecord(obj, key) {
|
|
1813
|
+
const cur = obj[key];
|
|
1814
|
+
if (cur != null && typeof cur === "object" && !Array.isArray(cur)) return cur;
|
|
1815
|
+
const fresh = {};
|
|
1816
|
+
obj[key] = fresh;
|
|
1817
|
+
return fresh;
|
|
1818
|
+
}
|
|
1819
|
+
//#endregion
|
|
1820
|
+
//#region src/version-compat.ts
|
|
1821
|
+
const VERSION_COMPAT_MAP = Object.freeze([
|
|
1822
|
+
{
|
|
1823
|
+
openclawLarkVersion: "2026.4.8",
|
|
1824
|
+
minOpenclawVersion: "2026.3.28"
|
|
1825
|
+
},
|
|
1826
|
+
{
|
|
1827
|
+
openclawLarkVersion: "2026.4.7",
|
|
1828
|
+
minOpenclawVersion: "2026.3.28"
|
|
1829
|
+
},
|
|
1830
|
+
{
|
|
1831
|
+
openclawLarkVersion: "2026.4.1",
|
|
1832
|
+
minOpenclawVersion: "2026.3.28"
|
|
1833
|
+
},
|
|
1834
|
+
{
|
|
1835
|
+
openclawLarkVersion: "2026.3.31",
|
|
1836
|
+
minOpenclawVersion: "2026.3.28"
|
|
1837
|
+
},
|
|
1838
|
+
{
|
|
1839
|
+
openclawLarkVersion: "2026.3.30",
|
|
1840
|
+
minOpenclawVersion: "2026.3.28"
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
openclawLarkVersion: "2026.3.29",
|
|
1844
|
+
minOpenclawVersion: "2026.3.28"
|
|
1845
|
+
},
|
|
1846
|
+
{
|
|
1847
|
+
openclawLarkVersion: "2026.3.26",
|
|
1848
|
+
minOpenclawVersion: "2026.3.22"
|
|
1849
|
+
},
|
|
1850
|
+
{
|
|
1851
|
+
openclawLarkVersion: "2026.3.25",
|
|
1852
|
+
minOpenclawVersion: "2026.3.22"
|
|
1853
|
+
},
|
|
1854
|
+
{
|
|
1855
|
+
openclawLarkVersion: "2026.3.24",
|
|
1856
|
+
minOpenclawVersion: "2026.3.22"
|
|
1857
|
+
},
|
|
1858
|
+
{
|
|
1859
|
+
openclawLarkVersion: "2026.3.18",
|
|
1860
|
+
minOpenclawVersion: "2026.2.26",
|
|
1861
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1862
|
+
},
|
|
1863
|
+
{
|
|
1864
|
+
openclawLarkVersion: "2026.3.17",
|
|
1865
|
+
minOpenclawVersion: "2026.2.26",
|
|
1866
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1867
|
+
},
|
|
1868
|
+
{
|
|
1869
|
+
openclawLarkVersion: "2026.3.15",
|
|
1870
|
+
minOpenclawVersion: "2026.2.26",
|
|
1871
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1872
|
+
},
|
|
1873
|
+
{
|
|
1874
|
+
openclawLarkVersion: "2026.3.12",
|
|
1875
|
+
minOpenclawVersion: "2026.2.26",
|
|
1876
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1877
|
+
},
|
|
1878
|
+
{
|
|
1879
|
+
openclawLarkVersion: "2026.3.10",
|
|
1880
|
+
minOpenclawVersion: "2026.2.26",
|
|
1881
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1882
|
+
},
|
|
1883
|
+
{
|
|
1884
|
+
openclawLarkVersion: "2026.3.9",
|
|
1885
|
+
minOpenclawVersion: "2026.2.26",
|
|
1886
|
+
maxOpenclawVersion: "2026.3.13"
|
|
1887
|
+
}
|
|
1888
|
+
]);
|
|
1889
|
+
/**
|
|
1890
|
+
* "YYYY.M.D" 按数值分量比较(宽松解析:缺失/非数字段视为 0,suffix 如 "-rc.1" 被忽略)。
|
|
1891
|
+
* 不能用字符串比较 —— '2026.3.18' < '2026.3.9' 字符串语义为 true。
|
|
1892
|
+
*/
|
|
1893
|
+
function compareCalVer(a, b) {
|
|
1894
|
+
const pa = parseCalVer(a);
|
|
1895
|
+
const pb = parseCalVer(b);
|
|
1896
|
+
for (let i = 0; i < 3; i++) {
|
|
1897
|
+
if (pa[i] < pb[i]) return -1;
|
|
1898
|
+
if (pa[i] > pb[i]) return 1;
|
|
1899
|
+
}
|
|
1900
|
+
return 0;
|
|
1901
|
+
}
|
|
1902
|
+
function parseCalVer(v) {
|
|
1903
|
+
const parts = v.split(".");
|
|
1904
|
+
return [
|
|
1905
|
+
toNum(parts[0]),
|
|
1906
|
+
toNum(parts[1]),
|
|
1907
|
+
toNum(parts[2])
|
|
1908
|
+
];
|
|
1909
|
+
}
|
|
1910
|
+
function toNum(s) {
|
|
1911
|
+
const n = s != null ? parseInt(s, 10) : 0;
|
|
1912
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
1913
|
+
}
|
|
1914
|
+
/** Whether the given openclaw version falls inside this plugin entry's compat range. */
|
|
1915
|
+
function compatible(entry, openclawVersion) {
|
|
1916
|
+
if (compareCalVer(openclawVersion, entry.minOpenclawVersion) < 0) return false;
|
|
1917
|
+
if (entry.maxOpenclawVersion != null && compareCalVer(openclawVersion, entry.maxOpenclawVersion) > 0) return false;
|
|
1918
|
+
return true;
|
|
1919
|
+
}
|
|
1920
|
+
/** Look up an entry by exact plugin version; undefined if not in the table. */
|
|
1921
|
+
function findEntry(pluginVersion) {
|
|
1922
|
+
return VERSION_COMPAT_MAP.find((e) => e.openclawLarkVersion === pluginVersion);
|
|
1923
|
+
}
|
|
1924
|
+
//#endregion
|
|
1925
|
+
//#region src/rules/feishu-plugin-version-compat.ts
|
|
1926
|
+
const PLUGIN_NAME = "openclaw-lark";
|
|
1927
|
+
const LEGACY_SHORT_NAMES = ["feishu-openclaw-plugin"];
|
|
1928
|
+
const FORK_SCOPES = ["@lark-apaas"];
|
|
1929
|
+
/**
|
|
1930
|
+
* 飞书插件 ↔ openclaw 版本兼容检测。
|
|
1931
|
+
*
|
|
1932
|
+
* 检测顺序(短路):
|
|
1933
|
+
* 1. fork 魔改版(@lark-apaas scope)→ pass,不查版本
|
|
1934
|
+
* 2. legacy 老插件(feishu-openclaw-plugin 短名)→ 走升级流程
|
|
1935
|
+
* 3. 官方版(@larksuite scope 或其他)→ 按 VERSION_COMPAT_MAP 校验
|
|
1936
|
+
*
|
|
1937
|
+
* 决策方向:oc < 推荐 → upgrade_openclaw;oc ≥ 推荐 → upgrade_lark。
|
|
1938
|
+
*
|
|
1939
|
+
* `repairMode: user-confirm` —— 失败结果进 failedRules.userConfirm;CLI 不
|
|
1940
|
+
* 自动执行升级,由消费方弹用户确认后调对应升级接口。
|
|
1941
|
+
*/
|
|
1942
|
+
let FeishuPluginVersionCompatRule = class FeishuPluginVersionCompatRule extends DiagnoseRule {
|
|
1943
|
+
validate(ctx) {
|
|
1944
|
+
const recommendedOc = ctx.vars.recommendedOpenclawTag;
|
|
1945
|
+
if (!recommendedOc) {
|
|
1946
|
+
console.error("[feishu_plugin_version_compat] vars.recommendedOpenclawTag 未注入,跳过本规则");
|
|
1947
|
+
return { pass: true };
|
|
1948
|
+
}
|
|
1949
|
+
const ocCur = readOpenclawRuntimeVersion();
|
|
1950
|
+
if (!ocCur) return { pass: true };
|
|
1951
|
+
const installed = detectInstalledPlugin(ctx);
|
|
1952
|
+
if (installed == null) return { pass: true };
|
|
1953
|
+
if (isForkPlugin(installed)) return { pass: true };
|
|
1954
|
+
const isLegacy = isLegacyPlugin(installed);
|
|
1955
|
+
if (!isLegacy && isVersionCompatible(installed, ocCur)) return { pass: true };
|
|
1956
|
+
return decideUpgrade({
|
|
1957
|
+
ocCur,
|
|
1958
|
+
recommendedOc,
|
|
1959
|
+
installed,
|
|
1960
|
+
isLegacy
|
|
1961
|
+
});
|
|
1962
|
+
}
|
|
1963
|
+
};
|
|
1964
|
+
FeishuPluginVersionCompatRule = __decorate([Rule({
|
|
1965
|
+
key: "feishu_plugin_version_compat",
|
|
1966
|
+
dependsOn: ["config_syntax_check"],
|
|
1967
|
+
repairMode: "user-confirm",
|
|
1968
|
+
usesVars: ["recommendedOpenclawTag"]
|
|
1969
|
+
})], FeishuPluginVersionCompatRule);
|
|
1970
|
+
function isForkPlugin(p) {
|
|
1971
|
+
return p.scope != null && FORK_SCOPES.includes(p.scope);
|
|
1972
|
+
}
|
|
1973
|
+
function isLegacyPlugin(p) {
|
|
1974
|
+
return LEGACY_SHORT_NAMES.includes(p.allowName);
|
|
1975
|
+
}
|
|
1976
|
+
/** 装了但版本读不到、版本不在表里、或不在 entry 区间内,都视为不兼容。 */
|
|
1977
|
+
function isVersionCompatible(p, ocCur) {
|
|
1978
|
+
if (!p.version) return false;
|
|
1979
|
+
const entry = findEntry(p.version);
|
|
1980
|
+
return entry != null && compatible(entry, ocCur);
|
|
1981
|
+
}
|
|
1982
|
+
function decideUpgrade(args) {
|
|
1983
|
+
const { ocCur, recommendedOc, installed, isLegacy } = args;
|
|
1984
|
+
const desc = describePlugin(installed);
|
|
1985
|
+
const prefix = isLegacy ? `检测到老飞书插件 (${desc})` : `飞书插件 ${desc} 与 openclaw@${ocCur} 不兼容`;
|
|
1986
|
+
if (compareCalVer(ocCur, recommendedOc) < 0) return {
|
|
1987
|
+
pass: false,
|
|
1988
|
+
action: "upgrade_openclaw",
|
|
1989
|
+
message: `${prefix};将 openclaw 升级到 ${recommendedOc},飞书插件会随之同步升级`
|
|
1990
|
+
};
|
|
1991
|
+
return {
|
|
1992
|
+
pass: false,
|
|
1993
|
+
action: "upgrade_lark",
|
|
1994
|
+
message: `${prefix};当前 openclaw@${ocCur} 已达推荐版本,可直接升级飞书插件`
|
|
1995
|
+
};
|
|
1996
|
+
}
|
|
1997
|
+
function describePlugin(p) {
|
|
1998
|
+
return (p.fullName ?? p.allowName) + (p.version ? `@${p.version}` : "");
|
|
1999
|
+
}
|
|
2000
|
+
function readPluginPackageJson(filePath) {
|
|
2001
|
+
try {
|
|
2002
|
+
if (!node_fs.default.existsSync(filePath)) return null;
|
|
2003
|
+
const raw = node_fs.default.readFileSync(filePath, "utf-8");
|
|
2004
|
+
const parsed = JSON.parse(raw);
|
|
2005
|
+
return {
|
|
2006
|
+
name: typeof parsed.name === "string" ? parsed.name : void 0,
|
|
2007
|
+
version: typeof parsed.version === "string" ? parsed.version : void 0
|
|
2008
|
+
};
|
|
2009
|
+
} catch {
|
|
2010
|
+
return null;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
/** "已装" = plugins.allow 含名 AND extensions/<name>/package.json 真实存在。 */
|
|
2014
|
+
function detectInstalledPlugin(ctx) {
|
|
2015
|
+
const allowRaw = asRecord(ctx.config.plugins)?.allow;
|
|
2016
|
+
const allow = Array.isArray(allowRaw) ? allowRaw.filter((e) => typeof e === "string") : [];
|
|
2017
|
+
const extDir = getExtensionsDir(ctx.configPath);
|
|
2018
|
+
const installs = getNestedMap(ctx.config, "plugins", "installs");
|
|
2019
|
+
for (const name of [PLUGIN_NAME, ...LEGACY_SHORT_NAMES]) {
|
|
2020
|
+
if (!allow.includes(name)) continue;
|
|
2021
|
+
const pkgPath = node_path.default.join(extDir, name, "package.json");
|
|
2022
|
+
if (!node_fs.default.existsSync(pkgPath)) continue;
|
|
2023
|
+
const pkg = readPluginPackageJson(pkgPath) ?? {};
|
|
2024
|
+
const installEntry = installs && asRecord(installs[name]);
|
|
2025
|
+
const fullName = pkg.name ?? extractScopedNameFromSpec(installEntry?.spec);
|
|
2026
|
+
return {
|
|
2027
|
+
allowName: name,
|
|
2028
|
+
fullName,
|
|
2029
|
+
scope: fullName?.startsWith("@") ? fullName.split("/")[0] : void 0,
|
|
2030
|
+
version: pkg.version ?? (typeof installEntry?.version === "string" ? installEntry.version : void 0)
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
return null;
|
|
2034
|
+
}
|
|
2035
|
+
/** "@scope/name@1.2.3" / "name@1.2.3" / "@scope/name" / "name" → 去掉 @version 后缀 */
|
|
2036
|
+
function extractScopedNameFromSpec(spec) {
|
|
2037
|
+
if (typeof spec !== "string") return void 0;
|
|
2038
|
+
const at = spec.indexOf("@", 1);
|
|
2039
|
+
return at === -1 ? spec : spec.slice(0, at);
|
|
2040
|
+
}
|
|
2041
|
+
//#endregion
|
|
1407
2042
|
//#region src/check.ts
|
|
1408
2043
|
/** Telemetry-aware entry: returns both the legacy CheckResult (for stdout)
|
|
1409
2044
|
* AND a DoctorReport-shape payload (for `openclaw.report_cli_run`). The
|
|
@@ -1416,7 +2051,8 @@ function runCheckImpl(input) {
|
|
|
1416
2051
|
const result = { failedRules: {
|
|
1417
2052
|
standard: [],
|
|
1418
2053
|
ai: [],
|
|
1419
|
-
reset: []
|
|
2054
|
+
reset: [],
|
|
2055
|
+
userConfirm: []
|
|
1420
2056
|
} };
|
|
1421
2057
|
const outcomes = [];
|
|
1422
2058
|
const disabledSet = new Set(input.disabledRules || []);
|
|
@@ -1516,10 +2152,11 @@ function runCheckImpl(input) {
|
|
|
1516
2152
|
outcomes.push({
|
|
1517
2153
|
rule: meta.key,
|
|
1518
2154
|
status: "failed",
|
|
1519
|
-
message: r.message
|
|
2155
|
+
message: r.message,
|
|
2156
|
+
action: r.action
|
|
1520
2157
|
});
|
|
1521
2158
|
failedKeys.add(meta.key);
|
|
1522
|
-
pushFailedRule(result, meta.repairMode, meta.key, r.message || "");
|
|
2159
|
+
pushFailedRule(result, meta.repairMode, meta.key, r.message || "", r.action);
|
|
1523
2160
|
}
|
|
1524
2161
|
}
|
|
1525
2162
|
console.error(`runCheck: end totalMs=${Date.now() - tStart} failed={standard:${result.failedRules.standard.length},ai:${result.failedRules.ai.length},reset:${result.failedRules.reset.length}}`);
|
|
@@ -1528,7 +2165,7 @@ function runCheckImpl(input) {
|
|
|
1528
2165
|
report: finalize$2(outcomes, aborted)
|
|
1529
2166
|
};
|
|
1530
2167
|
}
|
|
1531
|
-
function pushFailedRule(result, repairMode, key, detail) {
|
|
2168
|
+
function pushFailedRule(result, repairMode, key, detail, action) {
|
|
1532
2169
|
switch (repairMode) {
|
|
1533
2170
|
case "standard":
|
|
1534
2171
|
result.failedRules.standard.push(key);
|
|
@@ -1545,6 +2182,13 @@ function pushFailedRule(result, repairMode, key, detail) {
|
|
|
1545
2182
|
detail
|
|
1546
2183
|
});
|
|
1547
2184
|
break;
|
|
2185
|
+
case "user-confirm":
|
|
2186
|
+
result.failedRules.userConfirm.push({
|
|
2187
|
+
rule_name: key,
|
|
2188
|
+
detail,
|
|
2189
|
+
action
|
|
2190
|
+
});
|
|
2191
|
+
break;
|
|
1548
2192
|
}
|
|
1549
2193
|
}
|
|
1550
2194
|
function finalize$2(results, aborted) {
|
|
@@ -1587,173 +2231,6 @@ function finalize$2(results, aborted) {
|
|
|
1587
2231
|
};
|
|
1588
2232
|
}
|
|
1589
2233
|
//#endregion
|
|
1590
|
-
//#region src/fs-utils.ts
|
|
1591
|
-
/**
|
|
1592
|
-
* Rename src → dst, falling back to `mv` (which handles cross-device copy)
|
|
1593
|
-
* when the kernel returns EXDEV.
|
|
1594
|
-
*
|
|
1595
|
-
* Sandbox filesystems can put sibling paths on different "devices" from
|
|
1596
|
-
* rename(2)'s point of view: bind mounts, overlayfs copy-up, and
|
|
1597
|
-
* mount-point children inside a single directory all trip EXDEV. Seen in
|
|
1598
|
-
* production when reset's atomic swap did
|
|
1599
|
-
* /home/gem/.npm-global/lib/node_modules/openclaw → openclaw.bak
|
|
1600
|
-
* and the openclaw subdir was a bind-mounted volume.
|
|
1601
|
-
*
|
|
1602
|
-
* Behavior:
|
|
1603
|
-
* - Happy path hits rename(2) — atomic, single syscall, microseconds.
|
|
1604
|
-
* - EXDEV path shells out to `mv`, which does rename() then copy+unlink
|
|
1605
|
-
* on failure. Non-atomic but correct; callers already have rollback
|
|
1606
|
-
* logic (install-openclaw restores from .bak) so loss of atomicity
|
|
1607
|
-
* only matters if the process dies mid-copy, and that's survivable.
|
|
1608
|
-
* - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
|
|
1609
|
-
* see the real problem instead of a misleading `mv` fallback failure.
|
|
1610
|
-
*/
|
|
1611
|
-
function moveSafe(src, dst) {
|
|
1612
|
-
try {
|
|
1613
|
-
node_fs.default.renameSync(src, dst);
|
|
1614
|
-
} catch (e) {
|
|
1615
|
-
if (e?.code !== "EXDEV") throw e;
|
|
1616
|
-
execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
|
|
1617
|
-
}
|
|
1618
|
-
}
|
|
1619
|
-
/**
|
|
1620
|
-
* Run a shell command, re-throwing with stderr attached on failure.
|
|
1621
|
-
*
|
|
1622
|
-
* Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
|
|
1623
|
-
* callers only see "Command failed: <cmd>" with no hint of the real error
|
|
1624
|
-
* (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
|
|
1625
|
-
* sandboxed boxes is painful without the underlying message, so we pipe
|
|
1626
|
-
* stderr, capture it, and embed it in the thrown Error. stdout stays
|
|
1627
|
-
* suppressed because the commands we run here (tar/mv) are silent on
|
|
1628
|
-
* success.
|
|
1629
|
-
*/
|
|
1630
|
-
function execCaptureErr(cmd) {
|
|
1631
|
-
try {
|
|
1632
|
-
(0, node_child_process.execSync)(cmd, { stdio: [
|
|
1633
|
-
"ignore",
|
|
1634
|
-
"ignore",
|
|
1635
|
-
"pipe"
|
|
1636
|
-
] });
|
|
1637
|
-
} catch (e) {
|
|
1638
|
-
const stderr = e?.stderr;
|
|
1639
|
-
const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
|
|
1640
|
-
const base = e?.message ?? "command failed";
|
|
1641
|
-
throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
|
|
1642
|
-
}
|
|
1643
|
-
}
|
|
1644
|
-
/** POSIX single-quote shell escape. Paths with embedded quotes are rare but
|
|
1645
|
-
* the token-file path conventions in sandboxes don't guarantee cleanliness. */
|
|
1646
|
-
function shellQuote(s) {
|
|
1647
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1648
|
-
}
|
|
1649
|
-
/**
|
|
1650
|
-
* Snapshot the current `openclaw.json` to `<configPath>.<YYYYMMDD_HHMMSS>.bak`
|
|
1651
|
-
* before a repair / fix flow rewrites it. Local-time stamp matches the
|
|
1652
|
-
* shell-style backup convention (`cp openclaw.json openclaw.json.$(date
|
|
1653
|
-
* +%Y%m%d_%H%M%S).bak`) so operators triaging on the sandbox see the
|
|
1654
|
-
* familiar filename pattern.
|
|
1655
|
-
*
|
|
1656
|
-
* Returns the absolute backup path on success, or `null` when:
|
|
1657
|
-
* - source doesn't exist (first-time setup; nothing to back up)
|
|
1658
|
-
* - copy fails for any reason (disk full, permission denied, etc.)
|
|
1659
|
-
*
|
|
1660
|
-
* Failure is logged to cli.log via console.error and swallowed — the
|
|
1661
|
-
* caller's repair/fix work goes ahead either way. The backup is a safety
|
|
1662
|
-
* net, not a correctness gate; better to do the repair than refuse over
|
|
1663
|
-
* a missing rollback file.
|
|
1664
|
-
*/
|
|
1665
|
-
function backupConfigSync(configPath) {
|
|
1666
|
-
if (!node_fs.default.existsSync(configPath)) {
|
|
1667
|
-
console.error(`backupConfigSync: skip — source missing path=${configPath}`);
|
|
1668
|
-
return null;
|
|
1669
|
-
}
|
|
1670
|
-
const d = /* @__PURE__ */ new Date();
|
|
1671
|
-
const pad = (n) => String(n).padStart(2, "0");
|
|
1672
|
-
const backupPath = `${configPath}.${`${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}_${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`}.bak`;
|
|
1673
|
-
try {
|
|
1674
|
-
node_fs.default.copyFileSync(configPath, backupPath);
|
|
1675
|
-
const bytes = node_fs.default.statSync(backupPath).size;
|
|
1676
|
-
console.error(`backupConfigSync: ok src=${configPath} dst=${backupPath} bytes=${bytes}`);
|
|
1677
|
-
return backupPath;
|
|
1678
|
-
} catch (e) {
|
|
1679
|
-
console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
|
|
1680
|
-
return null;
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
/**
|
|
1684
|
-
* Extract an npm-packed gzipped tarball.
|
|
1685
|
-
*
|
|
1686
|
-
* ## The problem this works around
|
|
1687
|
-
*
|
|
1688
|
-
* Some tarballs (openclaw's among them — they're not packed by vanilla
|
|
1689
|
-
* `npm pack`) include relative symlinks inside nested .bin/ dirs whose
|
|
1690
|
-
* targets contain `..`, e.g.
|
|
1691
|
-
* node_modules/<pkg>/node_modules/.bin/foo -> ../foo/bin/cli.js
|
|
1692
|
-
*
|
|
1693
|
-
* GNU tar classifies any symlink target with `..` or a leading `/` as
|
|
1694
|
-
* "dangerous" and defers its extraction to a post-files pass, while also
|
|
1695
|
-
* needing a post-files pass to restore directory permissions/mtimes. The
|
|
1696
|
-
* two passes race: the deferred-symlink handling mutates parent-dir inodes,
|
|
1697
|
-
* then the directory stat-restore pass does `fstatat()` and the recorded
|
|
1698
|
-
* inode doesn't match, firing
|
|
1699
|
-
*
|
|
1700
|
-
* tar: <path>: Directory renamed before its status could be extracted
|
|
1701
|
-
*
|
|
1702
|
-
* from `apply_nonancestor_delayed_set_stat()` in extract.c. This is an
|
|
1703
|
-
* `ERROR` (hard-fail, exit 2) — the `--warning=no-rename-directory`
|
|
1704
|
-
* keyword controls a different, incremental-archive code path and does
|
|
1705
|
-
* NOT silence this. Reference: Paul Eggert, bug-tar 2004-04:
|
|
1706
|
-
* https://lists.gnu.org/archive/html/bug-tar/2004-04/msg00021.html
|
|
1707
|
-
*
|
|
1708
|
-
* ## The fix
|
|
1709
|
-
*
|
|
1710
|
-
* Pass `--absolute-names` (aka `-P`). Per GNU tar docs, this disables the
|
|
1711
|
-
* "normalize dangerous names" logic — including the deferred-symlink pass
|
|
1712
|
-
* that's racing us. Also stops stripping leading `/`, but our tarballs
|
|
1713
|
-
* only contain relative (`./node_modules/...`) paths so there's nothing
|
|
1714
|
-
* to strip. Safe because:
|
|
1715
|
-
* - The tarball is sha512-verified upstream (downloadWithCache)
|
|
1716
|
-
* - All entry paths are relative, no absolute-path escape risk
|
|
1717
|
-
* - All dangerous symlink targets resolve within the extracted tree
|
|
1718
|
-
*
|
|
1719
|
-
* ## Belt-and-suspenders
|
|
1720
|
-
*
|
|
1721
|
-
* If some tar variant still emits the error despite -P, we fall through
|
|
1722
|
-
* to checking the stderr pattern: if every error line is the benign
|
|
1723
|
-
* "Directory renamed …" text (no real failures like ENOSPC/EACCES/gzip
|
|
1724
|
-
* CRC/etc.), swallow exit 2. Callers MUST still verify extraction
|
|
1725
|
-
* (e.g. `fs.existsSync(path.join(dest, 'package.json'))`) — tar's
|
|
1726
|
-
* `skip_this_one = 1` after the error means some dirs missed their
|
|
1727
|
-
* mtime/mode restore, but content was written.
|
|
1728
|
-
*/
|
|
1729
|
-
function extractTarballTolerant(tarball, destDir, opts = {}) {
|
|
1730
|
-
const strip = opts.stripComponents ?? 0;
|
|
1731
|
-
const stripFlag = strip > 0 ? ` --strip-components=${strip}` : "";
|
|
1732
|
-
const cmd = `tar -xzf ${shellQuote(tarball)} -C ${shellQuote(destDir)}${stripFlag} -P`;
|
|
1733
|
-
try {
|
|
1734
|
-
execCaptureErr(cmd);
|
|
1735
|
-
return;
|
|
1736
|
-
} catch (e) {
|
|
1737
|
-
const msg = e?.message ?? "";
|
|
1738
|
-
const hasFalseAlarm = msg.includes("Directory renamed before its status could be extracted");
|
|
1739
|
-
const hasFatal = [
|
|
1740
|
-
/Cannot open/i,
|
|
1741
|
-
/Cannot mkdir/i,
|
|
1742
|
-
/Cannot create/i,
|
|
1743
|
-
/No space left on device/i,
|
|
1744
|
-
/Disk quota exceeded/i,
|
|
1745
|
-
/Permission denied/i,
|
|
1746
|
-
/Read-only file system/i,
|
|
1747
|
-
/unrecognized option/i,
|
|
1748
|
-
/gzip:/i,
|
|
1749
|
-
/Unexpected EOF/i,
|
|
1750
|
-
/Invalid argument/i
|
|
1751
|
-
].some((r) => r.test(msg));
|
|
1752
|
-
if (!hasFalseAlarm || hasFatal) throw e;
|
|
1753
|
-
console.error(`[tar] -P did not suppress "Directory renamed" on ${tarball}; tolerating (content must be verified by caller)`);
|
|
1754
|
-
}
|
|
1755
|
-
}
|
|
1756
|
-
//#endregion
|
|
1757
2234
|
//#region src/repair.ts
|
|
1758
2235
|
/** Telemetry-aware entry. Same per-rule pass + side effects, but also
|
|
1759
2236
|
* builds a `DoctorReport`-shape payload from per-rule revalidate
|
|
@@ -2210,22 +2687,88 @@ function startAsyncReset(ctxBase64) {
|
|
|
2210
2687
|
}
|
|
2211
2688
|
//#endregion
|
|
2212
2689
|
//#region src/oss/fetchWithDiag.ts
|
|
2690
|
+
/** Methods retried automatically on transient transport errors. POST and
|
|
2691
|
+
* PATCH are deliberately excluded: a transient ECONNRESET / ETIMEDOUT
|
|
2692
|
+
* after the request body has been written might mean the server
|
|
2693
|
+
* received and processed it (just couldn't reply) — auto-retrying
|
|
2694
|
+
* would cause duplicate side effects. AWS / GCP / Aliyun SDKs all use
|
|
2695
|
+
* the same allowlist. */
|
|
2696
|
+
const IDEMPOTENT_METHODS = new Set([
|
|
2697
|
+
"GET",
|
|
2698
|
+
"HEAD",
|
|
2699
|
+
"OPTIONS",
|
|
2700
|
+
"DELETE",
|
|
2701
|
+
"PUT"
|
|
2702
|
+
]);
|
|
2703
|
+
/** Errno codes treated as transient — almost always a stale connection
|
|
2704
|
+
* or momentary network blip that succeeds on retry. The list mirrors
|
|
2705
|
+
* what AWS / GCP / Aliyun SDKs retry by default. Only used for fetch
|
|
2706
|
+
* rejection cause; HTTP 4xx/5xx responses pass through unchanged
|
|
2707
|
+
* (caller decides whether the application-level status is retryable). */
|
|
2708
|
+
const TRANSIENT_CODES = new Set([
|
|
2709
|
+
"ECONNRESET",
|
|
2710
|
+
"ETIMEDOUT",
|
|
2711
|
+
"EPIPE",
|
|
2712
|
+
"ECONNREFUSED",
|
|
2713
|
+
"EHOSTUNREACH",
|
|
2714
|
+
"ENETUNREACH",
|
|
2715
|
+
"EAI_AGAIN"
|
|
2716
|
+
]);
|
|
2213
2717
|
async function fetchWithDiag(url, opts = {}) {
|
|
2718
|
+
const maxRetries = opts.maxRetries ?? 2;
|
|
2719
|
+
const method = (opts.init?.method ?? "GET").toUpperCase();
|
|
2720
|
+
const retrySafe = opts.retryNonIdempotent || IDEMPOTENT_METHODS.has(method);
|
|
2721
|
+
let lastErr;
|
|
2722
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) try {
|
|
2723
|
+
return await fetchOnce(url, opts);
|
|
2724
|
+
} catch (e) {
|
|
2725
|
+
lastErr = e;
|
|
2726
|
+
const code = extractCauseCode(e);
|
|
2727
|
+
if (!(code !== void 0 && TRANSIENT_CODES.has(code)) || attempt === maxRetries) throw e;
|
|
2728
|
+
if (!retrySafe) {
|
|
2729
|
+
console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code} but method=${method} is non-idempotent, NOT retrying`);
|
|
2730
|
+
throw e;
|
|
2731
|
+
}
|
|
2732
|
+
const delay = 200 * Math.pow(2, attempt) + Math.floor(Math.random() * 100);
|
|
2733
|
+
console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
|
|
2734
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
2735
|
+
}
|
|
2736
|
+
throw lastErr;
|
|
2737
|
+
}
|
|
2738
|
+
/** One fetch attempt with timeout. Throws on transport / abort failures
|
|
2739
|
+
* (caught by the retry loop above) and on its own enriched message form. */
|
|
2740
|
+
async function fetchOnce(url, opts) {
|
|
2214
2741
|
const label = opts.label ?? originOf(url);
|
|
2215
2742
|
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
2216
2743
|
const start = Date.now();
|
|
2217
2744
|
const ac = new AbortController();
|
|
2218
2745
|
const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
|
|
2219
2746
|
try {
|
|
2220
|
-
return await fetch(url, {
|
|
2747
|
+
return await fetch(url, {
|
|
2748
|
+
...opts.init,
|
|
2749
|
+
signal: ac.signal
|
|
2750
|
+
});
|
|
2221
2751
|
} catch (e) {
|
|
2222
2752
|
const durationMs = Date.now() - start;
|
|
2223
2753
|
const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
|
|
2224
|
-
|
|
2754
|
+
const enriched = /* @__PURE__ */ new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
|
|
2755
|
+
enriched.cause = e.cause ?? e;
|
|
2756
|
+
throw enriched;
|
|
2225
2757
|
} finally {
|
|
2226
2758
|
if (timer) clearTimeout(timer);
|
|
2227
2759
|
}
|
|
2228
2760
|
}
|
|
2761
|
+
/** Pull the errno-style code from the deepest cause we can reach. Used by
|
|
2762
|
+
* retry-classification only — error messages still get the human-readable
|
|
2763
|
+
* describeCause() rendering. */
|
|
2764
|
+
function extractCauseCode(e) {
|
|
2765
|
+
let cur = e.cause ?? e;
|
|
2766
|
+
for (let depth = 0; depth < 5 && cur; depth++) {
|
|
2767
|
+
const code = cur.code;
|
|
2768
|
+
if (typeof code === "string") return code;
|
|
2769
|
+
cur = cur.cause;
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2229
2772
|
/** Walk the Error.cause chain and produce a single-line summary like
|
|
2230
2773
|
* `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
|
|
2231
2774
|
function describeCause(c, depth = 0) {
|
|
@@ -3504,7 +4047,8 @@ async function runDoctor(rawCtx, opts) {
|
|
|
3504
4047
|
results.push({
|
|
3505
4048
|
rule: key,
|
|
3506
4049
|
status: "failed",
|
|
3507
|
-
message: v1.message
|
|
4050
|
+
message: v1.message,
|
|
4051
|
+
action: v1.action
|
|
3508
4052
|
});
|
|
3509
4053
|
failedKeys.add(key);
|
|
3510
4054
|
continue;
|
|
@@ -3514,7 +4058,8 @@ async function runDoctor(rawCtx, opts) {
|
|
|
3514
4058
|
results.push({
|
|
3515
4059
|
rule: key,
|
|
3516
4060
|
status: "failed",
|
|
3517
|
-
message: v1.message
|
|
4061
|
+
message: v1.message,
|
|
4062
|
+
action: v1.action
|
|
3518
4063
|
});
|
|
3519
4064
|
failedKeys.add(key);
|
|
3520
4065
|
continue;
|
|
@@ -3731,7 +4276,7 @@ async function reportCliRun(opts) {
|
|
|
3731
4276
|
//#region src/help.ts
|
|
3732
4277
|
const BIN = "mclaw-diagnose";
|
|
3733
4278
|
function versionBanner() {
|
|
3734
|
-
return `v0.1.4-alpha.
|
|
4279
|
+
return `v0.1.4-alpha.2`;
|
|
3735
4280
|
}
|
|
3736
4281
|
const COMMANDS = [
|
|
3737
4282
|
{
|