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