@lark-apaas/openclaw-scripts-diagnose-cli 0.1.4-alpha.0 → 0.1.4-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +2172 -493
- package/package.json +3 -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);
|
|
@@ -36,9 +36,26 @@ let node_os = require("node:os");
|
|
|
36
36
|
node_os = __toESM(node_os);
|
|
37
37
|
let node_stream = require("node:stream");
|
|
38
38
|
let node_stream_promises = require("node:stream/promises");
|
|
39
|
+
let _lark_apaas_http_client = require("@lark-apaas/http-client");
|
|
39
40
|
let node_assert = require("node:assert");
|
|
40
41
|
node_assert = __toESM(node_assert);
|
|
41
|
-
let
|
|
42
|
+
let json_diff = require("json-diff");
|
|
43
|
+
//#region src/version.ts
|
|
44
|
+
/**
|
|
45
|
+
* Machine-readable identifier of the running CLI build.
|
|
46
|
+
*
|
|
47
|
+
* prod build → "0.1.3" (the published semver)
|
|
48
|
+
* local-test → "local-test@2026-04-28T12:30:11.123Z"
|
|
49
|
+
*
|
|
50
|
+
* Exists separately from `help.ts`'s `versionBanner()`, which is for human
|
|
51
|
+
* display and includes prose like "(local test build, not a published
|
|
52
|
+
* release)". This one goes into telemetry payloads and log slicing — keep
|
|
53
|
+
* it terse and parseable.
|
|
54
|
+
*/
|
|
55
|
+
function getVersion() {
|
|
56
|
+
return "0.1.4-beta.0";
|
|
57
|
+
}
|
|
58
|
+
//#endregion
|
|
42
59
|
//#region src/rule-engine/base.ts
|
|
43
60
|
/** Abstract base class for all diagnose rules */
|
|
44
61
|
var DiagnoseRule = class {
|
|
@@ -88,6 +105,9 @@ function topoSort(rules) {
|
|
|
88
105
|
}
|
|
89
106
|
//#endregion
|
|
90
107
|
//#region src/utils.ts
|
|
108
|
+
function getExtensionsDir(configPath) {
|
|
109
|
+
return node_path.default.join(node_path.default.dirname(configPath), "extensions");
|
|
110
|
+
}
|
|
91
111
|
/**
|
|
92
112
|
* Canonical provider-ref for the feishu app secret. Both
|
|
93
113
|
* `feishu_default_account` (multi-agent path) and `feishu_channel`
|
|
@@ -245,6 +265,32 @@ function shell(cmd, timeoutMs = 6e4) {
|
|
|
245
265
|
}).trim();
|
|
246
266
|
}
|
|
247
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
|
|
248
294
|
//#region \0@oxc-project+runtime@0.115.0/helpers/decorate.js
|
|
249
295
|
function __decorate(decorators, target, key, desc) {
|
|
250
296
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
@@ -253,6 +299,33 @@ function __decorate(decorators, target, key, desc) {
|
|
|
253
299
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
254
300
|
}
|
|
255
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
|
|
256
329
|
//#region src/rules/multi-process-detect.ts
|
|
257
330
|
let ProcessStatusRule = class ProcessStatusRule extends DiagnoseRule {
|
|
258
331
|
validate(_ctx) {
|
|
@@ -415,7 +488,7 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
|
|
|
415
488
|
};
|
|
416
489
|
}
|
|
417
490
|
repair(ctx) {
|
|
418
|
-
const map = ctx.templateVars;
|
|
491
|
+
const map = ctx.vars.templateVars;
|
|
419
492
|
if (!map || Object.keys(map).length === 0) return;
|
|
420
493
|
replaceInPlace(ctx.config, Object.entries(map));
|
|
421
494
|
}
|
|
@@ -423,7 +496,8 @@ let TemplateVarsUnreplacedRule = class TemplateVarsUnreplacedRule extends Diagno
|
|
|
423
496
|
TemplateVarsUnreplacedRule = __decorate([Rule({
|
|
424
497
|
key: "template_vars_unreplaced",
|
|
425
498
|
dependsOn: ["config_syntax_check"],
|
|
426
|
-
repairMode: "standard"
|
|
499
|
+
repairMode: "standard",
|
|
500
|
+
usesVars: ["templateVars"]
|
|
427
501
|
})], TemplateVarsUnreplacedRule);
|
|
428
502
|
function collectPlaceholders(value, found) {
|
|
429
503
|
if (typeof value === "string") {
|
|
@@ -466,32 +540,33 @@ function applyVars(str, entries) {
|
|
|
466
540
|
}
|
|
467
541
|
//#endregion
|
|
468
542
|
//#region src/rules/model-provider.ts
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
543
|
+
const DEFAULT_API_KEY = {
|
|
544
|
+
source: "file",
|
|
545
|
+
provider: "miaoda-provider",
|
|
546
|
+
id: "value"
|
|
547
|
+
};
|
|
548
|
+
const DEFAULT_X_API_KEY_HEADER = {
|
|
549
|
+
source: "file",
|
|
550
|
+
provider: "miaoda-secret-provider",
|
|
551
|
+
id: "/models_providers_miaoda_headers_x_api_key"
|
|
552
|
+
};
|
|
553
|
+
const DEFAULT_API = "openai-completions";
|
|
554
|
+
function getExpected(vars) {
|
|
555
|
+
return {
|
|
556
|
+
baseUrl: vars.baseURL + "/api/v1/sgw/model/proxy",
|
|
557
|
+
apiKey: DEFAULT_API_KEY,
|
|
558
|
+
api: DEFAULT_API,
|
|
559
|
+
headers: { "x-api-key": DEFAULT_X_API_KEY_HEADER }
|
|
483
560
|
};
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
return vars.baseURL + "/api/v1/sgw/model/proxy";
|
|
487
|
-
}
|
|
561
|
+
}
|
|
562
|
+
let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
|
|
488
563
|
validate(ctx) {
|
|
489
564
|
const provider = getNestedMap(ctx.config, "models", "providers", "miaoda");
|
|
490
565
|
if (!provider) return {
|
|
491
566
|
pass: false,
|
|
492
567
|
message: "models.providers.miaoda not found"
|
|
493
568
|
};
|
|
494
|
-
const expected =
|
|
569
|
+
const expected = getExpected(ctx.vars);
|
|
495
570
|
if (provider.baseUrl !== expected.baseUrl) return {
|
|
496
571
|
pass: false,
|
|
497
572
|
message: "baseUrl mismatch: got " + provider.baseUrl + ", expected " + expected.baseUrl
|
|
@@ -539,7 +614,7 @@ let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
|
|
|
539
614
|
return { pass: true };
|
|
540
615
|
}
|
|
541
616
|
repair(ctx) {
|
|
542
|
-
const expected =
|
|
617
|
+
const expected = getExpected(ctx.vars);
|
|
543
618
|
setNestedValue(ctx.config, [
|
|
544
619
|
"models",
|
|
545
620
|
"providers",
|
|
@@ -565,19 +640,12 @@ let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
|
|
|
565
640
|
"headers"
|
|
566
641
|
], expected.headers);
|
|
567
642
|
}
|
|
568
|
-
getExpected(vars) {
|
|
569
|
-
return {
|
|
570
|
-
baseUrl: _ModelProviderRule.getExpectedBaseUrl(vars),
|
|
571
|
-
apiKey: _ModelProviderRule.DEFAULT_API_KEY,
|
|
572
|
-
api: _ModelProviderRule.DEFAULT_API,
|
|
573
|
-
headers: { "x-api-key": _ModelProviderRule.DEFAULT_X_API_KEY_HEADER }
|
|
574
|
-
};
|
|
575
|
-
}
|
|
576
643
|
};
|
|
577
|
-
ModelProviderRule =
|
|
644
|
+
ModelProviderRule = __decorate([Rule({
|
|
578
645
|
key: "model_provider",
|
|
579
646
|
dependsOn: ["config_syntax_check"],
|
|
580
647
|
repairMode: "standard",
|
|
648
|
+
usesVars: ["innerAPIKey", "baseURL"],
|
|
581
649
|
skipWhen: ({ hasMiaoda }) => !hasMiaoda
|
|
582
650
|
})], ModelProviderRule);
|
|
583
651
|
//#endregion
|
|
@@ -707,7 +775,8 @@ let FeishuChannelRule = class FeishuChannelRule extends DiagnoseRule {
|
|
|
707
775
|
FeishuChannelRule = __decorate([Rule({
|
|
708
776
|
key: "feishu_channel",
|
|
709
777
|
dependsOn: ["config_syntax_check", "feishu_default_account"],
|
|
710
|
-
repairMode: "standard"
|
|
778
|
+
repairMode: "standard",
|
|
779
|
+
usesVars: ["feishuAppID", "feishuAppSecret"]
|
|
711
780
|
})], FeishuChannelRule);
|
|
712
781
|
//#endregion
|
|
713
782
|
//#region src/rules/feishu-default-account.ts
|
|
@@ -848,7 +917,8 @@ let FeishuDefaultAccountRule = class FeishuDefaultAccountRule extends DiagnoseRu
|
|
|
848
917
|
FeishuDefaultAccountRule = __decorate([Rule({
|
|
849
918
|
key: "feishu_default_account",
|
|
850
919
|
dependsOn: ["config_syntax_check"],
|
|
851
|
-
repairMode: "standard"
|
|
920
|
+
repairMode: "standard",
|
|
921
|
+
usesVars: ["feishuAppID", "teamChatID"]
|
|
852
922
|
})], FeishuDefaultAccountRule);
|
|
853
923
|
function nonEmpty(v) {
|
|
854
924
|
return typeof v === "string" && v !== "" ? v : void 0;
|
|
@@ -879,23 +949,19 @@ function secretMatchesCanonical(secret) {
|
|
|
879
949
|
}
|
|
880
950
|
//#endregion
|
|
881
951
|
//#region src/rules/gateway.ts
|
|
882
|
-
|
|
952
|
+
const DEFAULT_PORT = 18789;
|
|
953
|
+
const DEFAULT_MODE = "local";
|
|
954
|
+
const DEFAULT_BIND = "loopback";
|
|
955
|
+
const DEFAULT_AUTH_MODE = "token";
|
|
956
|
+
const DEFAULT_AUTH_TOKEN = {
|
|
957
|
+
source: "file",
|
|
958
|
+
provider: "miaoda-secret-provider",
|
|
959
|
+
id: "/gateway_auth_token"
|
|
960
|
+
};
|
|
961
|
+
/** Required entries in gateway.trustedProxies. Repair appends any missing
|
|
962
|
+
* entries while preserving caller-added extras (no overwrite). */
|
|
963
|
+
const DEFAULT_TRUSTED_PROXIES = ["::1", "127.0.0.1"];
|
|
883
964
|
let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
884
|
-
static {
|
|
885
|
-
_GatewayRule = this;
|
|
886
|
-
}
|
|
887
|
-
static DEFAULT_PORT = 18789;
|
|
888
|
-
static DEFAULT_MODE = "local";
|
|
889
|
-
static DEFAULT_BIND = "loopback";
|
|
890
|
-
static DEFAULT_AUTH_MODE = "token";
|
|
891
|
-
static DEFAULT_AUTH_TOKEN = {
|
|
892
|
-
source: "file",
|
|
893
|
-
provider: "miaoda-secret-provider",
|
|
894
|
-
id: "/gateway_auth_token"
|
|
895
|
-
};
|
|
896
|
-
/** Required entries in gateway.trustedProxies. Repair appends any missing
|
|
897
|
-
* entries while preserving caller-added extras (no overwrite). */
|
|
898
|
-
static DEFAULT_TRUSTED_PROXIES = ["::1", "127.0.0.1"];
|
|
899
965
|
validate(ctx) {
|
|
900
966
|
const gateway = ctx.config.gateway;
|
|
901
967
|
if (!gateway || typeof gateway !== "object") return {
|
|
@@ -903,17 +969,17 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
903
969
|
message: "gateway not found"
|
|
904
970
|
};
|
|
905
971
|
const gw = gateway;
|
|
906
|
-
if (gw.port !==
|
|
972
|
+
if (gw.port !== DEFAULT_PORT) return {
|
|
907
973
|
pass: false,
|
|
908
|
-
message: "gateway.port mismatch: got " + gw.port + ", expected "
|
|
974
|
+
message: "gateway.port mismatch: got " + gw.port + ", expected 18789"
|
|
909
975
|
};
|
|
910
|
-
if (gw.mode !==
|
|
976
|
+
if (gw.mode !== DEFAULT_MODE) return {
|
|
911
977
|
pass: false,
|
|
912
|
-
message: "gateway.mode mismatch: got " + gw.mode + ", expected "
|
|
978
|
+
message: "gateway.mode mismatch: got " + gw.mode + ", expected local"
|
|
913
979
|
};
|
|
914
|
-
if (gw.bind !==
|
|
980
|
+
if (gw.bind !== DEFAULT_BIND) return {
|
|
915
981
|
pass: false,
|
|
916
|
-
message: "gateway.bind mismatch: got " + gw.bind + ", expected "
|
|
982
|
+
message: "gateway.bind mismatch: got " + gw.bind + ", expected loopback"
|
|
917
983
|
};
|
|
918
984
|
const auth = gw.auth;
|
|
919
985
|
if (!auth || typeof auth !== "object") return {
|
|
@@ -921,9 +987,9 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
921
987
|
message: "gateway.auth not found"
|
|
922
988
|
};
|
|
923
989
|
const authObj = auth;
|
|
924
|
-
if (authObj.mode !==
|
|
990
|
+
if (authObj.mode !== DEFAULT_AUTH_MODE) return {
|
|
925
991
|
pass: false,
|
|
926
|
-
message: "gateway.auth.mode mismatch: got " + authObj.mode + ", expected "
|
|
992
|
+
message: "gateway.auth.mode mismatch: got " + authObj.mode + ", expected token"
|
|
927
993
|
};
|
|
928
994
|
const token = authObj.token;
|
|
929
995
|
if (typeof token === "string") {
|
|
@@ -932,7 +998,7 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
932
998
|
message: "gateway.auth.token string value mismatch"
|
|
933
999
|
};
|
|
934
1000
|
} else if (typeof token === "object" && token !== null && !Array.isArray(token)) {
|
|
935
|
-
if (!matchMap(token,
|
|
1001
|
+
if (!matchMap(token, DEFAULT_AUTH_TOKEN)) return {
|
|
936
1002
|
pass: false,
|
|
937
1003
|
message: "gateway.auth.token object mismatch: got " + JSON.stringify(token)
|
|
938
1004
|
};
|
|
@@ -954,7 +1020,7 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
954
1020
|
pass: false,
|
|
955
1021
|
message: "gateway.trustedProxies missing or not an array"
|
|
956
1022
|
};
|
|
957
|
-
const missing =
|
|
1023
|
+
const missing = DEFAULT_TRUSTED_PROXIES.filter((p) => !proxies.includes(p));
|
|
958
1024
|
if (missing.length > 0) return {
|
|
959
1025
|
pass: false,
|
|
960
1026
|
message: "gateway.trustedProxies missing: " + JSON.stringify(missing)
|
|
@@ -962,19 +1028,19 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
962
1028
|
return { pass: true };
|
|
963
1029
|
}
|
|
964
1030
|
repair(ctx) {
|
|
965
|
-
setNestedValue(ctx.config, ["gateway", "port"],
|
|
966
|
-
setNestedValue(ctx.config, ["gateway", "mode"],
|
|
967
|
-
setNestedValue(ctx.config, ["gateway", "bind"],
|
|
1031
|
+
setNestedValue(ctx.config, ["gateway", "port"], DEFAULT_PORT);
|
|
1032
|
+
setNestedValue(ctx.config, ["gateway", "mode"], DEFAULT_MODE);
|
|
1033
|
+
setNestedValue(ctx.config, ["gateway", "bind"], DEFAULT_BIND);
|
|
968
1034
|
setNestedValue(ctx.config, [
|
|
969
1035
|
"gateway",
|
|
970
1036
|
"auth",
|
|
971
1037
|
"mode"
|
|
972
|
-
],
|
|
1038
|
+
], DEFAULT_AUTH_MODE);
|
|
973
1039
|
setNestedValue(ctx.config, [
|
|
974
1040
|
"gateway",
|
|
975
1041
|
"auth",
|
|
976
1042
|
"token"
|
|
977
|
-
],
|
|
1043
|
+
], DEFAULT_AUTH_TOKEN);
|
|
978
1044
|
setNestedValue(ctx.config, [
|
|
979
1045
|
"gateway",
|
|
980
1046
|
"controlUi",
|
|
@@ -983,17 +1049,18 @@ let GatewayRule = class GatewayRule extends DiagnoseRule {
|
|
|
983
1049
|
const gw = ctx.config.gateway ?? {};
|
|
984
1050
|
const current = Array.isArray(gw.trustedProxies) ? gw.trustedProxies.slice() : [];
|
|
985
1051
|
const seen = new Set(current.map((v) => String(v)));
|
|
986
|
-
for (const p of
|
|
1052
|
+
for (const p of DEFAULT_TRUSTED_PROXIES) if (!seen.has(p)) {
|
|
987
1053
|
current.push(p);
|
|
988
1054
|
seen.add(p);
|
|
989
1055
|
}
|
|
990
1056
|
setNestedValue(ctx.config, ["gateway", "trustedProxies"], current);
|
|
991
1057
|
}
|
|
992
1058
|
};
|
|
993
|
-
GatewayRule =
|
|
1059
|
+
GatewayRule = __decorate([Rule({
|
|
994
1060
|
key: "gateway",
|
|
995
1061
|
dependsOn: ["config_syntax_check"],
|
|
996
|
-
repairMode: "standard"
|
|
1062
|
+
repairMode: "standard",
|
|
1063
|
+
usesVars: ["gatewayToken"]
|
|
997
1064
|
})], GatewayRule);
|
|
998
1065
|
//#endregion
|
|
999
1066
|
//#region src/rules/allowed-origins.ts
|
|
@@ -1037,7 +1104,8 @@ let AllowedOriginsRule = class AllowedOriginsRule extends DiagnoseRule {
|
|
|
1037
1104
|
AllowedOriginsRule = __decorate([Rule({
|
|
1038
1105
|
key: "allowed_origins",
|
|
1039
1106
|
dependsOn: ["config_syntax_check"],
|
|
1040
|
-
repairMode: "standard"
|
|
1107
|
+
repairMode: "standard",
|
|
1108
|
+
usesVars: ["expectedOrigins"]
|
|
1041
1109
|
})], AllowedOriginsRule);
|
|
1042
1110
|
function getExpectedOrigins(vars) {
|
|
1043
1111
|
return Array.isArray(vars.expectedOrigins) ? vars.expectedOrigins : [];
|
|
@@ -1116,11 +1184,12 @@ JwtTokenRule = __decorate([Rule({
|
|
|
1116
1184
|
key: "jwt_token",
|
|
1117
1185
|
dependsOn: ["config_syntax_check"],
|
|
1118
1186
|
repairMode: "standard",
|
|
1187
|
+
usesVars: ["providerFilePath"],
|
|
1119
1188
|
skipWhen: ({ hasMiaoda, deps }) => !hasMiaoda || !deps.usesMiaodaProvider
|
|
1120
1189
|
})], JwtTokenRule);
|
|
1121
1190
|
//#endregion
|
|
1122
1191
|
//#region src/rules/secrets-file.ts
|
|
1123
|
-
let
|
|
1192
|
+
let SecretsFileRule = class SecretsFileRule extends DiagnoseRule {
|
|
1124
1193
|
validate(ctx) {
|
|
1125
1194
|
const filePath = ctx.vars.secretsFilePath;
|
|
1126
1195
|
if (!filePath) return {
|
|
@@ -1167,12 +1236,18 @@ let SecretsRule = class SecretsRule extends DiagnoseRule {
|
|
|
1167
1236
|
};
|
|
1168
1237
|
}
|
|
1169
1238
|
};
|
|
1170
|
-
|
|
1239
|
+
SecretsFileRule = __decorate([Rule({
|
|
1171
1240
|
key: "secrets_file",
|
|
1172
1241
|
dependsOn: ["config_syntax_check"],
|
|
1173
1242
|
repairMode: "standard",
|
|
1243
|
+
usesVars: [
|
|
1244
|
+
"secretsFilePath",
|
|
1245
|
+
"feishuAppSecret",
|
|
1246
|
+
"gatewayToken",
|
|
1247
|
+
"innerAPIKey"
|
|
1248
|
+
],
|
|
1174
1249
|
skipWhen: ({ hasMiaoda, deps }) => !hasMiaoda || !deps.usesMiaodaSecretProvider
|
|
1175
|
-
})],
|
|
1250
|
+
})], SecretsFileRule);
|
|
1176
1251
|
//#endregion
|
|
1177
1252
|
//#region src/rules/cleanup-install-backup-dirs.ts
|
|
1178
1253
|
const DIR_PREFIX = ".openclaw-install-";
|
|
@@ -1293,9 +1368,6 @@ function getPluginMaps(config) {
|
|
|
1293
1368
|
allow: Array.isArray(rawAllow) ? rawAllow : void 0
|
|
1294
1369
|
};
|
|
1295
1370
|
}
|
|
1296
|
-
function getExtensionsDir(configPath) {
|
|
1297
|
-
return node_path.default.join(node_path.default.dirname(configPath), "extensions");
|
|
1298
|
-
}
|
|
1299
1371
|
function hasNewMiaoda({ entries, installs, allow }) {
|
|
1300
1372
|
return asRecord(entries?.[NEW_MIAODA]) != null || asRecord(installs?.[NEW_MIAODA]) != null || (allow?.includes(NEW_MIAODA) ?? false);
|
|
1301
1373
|
}
|
|
@@ -1385,293 +1457,139 @@ function getAllow(config) {
|
|
|
1385
1457
|
return allow.filter((e) => typeof e === "string");
|
|
1386
1458
|
}
|
|
1387
1459
|
//#endregion
|
|
1388
|
-
//#region src/
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
const meta = rule.meta;
|
|
1411
|
-
if (disabledSet.has(meta.key)) continue;
|
|
1412
|
-
if (meta.dependsOn?.some((dep) => failedKeys.has(dep))) continue;
|
|
1413
|
-
if (meta.dependsOn?.includes("config_syntax_check") && !configParsed) try {
|
|
1414
|
-
const parsed = loadJSON5().parse(readFile(input.configPath));
|
|
1415
|
-
const deps = analyzeProviderDeps(parsed);
|
|
1416
|
-
ctx = {
|
|
1417
|
-
config: parsed,
|
|
1418
|
-
configPath: input.configPath,
|
|
1419
|
-
vars: input.vars,
|
|
1420
|
-
providerDeps: deps,
|
|
1421
|
-
templateVars: input.templateVars
|
|
1422
|
-
};
|
|
1423
|
-
configParsed = true;
|
|
1424
|
-
} catch {
|
|
1425
|
-
break;
|
|
1426
|
-
}
|
|
1427
|
-
if (meta.skipWhen && configParsed) {
|
|
1428
|
-
const hasMiaoda = shouldCheckMiaodaRules(ctx.config);
|
|
1429
|
-
if (meta.skipWhen({
|
|
1430
|
-
hasMiaoda,
|
|
1431
|
-
deps: ctx.providerDeps
|
|
1432
|
-
})) continue;
|
|
1433
|
-
}
|
|
1434
|
-
const r = rule.validate(ctx);
|
|
1435
|
-
if (!r.pass) {
|
|
1436
|
-
failedKeys.add(meta.key);
|
|
1437
|
-
pushFailedRule(result, meta.repairMode, meta.key, r.message || "");
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
return result;
|
|
1441
|
-
}
|
|
1442
|
-
function pushFailedRule(result, repairMode, key, detail) {
|
|
1443
|
-
switch (repairMode) {
|
|
1444
|
-
case "standard":
|
|
1445
|
-
result.failedRules.standard.push(key);
|
|
1446
|
-
break;
|
|
1447
|
-
case "ai":
|
|
1448
|
-
result.failedRules.ai.push({
|
|
1449
|
-
rule_name: key,
|
|
1450
|
-
detail
|
|
1451
|
-
});
|
|
1452
|
-
break;
|
|
1453
|
-
case "reset":
|
|
1454
|
-
result.failedRules.reset.push({
|
|
1455
|
-
rule_name: key,
|
|
1456
|
-
detail
|
|
1457
|
-
});
|
|
1458
|
-
break;
|
|
1459
|
-
}
|
|
1460
|
-
}
|
|
1461
|
-
//#endregion
|
|
1462
|
-
//#region src/repair.ts
|
|
1463
|
-
function runRepair(input) {
|
|
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) {
|
|
1464
1482
|
try {
|
|
1465
|
-
|
|
1466
|
-
const repairData = input.repairData || {};
|
|
1467
|
-
const rules = getAllRules();
|
|
1468
|
-
for (const rule of rules) {
|
|
1469
|
-
if (!failedSet.has(rule.meta.key)) continue;
|
|
1470
|
-
if (rule.meta.repairMode !== "standard") continue;
|
|
1471
|
-
if (rule.meta.dependsOn?.includes("config_syntax_check")) continue;
|
|
1472
|
-
rule.repair({
|
|
1473
|
-
config: {},
|
|
1474
|
-
configPath: input.configPath,
|
|
1475
|
-
vars: input.vars,
|
|
1476
|
-
providerDeps: {
|
|
1477
|
-
usesMiaodaProvider: false,
|
|
1478
|
-
usesMiaodaSecretProvider: false
|
|
1479
|
-
},
|
|
1480
|
-
templateVars: input.templateVars
|
|
1481
|
-
});
|
|
1482
|
-
}
|
|
1483
|
-
const JSON5 = loadJSON5();
|
|
1484
|
-
let config;
|
|
1485
|
-
try {
|
|
1486
|
-
config = JSON5.parse(readFile(input.configPath));
|
|
1487
|
-
} catch (e) {
|
|
1488
|
-
return {
|
|
1489
|
-
success: false,
|
|
1490
|
-
error: "cannot parse config for repair: " + e.message
|
|
1491
|
-
};
|
|
1492
|
-
}
|
|
1493
|
-
const deps = analyzeProviderDeps(config);
|
|
1494
|
-
const ctx = {
|
|
1495
|
-
config,
|
|
1496
|
-
configPath: input.configPath,
|
|
1497
|
-
vars: input.vars,
|
|
1498
|
-
providerDeps: deps,
|
|
1499
|
-
templateVars: input.templateVars
|
|
1500
|
-
};
|
|
1501
|
-
let configDirty = false;
|
|
1502
|
-
for (const rule of rules) {
|
|
1503
|
-
if (!failedSet.has(rule.meta.key)) continue;
|
|
1504
|
-
if (rule.meta.repairMode !== "standard") continue;
|
|
1505
|
-
if (!rule.meta.dependsOn?.includes("config_syntax_check")) continue;
|
|
1506
|
-
rule.repair(ctx);
|
|
1507
|
-
configDirty = true;
|
|
1508
|
-
}
|
|
1509
|
-
if (configDirty) writeFile(input.configPath, JSON.stringify(config, null, 2));
|
|
1510
|
-
if (repairData.secretsContent && input.vars.secretsFilePath) writeFile(input.vars.secretsFilePath, repairData.secretsContent);
|
|
1511
|
-
if (repairData.providerKeyContent && input.vars.providerFilePath) writeFile(input.vars.providerFilePath, repairData.providerKeyContent);
|
|
1512
|
-
if (repairData.restartCommand) try {
|
|
1513
|
-
shell(repairData.restartCommand, 3e4);
|
|
1514
|
-
} catch (e) {
|
|
1515
|
-
return {
|
|
1516
|
-
success: false,
|
|
1517
|
-
error: "restart command failed: " + e.message
|
|
1518
|
-
};
|
|
1519
|
-
}
|
|
1520
|
-
return { success: true };
|
|
1483
|
+
node_fs.default.renameSync(src, dst);
|
|
1521
1484
|
} catch (e) {
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
error: "repair failed: " + e.message
|
|
1525
|
-
};
|
|
1485
|
+
if (e?.code !== "EXDEV") throw e;
|
|
1486
|
+
execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
|
|
1526
1487
|
}
|
|
1527
1488
|
}
|
|
1528
|
-
//#endregion
|
|
1529
|
-
//#region src/paths.ts
|
|
1530
1489
|
/**
|
|
1531
|
-
*
|
|
1532
|
-
*
|
|
1533
|
-
* (
|
|
1534
|
-
*
|
|
1535
|
-
*
|
|
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.
|
|
1536
1499
|
*/
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
+
}
|
|
1540
1513
|
}
|
|
1541
|
-
|
|
1542
|
-
|
|
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, `'\\''`)}'`;
|
|
1543
1518
|
}
|
|
1544
|
-
/** Sandbox workspace root where openclaw config + agent state lives. */
|
|
1545
|
-
const WORKSPACE_DIR = "/home/gem/workspace/agent";
|
|
1546
|
-
/** File containing the provider key used by the openclaw miaoda provider. */
|
|
1547
|
-
const PROVIDER_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-provider-key";
|
|
1548
|
-
/** File containing the miaoda openclaw secrets JSON. */
|
|
1549
|
-
const SECRETS_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-openclaw-secrets.json";
|
|
1550
|
-
/** Absolute path to the openclaw config JSON. */
|
|
1551
|
-
const CONFIG_PATH = `${WORKSPACE_DIR}/openclaw.json`;
|
|
1552
|
-
//#endregion
|
|
1553
|
-
//#region src/logger.ts
|
|
1554
1519
|
/**
|
|
1555
|
-
*
|
|
1556
|
-
* `console.error` (rules, helpers, errors) or through the per-task
|
|
1557
|
-
* `makeLogger` (reset worker) — is tee'd here so operators have a single
|
|
1558
|
-
* file to tail when diagnosing a sandbox.
|
|
1520
|
+
* Recursively remove a path, retrying on transient overlayfs races.
|
|
1559
1521
|
*
|
|
1560
|
-
*
|
|
1561
|
-
* (
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
*
|
|
1566
|
-
|
|
1567
|
-
try {
|
|
1568
|
-
const dir = node_path.default.dirname(CLI_LOG_FILE);
|
|
1569
|
-
if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
|
|
1570
|
-
node_fs.default.appendFileSync(CLI_LOG_FILE, line);
|
|
1571
|
-
} catch {}
|
|
1572
|
-
}
|
|
1573
|
-
let stderrMirrorInstalled = false;
|
|
1574
|
-
/**
|
|
1575
|
-
* Install a process-wide `console.error` interceptor that mirrors each
|
|
1576
|
-
* line to BOTH the original stderr AND cli.log. Call once at CLI entry
|
|
1577
|
-
* before any subcommand dispatch; idempotent.
|
|
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.
|
|
1578
1529
|
*
|
|
1579
|
-
*
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
* helpers, and error paths therefore must route debug output through
|
|
1583
|
-
* console.error (stderr).
|
|
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).
|
|
1584
1533
|
*/
|
|
1585
|
-
function
|
|
1586
|
-
if (
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
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}`);
|
|
1600
1556
|
}
|
|
1557
|
+
return !node_fs.default.existsSync(target);
|
|
1601
1558
|
}
|
|
1602
|
-
function makeLogger(logFile) {
|
|
1603
|
-
try {
|
|
1604
|
-
const dir = node_path.default.dirname(logFile);
|
|
1605
|
-
if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
|
|
1606
|
-
} catch {}
|
|
1607
|
-
return (msg) => {
|
|
1608
|
-
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}\n`;
|
|
1609
|
-
try {
|
|
1610
|
-
node_fs.default.appendFileSync(logFile, line);
|
|
1611
|
-
} catch {}
|
|
1612
|
-
appendCliLog(line);
|
|
1613
|
-
};
|
|
1614
|
-
}
|
|
1615
|
-
//#endregion
|
|
1616
|
-
//#region src/fs-utils.ts
|
|
1617
1559
|
/**
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
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.
|
|
1620
1565
|
*
|
|
1621
|
-
*
|
|
1622
|
-
*
|
|
1623
|
-
*
|
|
1624
|
-
* production when reset's atomic swap did
|
|
1625
|
-
* /home/gem/.npm-global/lib/node_modules/openclaw → openclaw.bak
|
|
1626
|
-
* and the openclaw subdir was a bind-mounted volume.
|
|
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.)
|
|
1627
1569
|
*
|
|
1628
|
-
*
|
|
1629
|
-
*
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
* logic (install-openclaw restores from .bak) so loss of atomicity
|
|
1633
|
-
* only matters if the process dies mid-copy, and that's survivable.
|
|
1634
|
-
* - Any other error (ENOENT, EACCES, EBUSY...) rethrows as-is so callers
|
|
1635
|
-
* see the real problem instead of a misleading `mv` fallback failure.
|
|
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.
|
|
1636
1574
|
*/
|
|
1637
|
-
function
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
if (e?.code !== "EXDEV") throw e;
|
|
1642
|
-
execCaptureErr(`mv ${shellQuote(src)} ${shellQuote(dst)}`);
|
|
1575
|
+
function backupConfigSync(configPath) {
|
|
1576
|
+
if (!node_fs.default.existsSync(configPath)) {
|
|
1577
|
+
console.error(`backupConfigSync: skip — source missing path=${configPath}`);
|
|
1578
|
+
return null;
|
|
1643
1579
|
}
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
*
|
|
1648
|
-
* Node's `execSync(..., { stdio: 'ignore' })` swallows stderr entirely —
|
|
1649
|
-
* callers only see "Command failed: <cmd>" with no hint of the real error
|
|
1650
|
-
* (ENOSPC, EROFS, "unrecognized option", etc.). Production debugging on
|
|
1651
|
-
* sandboxed boxes is painful without the underlying message, so we pipe
|
|
1652
|
-
* stderr, capture it, and embed it in the thrown Error. stdout stays
|
|
1653
|
-
* suppressed because the commands we run here (tar/mv) are silent on
|
|
1654
|
-
* success.
|
|
1655
|
-
*/
|
|
1656
|
-
function execCaptureErr(cmd) {
|
|
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`;
|
|
1657
1583
|
try {
|
|
1658
|
-
(
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
] });
|
|
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;
|
|
1663
1588
|
} catch (e) {
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
const base = e?.message ?? "command failed";
|
|
1667
|
-
throw new Error(stderrStr ? `${base}\nstderr: ${stderrStr}` : base);
|
|
1589
|
+
console.error(`backupConfigSync: failed src=${configPath} message=${e.message}`);
|
|
1590
|
+
return null;
|
|
1668
1591
|
}
|
|
1669
1592
|
}
|
|
1670
|
-
/** POSIX single-quote shell escape. Paths with embedded quotes are rare but
|
|
1671
|
-
* the token-file path conventions in sandboxes don't guarantee cleanliness. */
|
|
1672
|
-
function shellQuote(s) {
|
|
1673
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
1674
|
-
}
|
|
1675
1593
|
/**
|
|
1676
1594
|
* Extract an npm-packed gzipped tarball.
|
|
1677
1595
|
*
|
|
@@ -1746,6 +1664,971 @@ function extractTarballTolerant(tarball, destDir, opts = {}) {
|
|
|
1746
1664
|
}
|
|
1747
1665
|
}
|
|
1748
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
|
|
2042
|
+
//#region src/check.ts
|
|
2043
|
+
/** Telemetry-aware entry: returns both the legacy CheckResult (for stdout)
|
|
2044
|
+
* AND a DoctorReport-shape payload (for `openclaw.report_cli_run`). The
|
|
2045
|
+
* two views are computed from the same single rule-loop pass. */
|
|
2046
|
+
function runCheckWithReport(input) {
|
|
2047
|
+
return runCheckImpl(input);
|
|
2048
|
+
}
|
|
2049
|
+
function runCheckImpl(input) {
|
|
2050
|
+
const tStart = Date.now();
|
|
2051
|
+
const result = { failedRules: {
|
|
2052
|
+
standard: [],
|
|
2053
|
+
ai: [],
|
|
2054
|
+
reset: [],
|
|
2055
|
+
userConfirm: []
|
|
2056
|
+
} };
|
|
2057
|
+
const outcomes = [];
|
|
2058
|
+
const disabledSet = new Set(input.disabledRules || []);
|
|
2059
|
+
const rules = getAllRules();
|
|
2060
|
+
const failedKeys = /* @__PURE__ */ new Set();
|
|
2061
|
+
let configParsed = false;
|
|
2062
|
+
let aborted = false;
|
|
2063
|
+
console.error(`runCheck: begin configPath=${input.configPath} disabledRules=[${[...disabledSet].join(",")}] rules=${rules.length}`);
|
|
2064
|
+
let ctx = {
|
|
2065
|
+
config: {},
|
|
2066
|
+
configPath: input.configPath,
|
|
2067
|
+
vars: input.vars,
|
|
2068
|
+
providerDeps: {
|
|
2069
|
+
usesMiaodaProvider: false,
|
|
2070
|
+
usesMiaodaSecretProvider: false
|
|
2071
|
+
}
|
|
2072
|
+
};
|
|
2073
|
+
for (const rule of rules) {
|
|
2074
|
+
const meta = rule.meta;
|
|
2075
|
+
if (disabledSet.has(meta.key)) {
|
|
2076
|
+
console.error(`rule ${meta.key}: skipped reason=disabledRules`);
|
|
2077
|
+
outcomes.push({
|
|
2078
|
+
rule: meta.key,
|
|
2079
|
+
status: "skipped",
|
|
2080
|
+
message: "disabledRules"
|
|
2081
|
+
});
|
|
2082
|
+
continue;
|
|
2083
|
+
}
|
|
2084
|
+
if (meta.dependsOn?.some((dep) => failedKeys.has(dep))) {
|
|
2085
|
+
const blockers = meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
|
|
2086
|
+
console.error(`rule ${meta.key}: skipped reason=dependsOn-failed blockers=${blockers}`);
|
|
2087
|
+
outcomes.push({
|
|
2088
|
+
rule: meta.key,
|
|
2089
|
+
status: "skipped",
|
|
2090
|
+
message: `dependsOn failed: ${blockers}`
|
|
2091
|
+
});
|
|
2092
|
+
continue;
|
|
2093
|
+
}
|
|
2094
|
+
if (meta.dependsOn?.includes("config_syntax_check") && !configParsed) try {
|
|
2095
|
+
const parsed = loadJSON5().parse(readFile(input.configPath));
|
|
2096
|
+
const deps = analyzeProviderDeps(parsed);
|
|
2097
|
+
ctx = {
|
|
2098
|
+
config: parsed,
|
|
2099
|
+
configPath: input.configPath,
|
|
2100
|
+
vars: input.vars,
|
|
2101
|
+
providerDeps: deps
|
|
2102
|
+
};
|
|
2103
|
+
configParsed = true;
|
|
2104
|
+
} catch (e) {
|
|
2105
|
+
console.error(`runCheck: config parse failed at ${meta.key} message=${e.message}`);
|
|
2106
|
+
outcomes.push({
|
|
2107
|
+
rule: meta.key,
|
|
2108
|
+
status: "error",
|
|
2109
|
+
message: "config parse failed: " + e.message
|
|
2110
|
+
});
|
|
2111
|
+
aborted = true;
|
|
2112
|
+
break;
|
|
2113
|
+
}
|
|
2114
|
+
if (meta.skipWhen && configParsed) {
|
|
2115
|
+
const hasMiaoda = shouldCheckMiaodaRules(ctx.config);
|
|
2116
|
+
if (meta.skipWhen({
|
|
2117
|
+
hasMiaoda,
|
|
2118
|
+
deps: ctx.providerDeps
|
|
2119
|
+
})) {
|
|
2120
|
+
console.error(`rule ${meta.key}: skipped reason=skipWhen`);
|
|
2121
|
+
outcomes.push({
|
|
2122
|
+
rule: meta.key,
|
|
2123
|
+
status: "skipped",
|
|
2124
|
+
message: "skipWhen"
|
|
2125
|
+
});
|
|
2126
|
+
continue;
|
|
2127
|
+
}
|
|
2128
|
+
}
|
|
2129
|
+
const t = Date.now();
|
|
2130
|
+
let r;
|
|
2131
|
+
try {
|
|
2132
|
+
r = rule.validate(ctx);
|
|
2133
|
+
} catch (e) {
|
|
2134
|
+
const msg = e.message;
|
|
2135
|
+
console.error(`rule ${meta.key}: validate -> error durationMs=${Date.now() - t} message=${msg}`);
|
|
2136
|
+
outcomes.push({
|
|
2137
|
+
rule: meta.key,
|
|
2138
|
+
status: "error",
|
|
2139
|
+
message: "validate threw: " + msg
|
|
2140
|
+
});
|
|
2141
|
+
failedKeys.add(meta.key);
|
|
2142
|
+
continue;
|
|
2143
|
+
}
|
|
2144
|
+
if (r.pass) {
|
|
2145
|
+
console.error(`rule ${meta.key}: validate -> pass durationMs=${Date.now() - t}`);
|
|
2146
|
+
outcomes.push({
|
|
2147
|
+
rule: meta.key,
|
|
2148
|
+
status: "pass"
|
|
2149
|
+
});
|
|
2150
|
+
} else {
|
|
2151
|
+
console.error(`rule ${meta.key}: validate -> failed durationMs=${Date.now() - t} message=${r.message ?? ""}`);
|
|
2152
|
+
outcomes.push({
|
|
2153
|
+
rule: meta.key,
|
|
2154
|
+
status: "failed",
|
|
2155
|
+
message: r.message,
|
|
2156
|
+
action: r.action
|
|
2157
|
+
});
|
|
2158
|
+
failedKeys.add(meta.key);
|
|
2159
|
+
pushFailedRule(result, meta.repairMode, meta.key, r.message || "", r.action);
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
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}}`);
|
|
2163
|
+
return {
|
|
2164
|
+
legacy: result,
|
|
2165
|
+
report: finalize$2(outcomes, aborted)
|
|
2166
|
+
};
|
|
2167
|
+
}
|
|
2168
|
+
function pushFailedRule(result, repairMode, key, detail, action) {
|
|
2169
|
+
switch (repairMode) {
|
|
2170
|
+
case "standard":
|
|
2171
|
+
result.failedRules.standard.push(key);
|
|
2172
|
+
break;
|
|
2173
|
+
case "ai":
|
|
2174
|
+
result.failedRules.ai.push({
|
|
2175
|
+
rule_name: key,
|
|
2176
|
+
detail
|
|
2177
|
+
});
|
|
2178
|
+
break;
|
|
2179
|
+
case "reset":
|
|
2180
|
+
result.failedRules.reset.push({
|
|
2181
|
+
rule_name: key,
|
|
2182
|
+
detail
|
|
2183
|
+
});
|
|
2184
|
+
break;
|
|
2185
|
+
case "user-confirm":
|
|
2186
|
+
result.failedRules.userConfirm.push({
|
|
2187
|
+
rule_name: key,
|
|
2188
|
+
detail,
|
|
2189
|
+
action
|
|
2190
|
+
});
|
|
2191
|
+
break;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
function finalize$2(results, aborted) {
|
|
2195
|
+
const summary = {
|
|
2196
|
+
pass: 0,
|
|
2197
|
+
failed: 0,
|
|
2198
|
+
fixed: 0,
|
|
2199
|
+
stillBroken: 0,
|
|
2200
|
+
skipped: 0,
|
|
2201
|
+
error: 0,
|
|
2202
|
+
unknown: 0
|
|
2203
|
+
};
|
|
2204
|
+
for (const r of results) switch (r.status) {
|
|
2205
|
+
case "pass":
|
|
2206
|
+
summary.pass++;
|
|
2207
|
+
break;
|
|
2208
|
+
case "failed":
|
|
2209
|
+
summary.failed++;
|
|
2210
|
+
break;
|
|
2211
|
+
case "fixed":
|
|
2212
|
+
summary.fixed++;
|
|
2213
|
+
break;
|
|
2214
|
+
case "still-broken":
|
|
2215
|
+
summary.stillBroken++;
|
|
2216
|
+
break;
|
|
2217
|
+
case "skipped":
|
|
2218
|
+
summary.skipped++;
|
|
2219
|
+
break;
|
|
2220
|
+
case "error":
|
|
2221
|
+
summary.error++;
|
|
2222
|
+
break;
|
|
2223
|
+
case "unknown":
|
|
2224
|
+
summary.unknown++;
|
|
2225
|
+
break;
|
|
2226
|
+
}
|
|
2227
|
+
return {
|
|
2228
|
+
results,
|
|
2229
|
+
summary,
|
|
2230
|
+
aborted
|
|
2231
|
+
};
|
|
2232
|
+
}
|
|
2233
|
+
//#endregion
|
|
2234
|
+
//#region src/repair.ts
|
|
2235
|
+
/** Telemetry-aware entry. Same per-rule pass + side effects, but also
|
|
2236
|
+
* builds a `DoctorReport`-shape payload from per-rule revalidate
|
|
2237
|
+
* outcomes (`fixed` / `still-broken` / `error`) so `report_cli_run`
|
|
2238
|
+
* carries the same shape doctor produces. */
|
|
2239
|
+
function runRepairWithReport(input) {
|
|
2240
|
+
return runRepairImpl(input);
|
|
2241
|
+
}
|
|
2242
|
+
function runRepairImpl(input) {
|
|
2243
|
+
const tStart = Date.now();
|
|
2244
|
+
console.error(`runRepair: begin configPath=${input.configPath} failedRules=[${(input.failedRules ?? []).join(",")}]`);
|
|
2245
|
+
const outcomes = [];
|
|
2246
|
+
let aborted = false;
|
|
2247
|
+
let legacy = { success: true };
|
|
2248
|
+
try {
|
|
2249
|
+
const failedSet = new Set(input.failedRules || []);
|
|
2250
|
+
const repairData = input.repairData || {};
|
|
2251
|
+
const rules = getAllRules();
|
|
2252
|
+
const phase1Ctx = {
|
|
2253
|
+
config: {},
|
|
2254
|
+
configPath: input.configPath,
|
|
2255
|
+
vars: input.vars,
|
|
2256
|
+
providerDeps: {
|
|
2257
|
+
usesMiaodaProvider: false,
|
|
2258
|
+
usesMiaodaSecretProvider: false
|
|
2259
|
+
}
|
|
2260
|
+
};
|
|
2261
|
+
for (const rule of rules) {
|
|
2262
|
+
if (!failedSet.has(rule.meta.key)) continue;
|
|
2263
|
+
if (rule.meta.repairMode !== "standard") continue;
|
|
2264
|
+
if (rule.meta.dependsOn?.includes("config_syntax_check")) continue;
|
|
2265
|
+
const tRepair = Date.now();
|
|
2266
|
+
try {
|
|
2267
|
+
rule.repair(phase1Ctx);
|
|
2268
|
+
} catch (e) {
|
|
2269
|
+
const msg = e.message;
|
|
2270
|
+
console.error(`rule ${rule.meta.key}: repair (phase1) -> error durationMs=${Date.now() - tRepair} message=${msg}`);
|
|
2271
|
+
outcomes.push({
|
|
2272
|
+
rule: rule.meta.key,
|
|
2273
|
+
status: "error",
|
|
2274
|
+
message: "repair threw: " + msg
|
|
2275
|
+
});
|
|
2276
|
+
aborted = true;
|
|
2277
|
+
continue;
|
|
2278
|
+
}
|
|
2279
|
+
console.error(`rule ${rule.meta.key}: repair (phase1) -> ok durationMs=${Date.now() - tRepair}`);
|
|
2280
|
+
const tRev = Date.now();
|
|
2281
|
+
try {
|
|
2282
|
+
const v = rule.validate(phase1Ctx);
|
|
2283
|
+
if (v.pass) {
|
|
2284
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase1) -> pass (fixed) durationMs=${Date.now() - tRev}`);
|
|
2285
|
+
outcomes.push({
|
|
2286
|
+
rule: rule.meta.key,
|
|
2287
|
+
status: "fixed"
|
|
2288
|
+
});
|
|
2289
|
+
} else {
|
|
2290
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase1) -> still-broken durationMs=${Date.now() - tRev} after=${v.message ?? ""}`);
|
|
2291
|
+
outcomes.push({
|
|
2292
|
+
rule: rule.meta.key,
|
|
2293
|
+
status: "still-broken",
|
|
2294
|
+
after: v.message
|
|
2295
|
+
});
|
|
2296
|
+
}
|
|
2297
|
+
} catch (e) {
|
|
2298
|
+
const msg = e.message;
|
|
2299
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase1) -> error durationMs=${Date.now() - tRev} message=${msg}`);
|
|
2300
|
+
outcomes.push({
|
|
2301
|
+
rule: rule.meta.key,
|
|
2302
|
+
status: "error",
|
|
2303
|
+
message: "post-repair validate threw: " + msg
|
|
2304
|
+
});
|
|
2305
|
+
aborted = true;
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
const JSON5 = loadJSON5();
|
|
2309
|
+
let config;
|
|
2310
|
+
try {
|
|
2311
|
+
config = JSON5.parse(readFile(input.configPath));
|
|
2312
|
+
} catch (e) {
|
|
2313
|
+
const msg = e.message;
|
|
2314
|
+
console.error(`runRepair: config parse failed message=${msg}`);
|
|
2315
|
+
outcomes.push({
|
|
2316
|
+
rule: "config_syntax_check",
|
|
2317
|
+
status: "error",
|
|
2318
|
+
message: "config parse failed: " + msg
|
|
2319
|
+
});
|
|
2320
|
+
legacy = {
|
|
2321
|
+
success: false,
|
|
2322
|
+
error: "cannot parse config for repair: " + msg
|
|
2323
|
+
};
|
|
2324
|
+
return {
|
|
2325
|
+
legacy,
|
|
2326
|
+
report: finalize$1(outcomes, true)
|
|
2327
|
+
};
|
|
2328
|
+
}
|
|
2329
|
+
const deps = analyzeProviderDeps(config);
|
|
2330
|
+
const ctx = {
|
|
2331
|
+
config,
|
|
2332
|
+
configPath: input.configPath,
|
|
2333
|
+
vars: input.vars,
|
|
2334
|
+
providerDeps: deps
|
|
2335
|
+
};
|
|
2336
|
+
let configDirty = false;
|
|
2337
|
+
for (const rule of rules) {
|
|
2338
|
+
if (!failedSet.has(rule.meta.key)) continue;
|
|
2339
|
+
if (rule.meta.repairMode !== "standard") continue;
|
|
2340
|
+
if (!rule.meta.dependsOn?.includes("config_syntax_check")) continue;
|
|
2341
|
+
const tRepair = Date.now();
|
|
2342
|
+
try {
|
|
2343
|
+
rule.repair(ctx);
|
|
2344
|
+
} catch (e) {
|
|
2345
|
+
const msg = e.message;
|
|
2346
|
+
console.error(`rule ${rule.meta.key}: repair (phase2) -> error durationMs=${Date.now() - tRepair} message=${msg}`);
|
|
2347
|
+
outcomes.push({
|
|
2348
|
+
rule: rule.meta.key,
|
|
2349
|
+
status: "error",
|
|
2350
|
+
message: "repair threw: " + msg
|
|
2351
|
+
});
|
|
2352
|
+
aborted = true;
|
|
2353
|
+
continue;
|
|
2354
|
+
}
|
|
2355
|
+
console.error(`rule ${rule.meta.key}: repair (phase2) -> ok durationMs=${Date.now() - tRepair}`);
|
|
2356
|
+
configDirty = true;
|
|
2357
|
+
const tRev = Date.now();
|
|
2358
|
+
try {
|
|
2359
|
+
const v = rule.validate(ctx);
|
|
2360
|
+
if (v.pass) {
|
|
2361
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase2) -> pass (fixed) durationMs=${Date.now() - tRev}`);
|
|
2362
|
+
outcomes.push({
|
|
2363
|
+
rule: rule.meta.key,
|
|
2364
|
+
status: "fixed"
|
|
2365
|
+
});
|
|
2366
|
+
} else {
|
|
2367
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase2) -> still-broken durationMs=${Date.now() - tRev} after=${v.message ?? ""}`);
|
|
2368
|
+
outcomes.push({
|
|
2369
|
+
rule: rule.meta.key,
|
|
2370
|
+
status: "still-broken",
|
|
2371
|
+
after: v.message
|
|
2372
|
+
});
|
|
2373
|
+
}
|
|
2374
|
+
} catch (e) {
|
|
2375
|
+
const msg = e.message;
|
|
2376
|
+
console.error(`rule ${rule.meta.key}: revalidate (phase2) -> error durationMs=${Date.now() - tRev} message=${msg}`);
|
|
2377
|
+
outcomes.push({
|
|
2378
|
+
rule: rule.meta.key,
|
|
2379
|
+
status: "error",
|
|
2380
|
+
message: "post-repair validate threw: " + msg
|
|
2381
|
+
});
|
|
2382
|
+
aborted = true;
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
if (configDirty) {
|
|
2386
|
+
backupConfigSync(input.configPath);
|
|
2387
|
+
const serialized = JSON.stringify(config, null, 2);
|
|
2388
|
+
writeFile(input.configPath, serialized);
|
|
2389
|
+
console.error(`runRepair: config writeback ok path=${input.configPath} bytes=${serialized.length}`);
|
|
2390
|
+
}
|
|
2391
|
+
if (repairData.secretsContent && input.vars.secretsFilePath) {
|
|
2392
|
+
writeFile(input.vars.secretsFilePath, repairData.secretsContent);
|
|
2393
|
+
console.error(`runRepair: secrets writeback ok path=${input.vars.secretsFilePath} bytes=${repairData.secretsContent.length}`);
|
|
2394
|
+
}
|
|
2395
|
+
if (repairData.providerKeyContent && input.vars.providerFilePath) {
|
|
2396
|
+
writeFile(input.vars.providerFilePath, repairData.providerKeyContent);
|
|
2397
|
+
console.error(`runRepair: provider key writeback ok path=${input.vars.providerFilePath} bytes=${repairData.providerKeyContent.length}`);
|
|
2398
|
+
}
|
|
2399
|
+
if (repairData.restartCommand) {
|
|
2400
|
+
const t = Date.now();
|
|
2401
|
+
try {
|
|
2402
|
+
console.error(`runRepair: restart begin cmd=${JSON.stringify(repairData.restartCommand)}`);
|
|
2403
|
+
shell(repairData.restartCommand, 3e4);
|
|
2404
|
+
console.error(`runRepair: restart ok durationMs=${Date.now() - t}`);
|
|
2405
|
+
} catch (e) {
|
|
2406
|
+
const msg = e.message;
|
|
2407
|
+
console.error(`runRepair: restart failed durationMs=${Date.now() - t} message=${msg}`);
|
|
2408
|
+
legacy = {
|
|
2409
|
+
success: false,
|
|
2410
|
+
error: "restart command failed: " + msg
|
|
2411
|
+
};
|
|
2412
|
+
return {
|
|
2413
|
+
legacy,
|
|
2414
|
+
report: finalize$1(outcomes, true)
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
}
|
|
2418
|
+
console.error(`runRepair: end success=true totalMs=${Date.now() - tStart}`);
|
|
2419
|
+
legacy = { success: true };
|
|
2420
|
+
return {
|
|
2421
|
+
legacy,
|
|
2422
|
+
report: finalize$1(outcomes, aborted)
|
|
2423
|
+
};
|
|
2424
|
+
} catch (e) {
|
|
2425
|
+
const msg = e.message;
|
|
2426
|
+
console.error(`runRepair: end success=false totalMs=${Date.now() - tStart} message=${msg}`);
|
|
2427
|
+
legacy = {
|
|
2428
|
+
success: false,
|
|
2429
|
+
error: "repair failed: " + msg
|
|
2430
|
+
};
|
|
2431
|
+
return {
|
|
2432
|
+
legacy,
|
|
2433
|
+
report: finalize$1(outcomes, true)
|
|
2434
|
+
};
|
|
2435
|
+
}
|
|
2436
|
+
}
|
|
2437
|
+
function finalize$1(results, aborted) {
|
|
2438
|
+
const summary = {
|
|
2439
|
+
pass: 0,
|
|
2440
|
+
failed: 0,
|
|
2441
|
+
fixed: 0,
|
|
2442
|
+
stillBroken: 0,
|
|
2443
|
+
skipped: 0,
|
|
2444
|
+
error: 0,
|
|
2445
|
+
unknown: 0
|
|
2446
|
+
};
|
|
2447
|
+
for (const r of results) switch (r.status) {
|
|
2448
|
+
case "pass":
|
|
2449
|
+
summary.pass++;
|
|
2450
|
+
break;
|
|
2451
|
+
case "failed":
|
|
2452
|
+
summary.failed++;
|
|
2453
|
+
break;
|
|
2454
|
+
case "fixed":
|
|
2455
|
+
summary.fixed++;
|
|
2456
|
+
break;
|
|
2457
|
+
case "still-broken":
|
|
2458
|
+
summary.stillBroken++;
|
|
2459
|
+
break;
|
|
2460
|
+
case "skipped":
|
|
2461
|
+
summary.skipped++;
|
|
2462
|
+
break;
|
|
2463
|
+
case "error":
|
|
2464
|
+
summary.error++;
|
|
2465
|
+
break;
|
|
2466
|
+
case "unknown":
|
|
2467
|
+
summary.unknown++;
|
|
2468
|
+
break;
|
|
2469
|
+
}
|
|
2470
|
+
return {
|
|
2471
|
+
results,
|
|
2472
|
+
summary,
|
|
2473
|
+
aborted
|
|
2474
|
+
};
|
|
2475
|
+
}
|
|
2476
|
+
//#endregion
|
|
2477
|
+
//#region src/paths.ts
|
|
2478
|
+
/**
|
|
2479
|
+
* Central directory for all ephemeral diagnose/reset artifacts: task status
|
|
2480
|
+
* files (`reset-<taskId>.json`) and human-readable step logs
|
|
2481
|
+
* (`reset-<taskId>.log`). Having everything under one dir makes debugging a
|
|
2482
|
+
* stuck reset much easier — `ls /tmp/openclaw-diagnose/` shows every recent
|
|
2483
|
+
* run, and each run's log is right next to its state.
|
|
2484
|
+
*/
|
|
2485
|
+
const DIAGNOSE_DIR = "/tmp/openclaw-diagnose";
|
|
2486
|
+
function resetResultFile(taskId) {
|
|
2487
|
+
return `${DIAGNOSE_DIR}/reset-${taskId}.json`;
|
|
2488
|
+
}
|
|
2489
|
+
function resetLogFile(taskId) {
|
|
2490
|
+
return `${DIAGNOSE_DIR}/reset-${taskId}.log`;
|
|
2491
|
+
}
|
|
2492
|
+
/** Sandbox workspace root where openclaw config + agent state lives. */
|
|
2493
|
+
const WORKSPACE_DIR = "/home/gem/workspace/agent";
|
|
2494
|
+
/** File containing the provider key used by the openclaw miaoda provider. */
|
|
2495
|
+
const PROVIDER_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-provider-key";
|
|
2496
|
+
/** File containing the miaoda openclaw secrets JSON. */
|
|
2497
|
+
const SECRETS_FILE_PATH = "/home/gem/workspace/.force/openclaw/miaoda-openclaw-secrets.json";
|
|
2498
|
+
/** Absolute path to the openclaw config JSON. */
|
|
2499
|
+
const CONFIG_PATH = `${WORKSPACE_DIR}/openclaw.json`;
|
|
2500
|
+
//#endregion
|
|
2501
|
+
//#region src/run-log.ts
|
|
2502
|
+
let currentRunContext;
|
|
2503
|
+
/**
|
|
2504
|
+
* Install the run context for this CLI execution. Must be called once at
|
|
2505
|
+
* the top of `main()` before any subcommand work; idempotent (last call
|
|
2506
|
+
* wins, but `main` only calls it once).
|
|
2507
|
+
*
|
|
2508
|
+
* Resolution order for `runId`:
|
|
2509
|
+
* 1. `OPENCLAW_RUN_ID` env var — set by a parent CLI process (e.g. the
|
|
2510
|
+
* `reset --async` dispatcher exports it before spawning the worker)
|
|
2511
|
+
* so cli.log lines for both processes carry the same `[run=...]`
|
|
2512
|
+
* tag. Any non-empty value is honoured verbatim.
|
|
2513
|
+
* 2. `--trace-id=<id>` flag — caller-supplied upstream trace id.
|
|
2514
|
+
* 3. Generated locally as `gen-<8 hex>`.
|
|
2515
|
+
*
|
|
2516
|
+
* Safe to call without any input — runId falls through to the generated
|
|
2517
|
+
* branch and both metadata fields stay undefined.
|
|
2518
|
+
*/
|
|
2519
|
+
function setRunContext(opts) {
|
|
2520
|
+
const inherited = process.env.OPENCLAW_RUN_ID;
|
|
2521
|
+
if (inherited && inherited !== "") currentRunContext = {
|
|
2522
|
+
runId: inherited,
|
|
2523
|
+
caller: opts.caller,
|
|
2524
|
+
traceId: opts.traceId,
|
|
2525
|
+
generated: false
|
|
2526
|
+
};
|
|
2527
|
+
else if (opts.traceId) currentRunContext = {
|
|
2528
|
+
runId: opts.traceId,
|
|
2529
|
+
caller: opts.caller,
|
|
2530
|
+
traceId: opts.traceId,
|
|
2531
|
+
generated: false
|
|
2532
|
+
};
|
|
2533
|
+
else currentRunContext = {
|
|
2534
|
+
runId: "gen-" + (0, node_crypto.randomBytes)(4).toString("hex"),
|
|
2535
|
+
caller: opts.caller,
|
|
2536
|
+
traceId: void 0,
|
|
2537
|
+
generated: true
|
|
2538
|
+
};
|
|
2539
|
+
return currentRunContext;
|
|
2540
|
+
}
|
|
2541
|
+
function getRunContext() {
|
|
2542
|
+
return currentRunContext;
|
|
2543
|
+
}
|
|
2544
|
+
/**
|
|
2545
|
+
* Format the run-scope tag the stderr mirror injects into cli.log lines.
|
|
2546
|
+
* Empty string when no context is set (mirror falls back to plain
|
|
2547
|
+
* timestamped lines, preserving pre-context startup logging).
|
|
2548
|
+
*/
|
|
2549
|
+
function runTag(rc = currentRunContext) {
|
|
2550
|
+
if (!rc) return "";
|
|
2551
|
+
const parts = [`run=${rc.runId}`];
|
|
2552
|
+
if (rc.caller) parts.push(`caller=${rc.caller}`);
|
|
2553
|
+
return `[${parts.join(" ")}]`;
|
|
2554
|
+
}
|
|
2555
|
+
//#endregion
|
|
2556
|
+
//#region src/logger.ts
|
|
2557
|
+
/**
|
|
2558
|
+
* Shared CLI log file. Every log line the CLI emits — whether through
|
|
2559
|
+
* `console.error` (rules, helpers, errors) or through the per-task
|
|
2560
|
+
* `makeLogger` (reset worker) — is tee'd here so operators have a single
|
|
2561
|
+
* file to tail when diagnosing a sandbox.
|
|
2562
|
+
*
|
|
2563
|
+
* `/tmp` is ephemeral on sandbox restart; we rely on that for rotation
|
|
2564
|
+
* (no size-based rotation implemented).
|
|
2565
|
+
*/
|
|
2566
|
+
const CLI_LOG_FILE = "/tmp/openclaw-diagnose/cli.log";
|
|
2567
|
+
/** Append one line to the shared cli.log. Swallows any fs error —
|
|
2568
|
+
* logging must never break the business flow. */
|
|
2569
|
+
function appendCliLog(line) {
|
|
2570
|
+
try {
|
|
2571
|
+
const dir = node_path.default.dirname(CLI_LOG_FILE);
|
|
2572
|
+
if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
|
|
2573
|
+
node_fs.default.appendFileSync(CLI_LOG_FILE, line);
|
|
2574
|
+
} catch {}
|
|
2575
|
+
}
|
|
2576
|
+
let stderrMirrorInstalled = false;
|
|
2577
|
+
/**
|
|
2578
|
+
* Install a process-wide `console.error` interceptor. Always tees each
|
|
2579
|
+
* line to cli.log; the stderr passthrough is opt-in via `stderrEnabled`.
|
|
2580
|
+
*
|
|
2581
|
+
* Default (`stderrEnabled: false`): the terminal sees ONLY this command's
|
|
2582
|
+
* stdout (typically a single JSON result line). Lifecycle / progress /
|
|
2583
|
+
* error logs are fully captured in `/tmp/openclaw-diagnose/cli.log`,
|
|
2584
|
+
* which is the canonical debugging surface and already carries
|
|
2585
|
+
* timestamps and `[run=...]` tags.
|
|
2586
|
+
*
|
|
2587
|
+
* Verbose (`stderrEnabled: true`, opt in with the top-level `-x` flag):
|
|
2588
|
+
* lifecycle logs also stream to stderr in real time. Useful when
|
|
2589
|
+
* iterating on a single sandbox.
|
|
2590
|
+
*
|
|
2591
|
+
* Idempotent: calling twice is a no-op (first install wins). Tests that
|
|
2592
|
+
* need to swap mode should call `resetStderrMirrorForTests()` first.
|
|
2593
|
+
*
|
|
2594
|
+
* Why console.error (and not console.log): the CLI's stdout carries the
|
|
2595
|
+
* structured JSON result protocol consumed by sandbox_console and other
|
|
2596
|
+
* callers — any log line on stdout would corrupt JSON parsing.
|
|
2597
|
+
*/
|
|
2598
|
+
function installStderrMirror(opts = {}) {
|
|
2599
|
+
if (stderrMirrorInstalled) return;
|
|
2600
|
+
stderrMirrorInstalled = true;
|
|
2601
|
+
const stderrEnabled = opts.stderrEnabled ?? false;
|
|
2602
|
+
const original = console.error.bind(console);
|
|
2603
|
+
console.error = (...args) => {
|
|
2604
|
+
if (stderrEnabled) original(...args);
|
|
2605
|
+
const body = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ");
|
|
2606
|
+
const tag = runTag();
|
|
2607
|
+
appendCliLog(`[${(/* @__PURE__ */ new Date()).toISOString()}]${tag ? ` ${tag}` : ""} ${body}\n`);
|
|
2608
|
+
};
|
|
2609
|
+
}
|
|
2610
|
+
function safeStringify(v) {
|
|
2611
|
+
try {
|
|
2612
|
+
return JSON.stringify(v);
|
|
2613
|
+
} catch {
|
|
2614
|
+
return String(v);
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
function makeLogger(logFile) {
|
|
2618
|
+
try {
|
|
2619
|
+
const dir = node_path.default.dirname(logFile);
|
|
2620
|
+
if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
|
|
2621
|
+
} catch {}
|
|
2622
|
+
return (msg) => {
|
|
2623
|
+
const tag = runTag();
|
|
2624
|
+
const line = `[${(/* @__PURE__ */ new Date()).toISOString()}]${tag ? ` ${tag}` : ""} ${msg}\n`;
|
|
2625
|
+
try {
|
|
2626
|
+
node_fs.default.appendFileSync(logFile, line);
|
|
2627
|
+
} catch {}
|
|
2628
|
+
appendCliLog(line);
|
|
2629
|
+
};
|
|
2630
|
+
}
|
|
2631
|
+
//#endregion
|
|
1749
2632
|
//#region src/reset-async.ts
|
|
1750
2633
|
/**
|
|
1751
2634
|
* Start an async reset task: spawn a detached child process and return the taskId.
|
|
@@ -1769,6 +2652,9 @@ function startAsyncReset(ctxBase64) {
|
|
|
1769
2652
|
if (!node_fs.default.existsSync(dir)) node_fs.default.mkdirSync(dir, { recursive: true });
|
|
1770
2653
|
node_fs.default.writeFileSync(tmpPath, JSON.stringify(initial), "utf-8");
|
|
1771
2654
|
moveSafe(tmpPath, resultFile);
|
|
2655
|
+
const rc = getRunContext();
|
|
2656
|
+
const childEnv = { ...process.env };
|
|
2657
|
+
if (rc?.runId) childEnv.OPENCLAW_RUN_ID = rc.runId;
|
|
1772
2658
|
const child = (0, node_child_process.spawn)(process.execPath, [
|
|
1773
2659
|
process.argv[1],
|
|
1774
2660
|
"reset",
|
|
@@ -1777,7 +2663,8 @@ function startAsyncReset(ctxBase64) {
|
|
|
1777
2663
|
`--ctx=${ctxBase64}`
|
|
1778
2664
|
], {
|
|
1779
2665
|
detached: true,
|
|
1780
|
-
stdio: "ignore"
|
|
2666
|
+
stdio: "ignore",
|
|
2667
|
+
env: childEnv
|
|
1781
2668
|
});
|
|
1782
2669
|
child.on("error", (err) => {
|
|
1783
2670
|
log(`FATAL worker failed to start: ${err.message}`);
|
|
@@ -1799,6 +2686,55 @@ function startAsyncReset(ctxBase64) {
|
|
|
1799
2686
|
return { taskId };
|
|
1800
2687
|
}
|
|
1801
2688
|
//#endregion
|
|
2689
|
+
//#region src/oss/fetchWithDiag.ts
|
|
2690
|
+
async function fetchWithDiag(url, opts = {}) {
|
|
2691
|
+
const label = opts.label ?? originOf(url);
|
|
2692
|
+
const timeoutMs = opts.timeoutMs ?? 3e4;
|
|
2693
|
+
const start = Date.now();
|
|
2694
|
+
const ac = new AbortController();
|
|
2695
|
+
const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
|
|
2696
|
+
try {
|
|
2697
|
+
return await fetch(url, { signal: ac.signal });
|
|
2698
|
+
} catch (e) {
|
|
2699
|
+
const durationMs = Date.now() - start;
|
|
2700
|
+
const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
|
|
2701
|
+
throw new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
|
|
2702
|
+
} finally {
|
|
2703
|
+
if (timer) clearTimeout(timer);
|
|
2704
|
+
}
|
|
2705
|
+
}
|
|
2706
|
+
/** Walk the Error.cause chain and produce a single-line summary like
|
|
2707
|
+
* `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
|
|
2708
|
+
function describeCause(c, depth = 0) {
|
|
2709
|
+
if (!c || depth > 3) return "";
|
|
2710
|
+
if (c instanceof Error) {
|
|
2711
|
+
const code = c.code;
|
|
2712
|
+
const head = code ? `${code} ${c.message}` : c.message;
|
|
2713
|
+
const inner = describeCause(c.cause, depth + 1);
|
|
2714
|
+
return inner ? `${head} <- ${inner}` : head;
|
|
2715
|
+
}
|
|
2716
|
+
return String(c);
|
|
2717
|
+
}
|
|
2718
|
+
/** Strip query string — OSS signed URLs put the auth token there. Keeps
|
|
2719
|
+
* protocol/host/path so the operator can tell *which* OSS bucket / object
|
|
2720
|
+
* was being fetched. */
|
|
2721
|
+
function redactUrl(raw) {
|
|
2722
|
+
try {
|
|
2723
|
+
const u = new URL(raw);
|
|
2724
|
+
const tail = u.search ? "?<redacted>" : "";
|
|
2725
|
+
return `${u.protocol}//${u.host}${u.pathname}${tail}`;
|
|
2726
|
+
} catch {
|
|
2727
|
+
return "<invalid-url>";
|
|
2728
|
+
}
|
|
2729
|
+
}
|
|
2730
|
+
function originOf(raw) {
|
|
2731
|
+
try {
|
|
2732
|
+
return new URL(raw).host;
|
|
2733
|
+
} catch {
|
|
2734
|
+
return "unknown-host";
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
//#endregion
|
|
1802
2738
|
//#region src/oss/fetchManifest.ts
|
|
1803
2739
|
const MANIFEST_PREFIX = "builtin/manifests/openclaw/recommended/";
|
|
1804
2740
|
const MANIFEST_SUFFIX = ".json";
|
|
@@ -1810,9 +2746,23 @@ async function fetchManifest(ossFileMap, tag) {
|
|
|
1810
2746
|
const availStr = available.length ? available.join(", ") : "(none)";
|
|
1811
2747
|
throw new Error(`manifest signed URL missing for tag "${tag}" (key ${key}). Available tags in ossFileMap: ${availStr}. Either pass an available tag or update the studio_server TCC openclaw_upgrade_config supported_versions.`);
|
|
1812
2748
|
}
|
|
1813
|
-
const
|
|
1814
|
-
|
|
1815
|
-
|
|
2749
|
+
const label = `openclaw manifest tag=${tag}`;
|
|
2750
|
+
const res = await fetchWithDiag(url, { label });
|
|
2751
|
+
if (!res.ok) {
|
|
2752
|
+
const body = await readPreview(res);
|
|
2753
|
+
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (body ? ` body=${body}` : ""));
|
|
2754
|
+
}
|
|
2755
|
+
return await res.json();
|
|
2756
|
+
}
|
|
2757
|
+
/** Best-effort body preview for HTTP-error diagnostics. OSS errors are
|
|
2758
|
+
* XML; first 256 chars usually contain the <Code> + <Message> we care
|
|
2759
|
+
* about. Failures here are swallowed (logging-only, not load-bearing). */
|
|
2760
|
+
async function readPreview(res) {
|
|
2761
|
+
try {
|
|
2762
|
+
return (await res.text()).slice(0, 256);
|
|
2763
|
+
} catch {
|
|
2764
|
+
return "";
|
|
2765
|
+
}
|
|
1816
2766
|
}
|
|
1817
2767
|
async function downloadWithCache(pkg, ossFileMap, opts = {}) {
|
|
1818
2768
|
const cacheRoot = opts.cacheRoot ?? "/tmp/openclaw-diagnose/resources";
|
|
@@ -1827,9 +2777,19 @@ async function downloadWithCache(pkg, ossFileMap, opts = {}) {
|
|
|
1827
2777
|
const expected = pkg.integrity.slice(7);
|
|
1828
2778
|
const tmpFile = node_path.default.join(destDir, `.tmp.${process.pid}.${node_crypto.default.randomBytes(4).toString("hex")}`);
|
|
1829
2779
|
try {
|
|
1830
|
-
const
|
|
1831
|
-
|
|
1832
|
-
|
|
2780
|
+
const label = `download ${pkg.ossKey}`;
|
|
2781
|
+
const res = await fetchWithDiag(url, {
|
|
2782
|
+
label,
|
|
2783
|
+
timeoutMs: 6e4
|
|
2784
|
+
});
|
|
2785
|
+
if (!res.ok) {
|
|
2786
|
+
let preview = "";
|
|
2787
|
+
try {
|
|
2788
|
+
preview = (await res.text()).slice(0, 256);
|
|
2789
|
+
} catch {}
|
|
2790
|
+
throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (preview ? ` body=${preview}` : ""));
|
|
2791
|
+
}
|
|
2792
|
+
if (!res.body) throw new Error(`${label}: empty body`);
|
|
1833
2793
|
const hasher = node_crypto.default.createHash("sha512");
|
|
1834
2794
|
const source = node_stream.Readable.fromWeb(res.body);
|
|
1835
2795
|
async function* teeAndHash(src) {
|
|
@@ -2354,16 +3314,6 @@ function writeSecretsAndRestart(vars, resetData, configDir, log) {
|
|
|
2354
3314
|
log(`restart.sh done in ${Date.now() - t}ms`);
|
|
2355
3315
|
} else log(`no restart.sh at ${restartScript}, skip`);
|
|
2356
3316
|
}
|
|
2357
|
-
/**
|
|
2358
|
-
* Run the 9-step reset process. Called from the worker entry point.
|
|
2359
|
-
*
|
|
2360
|
-
* Each step is an independent function. The orchestrator handles progress
|
|
2361
|
-
* reporting, error handling, and process-level exception guards.
|
|
2362
|
-
*
|
|
2363
|
-
* Template assets (openclaw.json + scripts/) are downloaded from OSS into a
|
|
2364
|
-
* scratch dir via `stageTemplate` before step 1 — there is no bundled
|
|
2365
|
-
* `template/` directory at runtime any more.
|
|
2366
|
-
*/
|
|
2367
3317
|
async function runReset(input, taskId, resultFile) {
|
|
2368
3318
|
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2369
3319
|
const { configPath, vars, resetData } = input;
|
|
@@ -2371,6 +3321,7 @@ async function runReset(input, taskId, resultFile) {
|
|
|
2371
3321
|
const stagedDir = node_path.default.join(DIAGNOSE_DIR, `reset-${taskId}-template`);
|
|
2372
3322
|
let currentStep = 0;
|
|
2373
3323
|
let stepStartedAt = Date.now();
|
|
3324
|
+
const stepResults = [];
|
|
2374
3325
|
const log = makeLogger(resetLogFile(taskId));
|
|
2375
3326
|
log(`=== reset started, taskId=${taskId}, pid=${process.pid} ===`);
|
|
2376
3327
|
log(`configPath=${configPath}, configDir=${configDir}, stagedDir=${stagedDir}`);
|
|
@@ -2379,7 +3330,12 @@ async function runReset(input, taskId, resultFile) {
|
|
|
2379
3330
|
const err = "resetData.ossFileMap missing or empty";
|
|
2380
3331
|
log(`ERROR: ${err}`);
|
|
2381
3332
|
markFailed(resultFile, 0, err, startedAt);
|
|
2382
|
-
|
|
3333
|
+
return {
|
|
3334
|
+
success: false,
|
|
3335
|
+
steps: stepResults,
|
|
3336
|
+
error: err,
|
|
3337
|
+
failedStep: 0
|
|
3338
|
+
};
|
|
2383
3339
|
}
|
|
2384
3340
|
let openclawTag;
|
|
2385
3341
|
if (resetData.openclawTag) openclawTag = resetData.openclawTag;
|
|
@@ -2389,7 +3345,12 @@ async function runReset(input, taskId, resultFile) {
|
|
|
2389
3345
|
const err = e.message;
|
|
2390
3346
|
log(`ERROR: ${err}`);
|
|
2391
3347
|
markFailed(resultFile, 0, err, startedAt);
|
|
2392
|
-
|
|
3348
|
+
return {
|
|
3349
|
+
success: false,
|
|
3350
|
+
steps: stepResults,
|
|
3351
|
+
error: err,
|
|
3352
|
+
failedStep: 0
|
|
3353
|
+
};
|
|
2393
3354
|
}
|
|
2394
3355
|
log(`openclawTag=${openclawTag}`);
|
|
2395
3356
|
process.on("uncaughtException", (err) => {
|
|
@@ -2402,9 +3363,19 @@ async function runReset(input, taskId, resultFile) {
|
|
|
2402
3363
|
markFailed(resultFile, currentStep, `unhandled rejection: ${reason}`, startedAt);
|
|
2403
3364
|
process.exit(1);
|
|
2404
3365
|
});
|
|
2405
|
-
/** Advance to the next step,
|
|
3366
|
+
/** Advance to the next step, recording the previous step's success
|
|
3367
|
+
* duration and updating the on-disk progress file. */
|
|
2406
3368
|
const step = (n) => {
|
|
2407
|
-
if (currentStep > 0)
|
|
3369
|
+
if (currentStep > 0) {
|
|
3370
|
+
const dur = Date.now() - stepStartedAt;
|
|
3371
|
+
log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${dur}ms`);
|
|
3372
|
+
stepResults.push({
|
|
3373
|
+
step: currentStep,
|
|
3374
|
+
name: STEPS[currentStep - 1],
|
|
3375
|
+
status: "ok",
|
|
3376
|
+
durationMs: dur
|
|
3377
|
+
});
|
|
3378
|
+
}
|
|
2408
3379
|
currentStep = n;
|
|
2409
3380
|
stepStartedAt = Date.now();
|
|
2410
3381
|
log(`--- step ${n}/${TOTAL_STEPS}: ${STEPS[n - 1]} ---`);
|
|
@@ -2430,14 +3401,38 @@ async function runReset(input, taskId, resultFile) {
|
|
|
2430
3401
|
await step8InstallExtensions(openclawTag, ossFileMap, log);
|
|
2431
3402
|
step(9);
|
|
2432
3403
|
writeSecretsAndRestart(vars, resetData, configDir, log);
|
|
2433
|
-
|
|
3404
|
+
const lastDur = Date.now() - stepStartedAt;
|
|
3405
|
+
log(`step 9 "${STEPS[8]}" done in ${lastDur}ms`);
|
|
3406
|
+
stepResults.push({
|
|
3407
|
+
step: 9,
|
|
3408
|
+
name: STEPS[8],
|
|
3409
|
+
status: "ok",
|
|
3410
|
+
durationMs: lastDur
|
|
3411
|
+
});
|
|
2434
3412
|
log("=== reset completed successfully ===");
|
|
2435
3413
|
markDone(resultFile, startedAt);
|
|
3414
|
+
return {
|
|
3415
|
+
success: true,
|
|
3416
|
+
steps: stepResults
|
|
3417
|
+
};
|
|
2436
3418
|
} catch (e) {
|
|
2437
3419
|
const err = e.message;
|
|
2438
|
-
|
|
3420
|
+
const dur = Date.now() - stepStartedAt;
|
|
3421
|
+
log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${dur}ms: ${err}\n${e.stack ?? ""}`);
|
|
3422
|
+
stepResults.push({
|
|
3423
|
+
step: currentStep,
|
|
3424
|
+
name: STEPS[currentStep - 1] ?? "init",
|
|
3425
|
+
status: "fail",
|
|
3426
|
+
durationMs: dur,
|
|
3427
|
+
error: err
|
|
3428
|
+
});
|
|
2439
3429
|
markFailed(resultFile, currentStep, err, startedAt);
|
|
2440
|
-
|
|
3430
|
+
return {
|
|
3431
|
+
success: false,
|
|
3432
|
+
steps: stepResults,
|
|
3433
|
+
error: err,
|
|
3434
|
+
failedStep: currentStep
|
|
3435
|
+
};
|
|
2441
3436
|
} finally {
|
|
2442
3437
|
try {
|
|
2443
3438
|
node_fs.default.rmSync(stagedDir, {
|
|
@@ -2504,38 +3499,24 @@ function resolveOssFileMap(args) {
|
|
|
2504
3499
|
throw new Error("ossFileMap missing: provide --oss_file_map flag, ctx.install.ossFileMap, or resetData.ossFileMap");
|
|
2505
3500
|
}
|
|
2506
3501
|
//#endregion
|
|
2507
|
-
//#region src/innerapi/
|
|
3502
|
+
//#region src/innerapi/client.ts
|
|
2508
3503
|
/**
|
|
2509
|
-
*
|
|
2510
|
-
*
|
|
2511
|
-
* Mirrors the proven pattern in
|
|
2512
|
-
* `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
|
|
3504
|
+
* Shared innerapi HTTP client + path constants for `openclaw.*` calls.
|
|
2513
3505
|
*
|
|
2514
3506
|
* - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
|
|
2515
3507
|
* openclaw sandbox).
|
|
2516
3508
|
* - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
|
|
2517
3509
|
* plugin auto-attaches the sandbox's identity JWT loaded from the
|
|
2518
3510
|
* rootfs token file. Same auth that the miaoda extension already uses.
|
|
2519
|
-
* - POST `/api/v1/studio/innerapi/integration_apis/call`
|
|
2520
|
-
*
|
|
2521
|
-
*
|
|
2522
|
-
* `
|
|
2523
|
-
*
|
|
2524
|
-
* `status_code` is a *string* ('0' = success).
|
|
2525
|
-
* Actual DoctorCtx lives in `data.output`.
|
|
2526
|
-
* - `x-tt-logid` header is logged on every failure path for cross-service
|
|
2527
|
-
* traceability.
|
|
2528
|
-
*
|
|
2529
|
-
* On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
|
|
2530
|
-
* instead of throwing — the outer catch in `index.ts` cannot then mask auth
|
|
2531
|
-
* failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
|
|
2532
|
-
* exit code and can refresh the token + retry.
|
|
3511
|
+
* - POST `/api/v1/studio/innerapi/integration_apis/call` with body
|
|
3512
|
+
* `{ apiName, bizType, input }` is the universal dispatcher; specific
|
|
3513
|
+
* apiName values (e.g. `openclaw.get_doctor_ctx`,
|
|
3514
|
+
* `openclaw.report_doctor_result`) are owned by individual call-site
|
|
3515
|
+
* modules.
|
|
2533
3516
|
*/
|
|
2534
3517
|
const INNERAPI_CALL_PATH = "/api/v1/studio/innerapi/integration_apis/call";
|
|
2535
|
-
const API_NAME = "openclaw.get_doctor_ctx";
|
|
2536
3518
|
const BIZ_TYPE = "openclaw";
|
|
2537
3519
|
const API_TIMEOUT_MS = 3e4;
|
|
2538
|
-
const MAX_LOG_BODY = 500;
|
|
2539
3520
|
let clientInstance = null;
|
|
2540
3521
|
function getHttpClient() {
|
|
2541
3522
|
if (!clientInstance) {
|
|
@@ -2552,6 +3533,35 @@ function getHttpClient() {
|
|
|
2552
3533
|
}
|
|
2553
3534
|
return clientInstance;
|
|
2554
3535
|
}
|
|
3536
|
+
//#endregion
|
|
3537
|
+
//#region src/innerapi/fetchCtx.ts
|
|
3538
|
+
/**
|
|
3539
|
+
* CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
|
|
3540
|
+
*
|
|
3541
|
+
* Mirrors the proven pattern in
|
|
3542
|
+
* `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
|
|
3543
|
+
*
|
|
3544
|
+
* - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
|
|
3545
|
+
* openclaw sandbox).
|
|
3546
|
+
* - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
|
|
3547
|
+
* plugin auto-attaches the sandbox's identity JWT loaded from the
|
|
3548
|
+
* rootfs token file. Same auth that the miaoda extension already uses.
|
|
3549
|
+
* - POST `/api/v1/studio/innerapi/integration_apis/call`
|
|
3550
|
+
* body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
|
|
3551
|
+
* — the server-side APICall dispatches by `apiName` to
|
|
3552
|
+
* `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
|
|
3553
|
+
* - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
|
|
3554
|
+
* `status_code` is a *string* ('0' = success).
|
|
3555
|
+
* Actual DoctorCtx lives in `data.output`.
|
|
3556
|
+
* - `x-tt-logid` header is logged on every failure path for cross-service
|
|
3557
|
+
* traceability.
|
|
3558
|
+
*
|
|
3559
|
+
* On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
|
|
3560
|
+
* instead of throwing — the outer catch in `index.ts` cannot then mask auth
|
|
3561
|
+
* failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
|
|
3562
|
+
* exit code and can refresh the token + retry.
|
|
3563
|
+
*/
|
|
3564
|
+
const API_NAME$1 = "openclaw.get_doctor_ctx";
|
|
2555
3565
|
/**
|
|
2556
3566
|
* Fetch the sandbox's DoctorCtx by calling the innerapi's generic
|
|
2557
3567
|
* `integration_apis/call` dispatcher with apiName=openclaw.get_doctor_ctx.
|
|
@@ -2559,14 +3569,19 @@ function getHttpClient() {
|
|
|
2559
3569
|
* Throws on HTTP (non-401) / decode / business errors. On 401 calls
|
|
2560
3570
|
* `process.exit(77)` directly.
|
|
2561
3571
|
*/
|
|
2562
|
-
async function fetchCtxViaInnerApi() {
|
|
3572
|
+
async function fetchCtxViaInnerApi(opts = {}) {
|
|
2563
3573
|
const client = getHttpClient();
|
|
3574
|
+
const input = {};
|
|
3575
|
+
if (opts.populate !== void 0) input.populate = opts.populate;
|
|
3576
|
+
if (opts.caller) input.caller = opts.caller;
|
|
3577
|
+
if (opts.traceId) input.traceId = opts.traceId;
|
|
2564
3578
|
const body = {
|
|
2565
|
-
apiName: API_NAME,
|
|
2566
|
-
input
|
|
3579
|
+
apiName: API_NAME$1,
|
|
3580
|
+
input,
|
|
2567
3581
|
bizType: BIZ_TYPE
|
|
2568
3582
|
};
|
|
2569
3583
|
const start = Date.now();
|
|
3584
|
+
console.error(`fetchCtx: start populate=${JSON.stringify(opts.populate ?? {})}${opts.caller ? ` caller=${opts.caller}` : ""}${opts.traceId ? ` traceId=${opts.traceId}` : ""}`);
|
|
2570
3585
|
const headers = { "Content-Type": "application/json" };
|
|
2571
3586
|
const ttEnv = process.env.X_TT_ENV;
|
|
2572
3587
|
if (ttEnv) headers["x-tt-env"] = ttEnv;
|
|
@@ -2596,7 +3611,7 @@ async function fetchCtxViaInnerApi() {
|
|
|
2596
3611
|
}
|
|
2597
3612
|
let preview = "";
|
|
2598
3613
|
try {
|
|
2599
|
-
preview = (await response.text()).slice(0,
|
|
3614
|
+
preview = (await response.text()).slice(0, 500);
|
|
2600
3615
|
} catch {}
|
|
2601
3616
|
throw new Error(`fetchCtxViaInnerApi HTTP ${response.status} ${response.statusText} (logID: ${logId}, durationMs: ${durationMs})${preview ? ` body=${preview}` : ""}`);
|
|
2602
3617
|
}
|
|
@@ -2610,6 +3625,7 @@ async function fetchCtxViaInnerApi() {
|
|
|
2610
3625
|
if (envelope.data && envelope.data.success === false) throw new Error(`fetchCtxViaInnerApi business error (logID: ${logId}, durationMs: ${durationMs}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
|
|
2611
3626
|
const output = envelope.data?.output;
|
|
2612
3627
|
if (!output || typeof output !== "object") throw new Error(`fetchCtxViaInnerApi empty/invalid output (logID: ${logId}, durationMs: ${durationMs})`);
|
|
3628
|
+
console.error(`fetchCtx: ok logId=${logId} durationMs=${durationMs} groups=[${Object.keys(output).join(",")}]`);
|
|
2613
3629
|
return output;
|
|
2614
3630
|
}
|
|
2615
3631
|
//#endregion
|
|
@@ -2628,26 +3644,40 @@ async function fetchCtxViaInnerApi() {
|
|
|
2628
3644
|
*/
|
|
2629
3645
|
function normalizeCtx(raw) {
|
|
2630
3646
|
const r = raw ?? {};
|
|
2631
|
-
if (r.app && typeof r.app === "object" && r.install && typeof r.install === "object" && r.secrets && typeof r.secrets === "object" && r.reset && typeof r.reset === "object")
|
|
2632
|
-
app:
|
|
2633
|
-
install:
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
3647
|
+
if (r.app && typeof r.app === "object" && r.install && typeof r.install === "object" && r.secrets && typeof r.secrets === "object" && r.reset && typeof r.reset === "object") {
|
|
3648
|
+
const templateVars = r.app.templateVars && typeof r.app.templateVars === "object" ? r.app.templateVars : r.reset.templateVars ?? {};
|
|
3649
|
+
const recommendedOpenclawTag = typeof r.app.recommendedOpenclawTag === "string" && r.app.recommendedOpenclawTag !== "" ? r.app.recommendedOpenclawTag : typeof r.install.openclawTag === "string" && r.install.openclawTag !== "" ? r.install.openclawTag : void 0;
|
|
3650
|
+
return {
|
|
3651
|
+
app: {
|
|
3652
|
+
...fillApp(r.app),
|
|
3653
|
+
templateVars,
|
|
3654
|
+
recommendedOpenclawTag
|
|
3655
|
+
},
|
|
3656
|
+
install: {
|
|
3657
|
+
openclawTag: r.install.openclawTag,
|
|
3658
|
+
ossFileMap: r.install.ossFileMap ?? {}
|
|
3659
|
+
},
|
|
3660
|
+
secrets: {
|
|
3661
|
+
secretsContent: r.secrets.secretsContent ?? "",
|
|
3662
|
+
providerKeyContent: r.secrets.providerKeyContent ?? ""
|
|
3663
|
+
},
|
|
3664
|
+
reset: {
|
|
3665
|
+
templateVars: r.reset.templateVars ?? {},
|
|
3666
|
+
coreBackup: r.reset.coreBackup
|
|
3667
|
+
}
|
|
3668
|
+
};
|
|
3669
|
+
}
|
|
2646
3670
|
const vars = r.vars ?? {};
|
|
2647
3671
|
const resetData = r.resetData ?? {};
|
|
2648
3672
|
const repairData = r.repairData ?? {};
|
|
3673
|
+
const legacyTemplateVars = vars.templateVars && typeof vars.templateVars === "object" ? vars.templateVars : resetData.templateVars ?? r.templateVars ?? {};
|
|
3674
|
+
const legacyRecommendedTag = typeof vars.recommendedOpenclawTag === "string" && vars.recommendedOpenclawTag !== "" ? vars.recommendedOpenclawTag : typeof resetData.openclawTag === "string" && resetData.openclawTag !== "" ? resetData.openclawTag : typeof r.openclawTag === "string" && r.openclawTag !== "" ? r.openclawTag : void 0;
|
|
2649
3675
|
return {
|
|
2650
|
-
app:
|
|
3676
|
+
app: {
|
|
3677
|
+
...fillApp(vars),
|
|
3678
|
+
templateVars: legacyTemplateVars,
|
|
3679
|
+
recommendedOpenclawTag: legacyRecommendedTag
|
|
3680
|
+
},
|
|
2651
3681
|
install: {
|
|
2652
3682
|
openclawTag: r.install?.openclawTag ?? r.openclawTag,
|
|
2653
3683
|
ossFileMap: r.install?.ossFileMap ?? resetData.ossFileMap ?? r.ossFileMap ?? {}
|
|
@@ -2700,11 +3730,15 @@ function fillApp(src) {
|
|
|
2700
3730
|
function buildCheckInput(raw, configPathOverride) {
|
|
2701
3731
|
const r = raw ?? {};
|
|
2702
3732
|
if (r.configPath && r.vars) {
|
|
2703
|
-
|
|
3733
|
+
const out = configPathOverride ? {
|
|
2704
3734
|
...r,
|
|
2705
3735
|
configPath: configPathOverride
|
|
3736
|
+
} : { ...r };
|
|
3737
|
+
if (out.vars && !out.vars.templateVars) out.vars = {
|
|
3738
|
+
...out.vars,
|
|
3739
|
+
templateVars: out.templateVars ?? {}
|
|
2706
3740
|
};
|
|
2707
|
-
return
|
|
3741
|
+
return out;
|
|
2708
3742
|
}
|
|
2709
3743
|
const ctx = normalizeCtx(raw);
|
|
2710
3744
|
return {
|
|
@@ -2718,19 +3752,25 @@ function buildCheckInput(raw, configPathOverride) {
|
|
|
2718
3752
|
baseURL: ctx.app.baseURL,
|
|
2719
3753
|
expectedOrigins: ctx.app.expectedOrigins,
|
|
2720
3754
|
providerFilePath: PROVIDER_FILE_PATH,
|
|
2721
|
-
secretsFilePath: SECRETS_FILE_PATH
|
|
3755
|
+
secretsFilePath: SECRETS_FILE_PATH,
|
|
3756
|
+
templateVars: ctx.app.templateVars,
|
|
3757
|
+
recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
|
|
2722
3758
|
},
|
|
2723
|
-
templateVars: ctx.
|
|
3759
|
+
templateVars: ctx.app.templateVars
|
|
2724
3760
|
};
|
|
2725
3761
|
}
|
|
2726
3762
|
function buildRepairInput(raw, configPathOverride) {
|
|
2727
3763
|
const r = raw ?? {};
|
|
2728
3764
|
if (r.configPath && r.vars) {
|
|
2729
|
-
|
|
3765
|
+
const out = configPathOverride ? {
|
|
2730
3766
|
...r,
|
|
2731
3767
|
configPath: configPathOverride
|
|
3768
|
+
} : { ...r };
|
|
3769
|
+
if (out.vars && !out.vars.templateVars) out.vars = {
|
|
3770
|
+
...out.vars,
|
|
3771
|
+
templateVars: out.templateVars ?? {}
|
|
2732
3772
|
};
|
|
2733
|
-
return
|
|
3773
|
+
return out;
|
|
2734
3774
|
}
|
|
2735
3775
|
const ctx = normalizeCtx(raw);
|
|
2736
3776
|
return {
|
|
@@ -2744,23 +3784,29 @@ function buildRepairInput(raw, configPathOverride) {
|
|
|
2744
3784
|
baseURL: ctx.app.baseURL,
|
|
2745
3785
|
expectedOrigins: ctx.app.expectedOrigins,
|
|
2746
3786
|
providerFilePath: PROVIDER_FILE_PATH,
|
|
2747
|
-
secretsFilePath: SECRETS_FILE_PATH
|
|
3787
|
+
secretsFilePath: SECRETS_FILE_PATH,
|
|
3788
|
+
templateVars: ctx.app.templateVars,
|
|
3789
|
+
recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
|
|
2748
3790
|
},
|
|
2749
3791
|
repairData: {
|
|
2750
3792
|
secretsContent: ctx.secrets.secretsContent,
|
|
2751
3793
|
providerKeyContent: ctx.secrets.providerKeyContent
|
|
2752
3794
|
},
|
|
2753
|
-
templateVars: ctx.
|
|
3795
|
+
templateVars: ctx.app.templateVars
|
|
2754
3796
|
};
|
|
2755
3797
|
}
|
|
2756
3798
|
function buildResetInput(raw, configPathOverride) {
|
|
2757
3799
|
const r = raw ?? {};
|
|
2758
3800
|
if (r.configPath && r.vars && r.resetData) {
|
|
2759
|
-
|
|
3801
|
+
const out = configPathOverride ? {
|
|
2760
3802
|
...r,
|
|
2761
3803
|
configPath: configPathOverride
|
|
3804
|
+
} : { ...r };
|
|
3805
|
+
if (out.vars && !out.vars.templateVars) out.vars = {
|
|
3806
|
+
...out.vars,
|
|
3807
|
+
templateVars: out.resetData?.templateVars ?? {}
|
|
2762
3808
|
};
|
|
2763
|
-
return
|
|
3809
|
+
return out;
|
|
2764
3810
|
}
|
|
2765
3811
|
const ctx = normalizeCtx(raw);
|
|
2766
3812
|
return {
|
|
@@ -2774,7 +3820,9 @@ function buildResetInput(raw, configPathOverride) {
|
|
|
2774
3820
|
baseURL: ctx.app.baseURL,
|
|
2775
3821
|
expectedOrigins: ctx.app.expectedOrigins,
|
|
2776
3822
|
providerFilePath: PROVIDER_FILE_PATH,
|
|
2777
|
-
secretsFilePath: SECRETS_FILE_PATH
|
|
3823
|
+
secretsFilePath: SECRETS_FILE_PATH,
|
|
3824
|
+
templateVars: ctx.app.templateVars,
|
|
3825
|
+
recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
|
|
2778
3826
|
},
|
|
2779
3827
|
resetData: {
|
|
2780
3828
|
templateVars: ctx.reset.templateVars,
|
|
@@ -2788,30 +3836,381 @@ function buildResetInput(raw, configPathOverride) {
|
|
|
2788
3836
|
}
|
|
2789
3837
|
//#endregion
|
|
2790
3838
|
//#region src/doctor.ts
|
|
3839
|
+
/**
|
|
3840
|
+
* Per-rule orchestration with telemetry-friendly outcomes.
|
|
3841
|
+
*
|
|
3842
|
+
* Flow per rule (in topological order, after disabledRules / dependsOn /
|
|
3843
|
+
* skipWhen filtering):
|
|
3844
|
+
*
|
|
3845
|
+
* 1. validate — exception → status='error', abort
|
|
3846
|
+
* 2. pass → status='pass'
|
|
3847
|
+
* 3. fail and !opts.fix → status='failed' (with v1.message)
|
|
3848
|
+
* 4. fail and opts.fix →
|
|
3849
|
+
* a. only standard-mode rules attempt repair; ai/reset stay 'failed'
|
|
3850
|
+
* b. repair — exception → status='error', abort
|
|
3851
|
+
* c. re-validate — exception → status='error', abort
|
|
3852
|
+
* d. v2.pass → status='fixed' (before = v1.message)
|
|
3853
|
+
* e. v2.fail → status='still-broken' (before+after)
|
|
3854
|
+
*
|
|
3855
|
+
* Aborted runs return a partial DoctorReport with `aborted: true`. The
|
|
3856
|
+
* caller should `console.log(JSON.stringify(report))` first, THEN
|
|
3857
|
+
* `process.exit(1)` so sweep / telemetry callers always get the partial
|
|
3858
|
+
* data plus the non-zero signal.
|
|
3859
|
+
*
|
|
3860
|
+
* Config writeback: if opts.fix and at least one rule transitioned
|
|
3861
|
+
* pass→fixed, the in-memory ctx.config is JSON-serialized and written
|
|
3862
|
+
* back to opts.configPath. On abort, no write — keep the on-disk file
|
|
3863
|
+
* matching whatever was successful before the failure.
|
|
3864
|
+
*
|
|
3865
|
+
* Secrets file / provider-key / restart-command writes are NOT performed
|
|
3866
|
+
* by doctor. Those belong to the standalone `repair` subcommand which
|
|
3867
|
+
* sandbox_console push uses. Doctor is for diagnosis + per-rule fix
|
|
3868
|
+
* with structured outcomes.
|
|
3869
|
+
*/
|
|
2791
3870
|
async function runDoctor(rawCtx, opts) {
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
3871
|
+
const results = [];
|
|
3872
|
+
console.error(`runDoctor: begin fix=${opts.fix} rules=[${opts.rules.join(",")}] configPath=${opts.configPath ?? "(default)"}`);
|
|
3873
|
+
const checkInput = buildCheckInput(rawCtx, opts.configPath);
|
|
3874
|
+
let configRaw;
|
|
3875
|
+
let config;
|
|
3876
|
+
try {
|
|
3877
|
+
configRaw = readFile(checkInput.configPath);
|
|
3878
|
+
} catch (e) {
|
|
3879
|
+
console.error(`runDoctor: read config failed at ${checkInput.configPath} message=${e.message}`);
|
|
3880
|
+
return finalize([{
|
|
3881
|
+
rule: "config_syntax_check",
|
|
3882
|
+
status: "error",
|
|
3883
|
+
message: "read config failed: " + e.message
|
|
3884
|
+
}], true);
|
|
3885
|
+
}
|
|
3886
|
+
try {
|
|
3887
|
+
config = loadJSON5().parse(configRaw);
|
|
3888
|
+
} catch (e) {
|
|
3889
|
+
console.error(`runDoctor: config JSON5 parse failed message=${e.message}`);
|
|
3890
|
+
results.push({
|
|
3891
|
+
rule: "config_syntax_check",
|
|
3892
|
+
status: opts.fix ? "still-broken" : "failed",
|
|
3893
|
+
message: "config JSON5 parse failed: " + e.message
|
|
3894
|
+
});
|
|
3895
|
+
return finalize(results, true);
|
|
3896
|
+
}
|
|
3897
|
+
const ctx = {
|
|
3898
|
+
config,
|
|
3899
|
+
configPath: checkInput.configPath,
|
|
3900
|
+
vars: checkInput.vars,
|
|
3901
|
+
providerDeps: analyzeProviderDeps(config)
|
|
3902
|
+
};
|
|
3903
|
+
const originalConfig = opts.showDiff && opts.fix ? deepClone(config) : null;
|
|
3904
|
+
const allRules = getAllRules();
|
|
3905
|
+
const onlyKeys = opts.rules.length > 0 ? new Set(opts.rules) : null;
|
|
3906
|
+
const skipRuleKeys = new Set(opts.skipRules ?? []);
|
|
3907
|
+
const disabledKeys = new Set([...checkInput.disabledRules ?? [], ...skipRuleKeys]);
|
|
3908
|
+
if (onlyKeys) {
|
|
3909
|
+
const registeredKeys = new Set(allRules.map((r) => r.meta.key));
|
|
3910
|
+
for (const k of opts.rules) if (!registeredKeys.has(k)) results.push({
|
|
3911
|
+
rule: k,
|
|
3912
|
+
status: "unknown"
|
|
3913
|
+
});
|
|
3914
|
+
}
|
|
3915
|
+
const failedKeys = /* @__PURE__ */ new Set();
|
|
3916
|
+
let configDirty = false;
|
|
3917
|
+
let aborted = false;
|
|
3918
|
+
const hasMiaoda = shouldCheckMiaodaRules(config);
|
|
3919
|
+
const plannedRules = allRules.filter((r) => !onlyKeys || onlyKeys.has(r.meta.key));
|
|
3920
|
+
console.error(`runDoctor: planned ${plannedRules.length} rules hasMiaoda=${hasMiaoda} providerDeps=${JSON.stringify(ctx.providerDeps)}`);
|
|
3921
|
+
outer: for (const rule of allRules) {
|
|
3922
|
+
const key = rule.meta.key;
|
|
3923
|
+
if (onlyKeys && !onlyKeys.has(key)) continue;
|
|
3924
|
+
if (disabledKeys.has(key)) {
|
|
3925
|
+
const reason = skipRuleKeys.has(key) ? "--skip-rule" : "disabledRules";
|
|
3926
|
+
console.error(`rule ${key}: skipped reason=${reason}`);
|
|
3927
|
+
results.push({
|
|
3928
|
+
rule: key,
|
|
3929
|
+
status: "skipped",
|
|
3930
|
+
message: reason
|
|
3931
|
+
});
|
|
3932
|
+
continue;
|
|
3933
|
+
}
|
|
3934
|
+
if (rule.meta.dependsOn?.some((d) => failedKeys.has(d))) {
|
|
3935
|
+
const blockers = rule.meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
|
|
3936
|
+
console.error(`rule ${key}: skipped reason=dependsOn-failed blockers=${blockers}`);
|
|
3937
|
+
results.push({
|
|
3938
|
+
rule: key,
|
|
3939
|
+
status: "skipped",
|
|
3940
|
+
message: `dependsOn failed: ${blockers}`
|
|
3941
|
+
});
|
|
3942
|
+
continue;
|
|
3943
|
+
}
|
|
3944
|
+
if (rule.meta.skipWhen?.({
|
|
3945
|
+
hasMiaoda,
|
|
3946
|
+
deps: ctx.providerDeps
|
|
3947
|
+
})) {
|
|
3948
|
+
console.error(`rule ${key}: skipped reason=skipWhen`);
|
|
3949
|
+
results.push({
|
|
3950
|
+
rule: key,
|
|
3951
|
+
status: "skipped",
|
|
3952
|
+
message: "skipWhen"
|
|
3953
|
+
});
|
|
3954
|
+
continue;
|
|
3955
|
+
}
|
|
3956
|
+
const tValidate = Date.now();
|
|
3957
|
+
let v1;
|
|
3958
|
+
try {
|
|
3959
|
+
v1 = rule.validate(ctx);
|
|
3960
|
+
} catch (e) {
|
|
3961
|
+
const msg = e.message;
|
|
3962
|
+
console.error(`rule ${key}: validate -> error durationMs=${Date.now() - tValidate} message=${msg}`);
|
|
3963
|
+
results.push({
|
|
3964
|
+
rule: key,
|
|
3965
|
+
status: "error",
|
|
3966
|
+
message: "validate threw: " + msg
|
|
3967
|
+
});
|
|
3968
|
+
aborted = true;
|
|
3969
|
+
break outer;
|
|
3970
|
+
}
|
|
3971
|
+
if (v1.pass) {
|
|
3972
|
+
console.error(`rule ${key}: validate -> pass durationMs=${Date.now() - tValidate}`);
|
|
3973
|
+
results.push({
|
|
3974
|
+
rule: key,
|
|
3975
|
+
status: "pass"
|
|
3976
|
+
});
|
|
3977
|
+
continue;
|
|
3978
|
+
}
|
|
3979
|
+
if (!opts.fix) {
|
|
3980
|
+
console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
|
|
3981
|
+
results.push({
|
|
3982
|
+
rule: key,
|
|
3983
|
+
status: "failed",
|
|
3984
|
+
message: v1.message,
|
|
3985
|
+
action: v1.action
|
|
3986
|
+
});
|
|
3987
|
+
failedKeys.add(key);
|
|
3988
|
+
continue;
|
|
3989
|
+
}
|
|
3990
|
+
if (rule.meta.repairMode !== "standard") {
|
|
3991
|
+
console.error(`rule ${key}: validate -> failed (no auto-repair, repairMode=${rule.meta.repairMode}) durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
|
|
3992
|
+
results.push({
|
|
3993
|
+
rule: key,
|
|
3994
|
+
status: "failed",
|
|
3995
|
+
message: v1.message,
|
|
3996
|
+
action: v1.action
|
|
3997
|
+
});
|
|
3998
|
+
failedKeys.add(key);
|
|
3999
|
+
continue;
|
|
4000
|
+
}
|
|
4001
|
+
console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
|
|
4002
|
+
const tRepair = Date.now();
|
|
4003
|
+
try {
|
|
4004
|
+
rule.repair(ctx);
|
|
4005
|
+
} catch (e) {
|
|
4006
|
+
const msg = e.message;
|
|
4007
|
+
console.error(`rule ${key}: repair -> error durationMs=${Date.now() - tRepair} message=${msg}`);
|
|
4008
|
+
results.push({
|
|
4009
|
+
rule: key,
|
|
4010
|
+
status: "error",
|
|
4011
|
+
message: "repair threw: " + msg,
|
|
4012
|
+
before: v1.message
|
|
4013
|
+
});
|
|
4014
|
+
aborted = true;
|
|
4015
|
+
break outer;
|
|
4016
|
+
}
|
|
4017
|
+
console.error(`rule ${key}: repair -> ok durationMs=${Date.now() - tRepair}`);
|
|
4018
|
+
configDirty = true;
|
|
4019
|
+
const tRevalidate = Date.now();
|
|
4020
|
+
let v2;
|
|
4021
|
+
try {
|
|
4022
|
+
v2 = rule.validate(ctx);
|
|
4023
|
+
} catch (e) {
|
|
4024
|
+
const msg = e.message;
|
|
4025
|
+
console.error(`rule ${key}: revalidate -> error durationMs=${Date.now() - tRevalidate} message=${msg}`);
|
|
4026
|
+
results.push({
|
|
4027
|
+
rule: key,
|
|
4028
|
+
status: "error",
|
|
4029
|
+
message: "post-repair validate threw: " + msg,
|
|
4030
|
+
before: v1.message
|
|
4031
|
+
});
|
|
4032
|
+
aborted = true;
|
|
4033
|
+
break outer;
|
|
4034
|
+
}
|
|
4035
|
+
if (v2.pass) {
|
|
4036
|
+
console.error(`rule ${key}: revalidate -> pass (fixed) durationMs=${Date.now() - tRevalidate}`);
|
|
4037
|
+
results.push({
|
|
4038
|
+
rule: key,
|
|
4039
|
+
status: "fixed",
|
|
4040
|
+
before: v1.message
|
|
4041
|
+
});
|
|
4042
|
+
} else {
|
|
4043
|
+
console.error(`rule ${key}: revalidate -> still-broken durationMs=${Date.now() - tRevalidate} after=${v2.message ?? ""}`);
|
|
4044
|
+
results.push({
|
|
4045
|
+
rule: key,
|
|
4046
|
+
status: "still-broken",
|
|
4047
|
+
before: v1.message,
|
|
4048
|
+
after: v2.message
|
|
4049
|
+
});
|
|
4050
|
+
failedKeys.add(key);
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
let backupPath = null;
|
|
4054
|
+
if (configDirty && !aborted) {
|
|
4055
|
+
backupPath = backupConfigSync(ctx.configPath);
|
|
4056
|
+
const serialized = JSON.stringify(ctx.config, null, 2);
|
|
4057
|
+
try {
|
|
4058
|
+
writeFile(ctx.configPath, serialized);
|
|
4059
|
+
console.error(`runDoctor: writeback ok path=${ctx.configPath} bytes=${serialized.length}`);
|
|
4060
|
+
} catch (e) {
|
|
4061
|
+
const msg = e.message;
|
|
4062
|
+
console.error(`runDoctor: writeback failed path=${ctx.configPath} message=${msg}`);
|
|
4063
|
+
results.push({
|
|
4064
|
+
rule: "*config-writeback*",
|
|
4065
|
+
status: "error",
|
|
4066
|
+
message: "config write failed: " + msg
|
|
4067
|
+
});
|
|
4068
|
+
aborted = true;
|
|
4069
|
+
}
|
|
4070
|
+
} else if (configDirty && aborted) console.error("runDoctor: writeback skipped (aborted, leaving on-disk config untouched)");
|
|
4071
|
+
else console.error("runDoctor: writeback skipped (no rule transitioned to fixed)");
|
|
4072
|
+
console.error(`runDoctor: end aborted=${aborted} results=${results.length}`);
|
|
4073
|
+
if (originalConfig !== null) {
|
|
4074
|
+
const d = (0, json_diff.diffString)(originalConfig, ctx.config, { color: false });
|
|
4075
|
+
console.error(`runDoctor: diff backupPath=${backupPath ?? "(none)"} changed=${!!d}`);
|
|
4076
|
+
if (d) {
|
|
4077
|
+
process.stdout.write(`original: ${backupPath ?? "(none)"}\n`);
|
|
4078
|
+
process.stdout.write(`after fixed: ${ctx.configPath}\n`);
|
|
4079
|
+
process.stdout.write("diff:\n");
|
|
4080
|
+
process.stdout.write(d);
|
|
4081
|
+
if (!d.endsWith("\n")) process.stdout.write("\n");
|
|
4082
|
+
} else process.stdout.write("(no openclaw.json changes)\n");
|
|
4083
|
+
}
|
|
4084
|
+
return finalize(results, aborted);
|
|
4085
|
+
}
|
|
4086
|
+
/** Deep-clone a JSON-shaped value. Uses structuredClone where available
|
|
4087
|
+
* (Node 17+); falls back to JSON round-trip otherwise. The config we
|
|
4088
|
+
* clone here is JSON5-parsed so it's strictly tree-shaped — no
|
|
4089
|
+
* references, no functions — making both paths equivalent. */
|
|
4090
|
+
function deepClone(v) {
|
|
4091
|
+
if (typeof structuredClone === "function") return structuredClone(v);
|
|
4092
|
+
return JSON.parse(JSON.stringify(v));
|
|
4093
|
+
}
|
|
4094
|
+
function finalize(results, aborted) {
|
|
4095
|
+
const summary = {
|
|
4096
|
+
pass: 0,
|
|
4097
|
+
failed: 0,
|
|
4098
|
+
fixed: 0,
|
|
4099
|
+
stillBroken: 0,
|
|
4100
|
+
skipped: 0,
|
|
4101
|
+
error: 0,
|
|
4102
|
+
unknown: 0
|
|
4103
|
+
};
|
|
4104
|
+
for (const r of results) switch (r.status) {
|
|
4105
|
+
case "pass":
|
|
4106
|
+
summary.pass++;
|
|
4107
|
+
break;
|
|
4108
|
+
case "failed":
|
|
4109
|
+
summary.failed++;
|
|
4110
|
+
break;
|
|
4111
|
+
case "fixed":
|
|
4112
|
+
summary.fixed++;
|
|
4113
|
+
break;
|
|
4114
|
+
case "still-broken":
|
|
4115
|
+
summary.stillBroken++;
|
|
4116
|
+
break;
|
|
4117
|
+
case "skipped":
|
|
4118
|
+
summary.skipped++;
|
|
4119
|
+
break;
|
|
4120
|
+
case "error":
|
|
4121
|
+
summary.error++;
|
|
4122
|
+
break;
|
|
4123
|
+
case "unknown":
|
|
4124
|
+
summary.unknown++;
|
|
4125
|
+
break;
|
|
4126
|
+
}
|
|
2805
4127
|
return {
|
|
2806
|
-
|
|
2807
|
-
|
|
4128
|
+
results,
|
|
4129
|
+
summary,
|
|
4130
|
+
aborted
|
|
4131
|
+
};
|
|
4132
|
+
}
|
|
4133
|
+
//#endregion
|
|
4134
|
+
//#region src/innerapi/reportCliRun.ts
|
|
4135
|
+
/**
|
|
4136
|
+
* CLI-side client for studio_server's `openclaw.report_cli_run` inner
|
|
4137
|
+
* API — the unified telemetry sink for every CLI command.
|
|
4138
|
+
*
|
|
4139
|
+
* Best-effort: gated by `DoctorCtx.telemetry.reportCliRun` returned by
|
|
4140
|
+
* `get_doctor_ctx`. When the server hasn't rolled out the API yet the
|
|
4141
|
+
* flag is absent and the CLI never invokes this. When enabled, the CLI
|
|
4142
|
+
* fires-and-forgets one report per command after work is done.
|
|
4143
|
+
*
|
|
4144
|
+
* Failures here MUST NOT change exit code or otherwise affect the
|
|
4145
|
+
* command's data path — telemetry is auxiliary. Caller is expected to
|
|
4146
|
+
* `try/catch` and just `console.error` on rejection.
|
|
4147
|
+
*
|
|
4148
|
+
* Replaces the earlier `openclaw.report_doctor_result` API. The schema
|
|
4149
|
+
* generalises so check / repair / install-* / download-resource / reset
|
|
4150
|
+
* (worker) can all use the same envelope; per-command shape lives under
|
|
4151
|
+
* `result`.
|
|
4152
|
+
*/
|
|
4153
|
+
const API_NAME = "openclaw.report_cli_run";
|
|
4154
|
+
async function reportCliRun(opts) {
|
|
4155
|
+
const client = getHttpClient();
|
|
4156
|
+
const input = {
|
|
4157
|
+
command: opts.command,
|
|
4158
|
+
runId: opts.runId,
|
|
4159
|
+
version: opts.version,
|
|
4160
|
+
invocation: opts.invocation,
|
|
4161
|
+
durationMs: opts.durationMs,
|
|
4162
|
+
success: opts.success
|
|
4163
|
+
};
|
|
4164
|
+
if (opts.caller) input.caller = opts.caller;
|
|
4165
|
+
if (opts.traceId) input.traceId = opts.traceId;
|
|
4166
|
+
if (opts.result !== void 0) input.result = opts.result;
|
|
4167
|
+
if (opts.error) input.error = opts.error;
|
|
4168
|
+
const body = {
|
|
4169
|
+
apiName: API_NAME,
|
|
4170
|
+
input,
|
|
4171
|
+
bizType: BIZ_TYPE
|
|
2808
4172
|
};
|
|
4173
|
+
const headers = { "Content-Type": "application/json" };
|
|
4174
|
+
const ttEnv = process.env.X_TT_ENV;
|
|
4175
|
+
if (ttEnv) headers["x-tt-env"] = ttEnv;
|
|
4176
|
+
const start = Date.now();
|
|
4177
|
+
console.error(`reportCliRun: start command=${opts.command} success=${opts.success} version=${opts.version} invocation=${JSON.stringify(opts.invocation)} durationMs=${opts.durationMs}`);
|
|
4178
|
+
let response;
|
|
4179
|
+
try {
|
|
4180
|
+
response = await client.post(INNERAPI_CALL_PATH, body, { headers });
|
|
4181
|
+
} catch (e) {
|
|
4182
|
+
if (e instanceof _lark_apaas_http_client.HttpError && e.response) {
|
|
4183
|
+
const status = e.response.status;
|
|
4184
|
+
const logId = e.response.headers.get("x-tt-logid") ?? "";
|
|
4185
|
+
throw new Error(`reportCliRun HTTP ${status} ${e.response.statusText} (logID: ${logId})`);
|
|
4186
|
+
}
|
|
4187
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
4188
|
+
throw new Error(`reportCliRun network error: ${msg}`);
|
|
4189
|
+
}
|
|
4190
|
+
const logId = response.headers.get("x-tt-logid") ?? "";
|
|
4191
|
+
if (!response.ok) {
|
|
4192
|
+
let preview = "";
|
|
4193
|
+
try {
|
|
4194
|
+
preview = (await response.text()).slice(0, 500);
|
|
4195
|
+
} catch {}
|
|
4196
|
+
throw new Error(`reportCliRun HTTP ${response.status} ${response.statusText} (logID: ${logId})${preview ? ` body=${preview}` : ""}`);
|
|
4197
|
+
}
|
|
4198
|
+
let envelope;
|
|
4199
|
+
try {
|
|
4200
|
+
envelope = await response.json();
|
|
4201
|
+
} catch {
|
|
4202
|
+
console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start} (body unparseable, treated as success)`);
|
|
4203
|
+
return;
|
|
4204
|
+
}
|
|
4205
|
+
if (envelope.status_code !== void 0 && envelope.status_code !== "0") throw new Error(`reportCliRun API error (logID: ${logId}): code=${envelope.status_code}, message=${envelope.error_msg ?? ""}`);
|
|
4206
|
+
if (envelope.data && envelope.data.success === false) throw new Error(`reportCliRun business error (logID: ${logId}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
|
|
4207
|
+
console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start}`);
|
|
2809
4208
|
}
|
|
2810
4209
|
//#endregion
|
|
2811
4210
|
//#region src/help.ts
|
|
2812
4211
|
const BIN = "mclaw-diagnose";
|
|
2813
4212
|
function versionBanner() {
|
|
2814
|
-
return `v0.1.4-
|
|
4213
|
+
return `v0.1.4-beta.0`;
|
|
2815
4214
|
}
|
|
2816
4215
|
const COMMANDS = [
|
|
2817
4216
|
{
|
|
@@ -2819,46 +4218,85 @@ const COMMANDS = [
|
|
|
2819
4218
|
hidden: false,
|
|
2820
4219
|
summary: "Diagnose openclaw config; apply repairs with --fix",
|
|
2821
4220
|
help: `USAGE
|
|
2822
|
-
${BIN} doctor [--fix] [--rule=<key>]...
|
|
4221
|
+
${BIN} doctor [--fix] [--show-diff] [--rule=<key>]... [--skip-rule=<key>]...
|
|
4222
|
+
[--caller=<n>] [--trace-id=<id>]
|
|
2823
4223
|
|
|
2824
4224
|
DESCRIPTION
|
|
2825
|
-
Fetches DoctorCtx via innerapi, then
|
|
2826
|
-
|
|
4225
|
+
Fetches DoctorCtx via innerapi, then orchestrates each rule:
|
|
4226
|
+
|
|
4227
|
+
validate (always)
|
|
4228
|
+
pass → status='pass'
|
|
4229
|
+
fail and !--fix → status='failed'
|
|
4230
|
+
fail and --fix
|
|
4231
|
+
repair → re-validate
|
|
4232
|
+
pass → status='fixed'
|
|
4233
|
+
still fail → status='still-broken'
|
|
2827
4234
|
|
|
2828
|
-
|
|
2829
|
-
(no flags) Check-only. Runs the rule engine against the
|
|
2830
|
-
sandbox's current openclaw config and returns
|
|
2831
|
-
{ failedRules: { standard, ai, reset } }
|
|
2832
|
-
No files are mutated. Use this when you just
|
|
2833
|
-
want to know what's wrong.
|
|
4235
|
+
Output is a single JSON object on stdout:
|
|
2834
4236
|
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
4237
|
+
{
|
|
4238
|
+
"results": [{ "rule": "...", "status": "...", "message"?: "...", ...}],
|
|
4239
|
+
"summary": { "pass": N, "failed": N, "fixed": N, "stillBroken": N,
|
|
4240
|
+
"skipped": N, "error": N, "unknown": N },
|
|
4241
|
+
"aborted": false
|
|
4242
|
+
}
|
|
2840
4243
|
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
4244
|
+
STATUSES
|
|
4245
|
+
pass Rule's validate passed; no repair attempted.
|
|
4246
|
+
failed Rule's validate failed; --fix was not given.
|
|
4247
|
+
fixed Rule's validate failed; repair ran; re-validate passed.
|
|
4248
|
+
still-broken Rule's validate failed; repair ran; re-validate still failed.
|
|
4249
|
+
skipped Rule excluded by disabledRules / --skip-rule / dependsOn /
|
|
4250
|
+
skipWhen.
|
|
4251
|
+
error validate or repair threw an exception. ABORTS the run —
|
|
4252
|
+
exit code becomes 1; partial 'results' still written to stdout.
|
|
4253
|
+
unknown --rule=<k> with k not registered. Reported, not aborting.
|
|
2847
4254
|
|
|
2848
4255
|
OPTIONS
|
|
2849
|
-
--fix
|
|
2850
|
-
|
|
2851
|
-
|
|
4256
|
+
--fix Enable repair. Without it, validate runs but failures
|
|
4257
|
+
are reported as 'failed' instead of being repaired.
|
|
4258
|
+
--show-diff With --fix: replace the JSON report on stdout with
|
|
4259
|
+
a plain-text section showing
|
|
4260
|
+
original: <backup path>
|
|
4261
|
+
after fixed: <openclaw.json path>
|
|
4262
|
+
diff:
|
|
4263
|
+
<unified-diff body, same format as \`json-diff\`>
|
|
4264
|
+
Interactive flag — sandbox_console's push path never
|
|
4265
|
+
sets it, so the JSON contract is preserved for that
|
|
4266
|
+
caller. No-op without --fix (no mutation, no diff).
|
|
4267
|
+
--rule=<key> Whitelist: restrict the run to this rule key (repeatable).
|
|
4268
|
+
Without --rule, every registered rule runs. With --rule,
|
|
4269
|
+
the planner narrows the innerapi vars fetch to just
|
|
4270
|
+
that rule's needs (see lazy-vars).
|
|
4271
|
+
--skip-rule=<key> Blacklist: skip the rule entirely — neither validate
|
|
4272
|
+
nor repair runs (repeatable). Outcome is 'skipped' with
|
|
4273
|
+
message='--skip-rule'. Stacks with the server's
|
|
4274
|
+
disabledRules. Skipped rules also subtract their vars
|
|
4275
|
+
from the planner's union.
|
|
4276
|
+
--caller=<name> Optional metadata; passed through verbatim to innerapi
|
|
4277
|
+
(fetchCtx + report_cli_run body.input.caller).
|
|
4278
|
+
E.g. "sweep_job", "user_yxy". Server uses for audit /
|
|
4279
|
+
correlation; CLI doesn't act on it.
|
|
4280
|
+
--trace-id=<id> Optional log-correlation id; passed through verbatim
|
|
4281
|
+
to innerapi body.input.traceId. Typically the upstream
|
|
4282
|
+
caller's logID (e.g. sandbox_console's request id).
|
|
2852
4283
|
|
|
2853
4284
|
EXAMPLES
|
|
2854
|
-
${BIN} doctor
|
|
2855
|
-
${BIN} doctor --fix
|
|
2856
|
-
${BIN} doctor --fix --rule=gateway
|
|
2857
|
-
${BIN} doctor --fix --rule=
|
|
4285
|
+
${BIN} doctor # diagnose, no fixes
|
|
4286
|
+
${BIN} doctor --fix # diagnose + fix everything
|
|
4287
|
+
${BIN} doctor --fix --rule=gateway # fix only 'gateway'
|
|
4288
|
+
${BIN} doctor --fix --skip-rule=feishu_channel # fix everything except feishu
|
|
4289
|
+
${BIN} doctor --fix --skip-rule=feishu_channel --skip-rule=feishu_default_account
|
|
4290
|
+
# skip multiple rules
|
|
4291
|
+
${BIN} doctor --fix --show-diff # see exactly what changed
|
|
4292
|
+
# (plain-text diff after
|
|
4293
|
+
# the JSON report)
|
|
2858
4294
|
|
|
2859
4295
|
EXIT CODES
|
|
2860
|
-
0
|
|
2861
|
-
|
|
4296
|
+
0 completed (results may include 'failed' / 'still-broken' — those
|
|
4297
|
+
are normal data outcomes, not run failures)
|
|
4298
|
+
1 aborted because a rule's validate or repair threw. Partial
|
|
4299
|
+
results still on stdout.
|
|
2862
4300
|
77 innerapi authentication failed (sandbox JWT expired/invalid)
|
|
2863
4301
|
`
|
|
2864
4302
|
},
|
|
@@ -3047,6 +4485,62 @@ function formatCommandHelp(name) {
|
|
|
3047
4485
|
return cmd.help;
|
|
3048
4486
|
}
|
|
3049
4487
|
//#endregion
|
|
4488
|
+
//#region src/rule-engine/planner.ts
|
|
4489
|
+
/**
|
|
4490
|
+
* Compute the union of `Vars` field names that the **enabled** rules will
|
|
4491
|
+
* actually read. Sources from each rule's `@Rule({ usesVars: [...] })`
|
|
4492
|
+
* declaration; drift is caught at test time by `strict-vars.test.ts`.
|
|
4493
|
+
*
|
|
4494
|
+
* Inactive rules (skipped via skipWhen at runtime) are STILL counted —
|
|
4495
|
+
* skipWhen depends on parsed-config state we don't have at planning time.
|
|
4496
|
+
* Slight over-fetch in those cases, but no correctness issue.
|
|
4497
|
+
*
|
|
4498
|
+
* Result is stable-sorted for deterministic logging / test snapshots.
|
|
4499
|
+
*/
|
|
4500
|
+
function planVarsFields(opts = {}) {
|
|
4501
|
+
const disabled = new Set(opts.disabled ?? []);
|
|
4502
|
+
const only = opts.onlyRules && opts.onlyRules.length > 0 ? new Set(opts.onlyRules) : null;
|
|
4503
|
+
const fields = /* @__PURE__ */ new Set();
|
|
4504
|
+
for (const rule of getAllRules()) {
|
|
4505
|
+
const key = rule.meta.key;
|
|
4506
|
+
if (disabled.has(key)) continue;
|
|
4507
|
+
if (only && !only.has(key)) continue;
|
|
4508
|
+
for (const f of rule.meta.usesVars ?? []) fields.add(f);
|
|
4509
|
+
}
|
|
4510
|
+
return [...fields].sort();
|
|
4511
|
+
}
|
|
4512
|
+
/**
|
|
4513
|
+
* Plan a `populate` selector for a given subcommand.
|
|
4514
|
+
*
|
|
4515
|
+
* Per-command group needs:
|
|
4516
|
+
*
|
|
4517
|
+
* doctor / check app (rule-driven)
|
|
4518
|
+
* repair app + secrets (writes secretsContent / providerKeyContent)
|
|
4519
|
+
* reset app + secrets + install + reset (the works)
|
|
4520
|
+
* install-* install only
|
|
4521
|
+
*
|
|
4522
|
+
* Empty result (`{}`) means "no group needed" — the CLI can skip the
|
|
4523
|
+
* `fetchCtxViaInnerApi` call entirely and run with a synthetic empty ctx.
|
|
4524
|
+
* Happens e.g. when the user pinned `--rule=<key>` to a vars-free rule on
|
|
4525
|
+
* `doctor`.
|
|
4526
|
+
*/
|
|
4527
|
+
function planCtxPopulate(opts) {
|
|
4528
|
+
if (opts.command === "install") return { install: true };
|
|
4529
|
+
const populate = {};
|
|
4530
|
+
const appFields = planVarsFields({
|
|
4531
|
+
disabled: opts.disabled,
|
|
4532
|
+
onlyRules: opts.onlyRules
|
|
4533
|
+
});
|
|
4534
|
+
if (appFields.length > 0) populate.app = appFields;
|
|
4535
|
+
if (opts.command === "repair") populate.secrets = true;
|
|
4536
|
+
else if (opts.command === "reset") {
|
|
4537
|
+
populate.secrets = true;
|
|
4538
|
+
populate.install = true;
|
|
4539
|
+
populate.reset = true;
|
|
4540
|
+
}
|
|
4541
|
+
return populate;
|
|
4542
|
+
}
|
|
4543
|
+
//#endregion
|
|
3050
4544
|
//#region src/index.ts
|
|
3051
4545
|
const args = node_process.default.argv.slice(2);
|
|
3052
4546
|
const mode = args.find((a) => !a.startsWith("-"));
|
|
@@ -3080,8 +4574,39 @@ function getMultiFlag(args, name) {
|
|
|
3080
4574
|
const prefix = `--${name}=`;
|
|
3081
4575
|
return args.filter((a) => a.startsWith(prefix)).map((a) => a.slice(prefix.length));
|
|
3082
4576
|
}
|
|
4577
|
+
/**
|
|
4578
|
+
* Per-case telemetry helper: fires `openclaw.report_cli_run` once with
|
|
4579
|
+
* the command's outcome. Always called — the previous server-flag gate
|
|
4580
|
+
* was removed (server controls receipt by routing the apiName).
|
|
4581
|
+
*
|
|
4582
|
+
* Best-effort: a thrown reportCliRun is logged to cli.log and swallowed.
|
|
4583
|
+
* Never alters the data path's exit code — the user-visible work has
|
|
4584
|
+
* already finished by the time we report.
|
|
4585
|
+
*
|
|
4586
|
+
* `raw` is kept in the signature for backward symmetry with the doctor
|
|
4587
|
+
* case but is no longer consulted.
|
|
4588
|
+
*/
|
|
4589
|
+
async function reportRun(command, rc, _raw, invocation, durationMs, outcome) {
|
|
4590
|
+
console.error(`${command}: telemetry calling report_cli_run`);
|
|
4591
|
+
try {
|
|
4592
|
+
await reportCliRun({
|
|
4593
|
+
command,
|
|
4594
|
+
runId: rc.runId,
|
|
4595
|
+
version: getVersion(),
|
|
4596
|
+
invocation,
|
|
4597
|
+
durationMs,
|
|
4598
|
+
caller: rc.caller,
|
|
4599
|
+
traceId: rc.traceId,
|
|
4600
|
+
success: outcome.success,
|
|
4601
|
+
result: outcome.result,
|
|
4602
|
+
error: outcome.error ? { message: outcome.error.message } : void 0
|
|
4603
|
+
});
|
|
4604
|
+
} catch (e) {
|
|
4605
|
+
console.error(`[telemetry] reportCliRun failed: ${e.message}`);
|
|
4606
|
+
}
|
|
4607
|
+
}
|
|
3083
4608
|
async function main() {
|
|
3084
|
-
installStderrMirror();
|
|
4609
|
+
installStderrMirror({ stderrEnabled: args.includes("-x") });
|
|
3085
4610
|
const helpFlags = parseHelpFlags(args);
|
|
3086
4611
|
if (mode && helpFlags.help) {
|
|
3087
4612
|
const body = formatCommandHelp(mode);
|
|
@@ -3101,22 +4626,79 @@ async function main() {
|
|
|
3101
4626
|
node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
|
|
3102
4627
|
node_process.default.exit(1);
|
|
3103
4628
|
}
|
|
4629
|
+
const caller = getFlag(args, "caller");
|
|
4630
|
+
const traceId = getFlag(args, "trace-id");
|
|
4631
|
+
const rc = setRunContext({
|
|
4632
|
+
caller,
|
|
4633
|
+
traceId
|
|
4634
|
+
});
|
|
4635
|
+
const t0 = Date.now();
|
|
4636
|
+
console.error(`${mode}: begin argv=[${args.join(" ")}] version=${getVersion()} traceId=${traceId ?? "-"} caller=${caller ?? "-"} runIdGenerated=${rc.generated}`);
|
|
3104
4637
|
switch (mode) {
|
|
3105
|
-
case "check":
|
|
4638
|
+
case "check": {
|
|
4639
|
+
const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4640
|
+
populate: planCtxPopulate({ command: "check" }),
|
|
4641
|
+
caller,
|
|
4642
|
+
traceId
|
|
4643
|
+
});
|
|
4644
|
+
const { legacy, report } = runCheckWithReport(buildCheckInput(raw));
|
|
4645
|
+
console.log(JSON.stringify(legacy));
|
|
4646
|
+
await reportRun("check", rc, raw, args.join(" "), Date.now() - t0, {
|
|
4647
|
+
success: !report.aborted,
|
|
4648
|
+
result: report,
|
|
4649
|
+
error: report.aborted ? /* @__PURE__ */ new Error("check run aborted") : void 0
|
|
4650
|
+
});
|
|
4651
|
+
break;
|
|
4652
|
+
}
|
|
3106
4653
|
case "repair": {
|
|
3107
|
-
const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi(
|
|
3108
|
-
|
|
3109
|
-
|
|
4654
|
+
const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4655
|
+
populate: planCtxPopulate({ command: "repair" }),
|
|
4656
|
+
caller,
|
|
4657
|
+
traceId
|
|
4658
|
+
});
|
|
4659
|
+
const { legacy, report } = runRepairWithReport(buildRepairInput(raw));
|
|
4660
|
+
console.log(JSON.stringify(legacy));
|
|
4661
|
+
await reportRun("repair", rc, raw, args.join(" "), Date.now() - t0, {
|
|
4662
|
+
success: legacy.success,
|
|
4663
|
+
result: report,
|
|
4664
|
+
error: legacy.success ? void 0 : new Error(legacy.error ?? "repair failed")
|
|
4665
|
+
});
|
|
3110
4666
|
break;
|
|
3111
4667
|
}
|
|
3112
4668
|
case "doctor": {
|
|
3113
4669
|
const fix = args.includes("--fix");
|
|
3114
4670
|
const rules = getMultiFlag(args, "rule");
|
|
3115
|
-
const
|
|
4671
|
+
const skipRules = getMultiFlag(args, "skip-rule");
|
|
4672
|
+
const showDiff = args.includes("--show-diff");
|
|
4673
|
+
const populate = planCtxPopulate({
|
|
4674
|
+
command: "doctor",
|
|
4675
|
+
onlyRules: fix && rules.length > 0 ? rules : void 0,
|
|
4676
|
+
disabled: skipRules
|
|
4677
|
+
});
|
|
4678
|
+
console.error(`doctor: populate=${JSON.stringify(populate)} fix=${fix} rules=[${rules.join(",")}] skipRules=[${skipRules.join(",")}]`);
|
|
4679
|
+
let raw;
|
|
4680
|
+
if (Object.keys(populate).length === 0) {
|
|
4681
|
+
console.error("doctor: skipping fetchCtx (empty populate)");
|
|
4682
|
+
raw = {};
|
|
4683
|
+
} else raw = await fetchCtxViaInnerApi({
|
|
4684
|
+
populate,
|
|
4685
|
+
caller,
|
|
4686
|
+
traceId
|
|
4687
|
+
});
|
|
4688
|
+
const result = await runDoctor(raw, {
|
|
3116
4689
|
fix,
|
|
3117
|
-
rules
|
|
4690
|
+
rules,
|
|
4691
|
+
skipRules,
|
|
4692
|
+
showDiff
|
|
3118
4693
|
});
|
|
3119
|
-
console.log(JSON.stringify(result));
|
|
4694
|
+
if (!(showDiff && fix)) console.log(JSON.stringify(result));
|
|
4695
|
+
await reportRun("doctor", rc, raw, args.join(" "), Date.now() - t0, {
|
|
4696
|
+
success: !result.aborted,
|
|
4697
|
+
result,
|
|
4698
|
+
error: result.aborted ? /* @__PURE__ */ new Error("doctor run aborted") : void 0
|
|
4699
|
+
});
|
|
4700
|
+
console.error(`doctor: end aborted=${result.aborted} totalMs=${Date.now() - t0} summary=${JSON.stringify(result.summary)}`);
|
|
4701
|
+
if (result.aborted) node_process.default.exit(1);
|
|
3120
4702
|
break;
|
|
3121
4703
|
}
|
|
3122
4704
|
case "reset":
|
|
@@ -3125,7 +4707,11 @@ async function main() {
|
|
|
3125
4707
|
let ctxBase64;
|
|
3126
4708
|
if (ctxArg) ctxBase64 = ctxArg.slice(6);
|
|
3127
4709
|
else {
|
|
3128
|
-
const fetched = await fetchCtxViaInnerApi(
|
|
4710
|
+
const fetched = await fetchCtxViaInnerApi({
|
|
4711
|
+
populate: planCtxPopulate({ command: "reset" }),
|
|
4712
|
+
caller,
|
|
4713
|
+
traceId
|
|
4714
|
+
});
|
|
3129
4715
|
ctxBase64 = Buffer.from(JSON.stringify(fetched), "utf-8").toString("base64");
|
|
3130
4716
|
}
|
|
3131
4717
|
console.log(JSON.stringify(startAsyncReset(ctxBase64)));
|
|
@@ -3136,7 +4722,22 @@ async function main() {
|
|
|
3136
4722
|
node_process.default.exit(1);
|
|
3137
4723
|
}
|
|
3138
4724
|
const resultFile = resetResultFile(taskId);
|
|
3139
|
-
|
|
4725
|
+
const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4726
|
+
populate: planCtxPopulate({ command: "reset" }),
|
|
4727
|
+
caller,
|
|
4728
|
+
traceId
|
|
4729
|
+
});
|
|
4730
|
+
const outcome = await runReset(buildResetInput(raw), taskId, resultFile);
|
|
4731
|
+
await reportRun("reset", rc, raw, args.join(" "), Date.now() - t0, {
|
|
4732
|
+
success: outcome.success,
|
|
4733
|
+
result: {
|
|
4734
|
+
taskId,
|
|
4735
|
+
steps: outcome.steps,
|
|
4736
|
+
failedStep: outcome.failedStep
|
|
4737
|
+
},
|
|
4738
|
+
error: outcome.success ? void 0 : new Error(outcome.error ?? "reset failed")
|
|
4739
|
+
});
|
|
4740
|
+
if (!outcome.success) node_process.default.exit(1);
|
|
3140
4741
|
} else {
|
|
3141
4742
|
console.error("Usage: reset --async [--ctx=<base64>] | reset --worker --task-id=<id> [--ctx=<base64>]");
|
|
3142
4743
|
node_process.default.exit(1);
|
|
@@ -3159,12 +4760,34 @@ async function main() {
|
|
|
3159
4760
|
}
|
|
3160
4761
|
const ossFileMapFlag = getFlag(args, "oss_file_map");
|
|
3161
4762
|
let installOssFileMap;
|
|
3162
|
-
|
|
3163
|
-
|
|
4763
|
+
let rawForTelemetry;
|
|
4764
|
+
if (!ossFileMapFlag) {
|
|
4765
|
+
rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4766
|
+
populate: planCtxPopulate({ command: "install" }),
|
|
4767
|
+
caller,
|
|
4768
|
+
traceId
|
|
4769
|
+
});
|
|
4770
|
+
installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
|
|
4771
|
+
}
|
|
4772
|
+
const ossFileMap = resolveOssFileMap({
|
|
3164
4773
|
ossFileMapFlag,
|
|
3165
4774
|
installOssFileMap
|
|
3166
|
-
})
|
|
3167
|
-
|
|
4775
|
+
});
|
|
4776
|
+
let success = true;
|
|
4777
|
+
let error;
|
|
4778
|
+
try {
|
|
4779
|
+
await installOpenclaw(tag, ossFileMap);
|
|
4780
|
+
} catch (e) {
|
|
4781
|
+
success = false;
|
|
4782
|
+
error = e;
|
|
4783
|
+
}
|
|
4784
|
+
if (success) console.log(JSON.stringify({ ok: true }));
|
|
4785
|
+
await reportRun("install-openclaw", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
|
|
4786
|
+
success,
|
|
4787
|
+
result: { tag },
|
|
4788
|
+
error
|
|
4789
|
+
});
|
|
4790
|
+
if (error) throw error;
|
|
3168
4791
|
break;
|
|
3169
4792
|
}
|
|
3170
4793
|
case "install-extension": {
|
|
@@ -3180,18 +4803,45 @@ async function main() {
|
|
|
3180
4803
|
const skipConfigUpdate = args.includes("--skip-config-update");
|
|
3181
4804
|
const ossFileMapFlag = getFlag(args, "oss_file_map");
|
|
3182
4805
|
let installOssFileMap;
|
|
3183
|
-
|
|
3184
|
-
|
|
4806
|
+
let rawForTelemetry;
|
|
4807
|
+
if (!ossFileMapFlag) {
|
|
4808
|
+
rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4809
|
+
populate: planCtxPopulate({ command: "install" }),
|
|
4810
|
+
caller,
|
|
4811
|
+
traceId
|
|
4812
|
+
});
|
|
4813
|
+
installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
|
|
4814
|
+
}
|
|
4815
|
+
const ossFileMap = resolveOssFileMap({
|
|
3185
4816
|
ossFileMapFlag,
|
|
3186
4817
|
installOssFileMap
|
|
3187
|
-
}), {
|
|
3188
|
-
all,
|
|
3189
|
-
names: names.length > 0 ? names : void 0,
|
|
3190
|
-
homeBase,
|
|
3191
|
-
configPath,
|
|
3192
|
-
skipConfigUpdate
|
|
3193
4818
|
});
|
|
3194
|
-
|
|
4819
|
+
let success = true;
|
|
4820
|
+
let error;
|
|
4821
|
+
try {
|
|
4822
|
+
await installExtension(tag, ossFileMap, {
|
|
4823
|
+
all,
|
|
4824
|
+
names: names.length > 0 ? names : void 0,
|
|
4825
|
+
homeBase,
|
|
4826
|
+
configPath,
|
|
4827
|
+
skipConfigUpdate
|
|
4828
|
+
});
|
|
4829
|
+
} catch (e) {
|
|
4830
|
+
success = false;
|
|
4831
|
+
error = e;
|
|
4832
|
+
}
|
|
4833
|
+
if (success) console.log(JSON.stringify({ ok: true }));
|
|
4834
|
+
await reportRun("install-extension", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
|
|
4835
|
+
success,
|
|
4836
|
+
result: {
|
|
4837
|
+
tag,
|
|
4838
|
+
all,
|
|
4839
|
+
names,
|
|
4840
|
+
skipConfigUpdate
|
|
4841
|
+
},
|
|
4842
|
+
error
|
|
4843
|
+
});
|
|
4844
|
+
if (error) throw error;
|
|
3195
4845
|
break;
|
|
3196
4846
|
}
|
|
3197
4847
|
case "download-resource": {
|
|
@@ -3209,16 +4859,43 @@ async function main() {
|
|
|
3209
4859
|
}
|
|
3210
4860
|
const ossFileMapFlag = getFlag(args, "oss_file_map");
|
|
3211
4861
|
let installOssFileMap;
|
|
3212
|
-
|
|
3213
|
-
|
|
4862
|
+
let rawForTelemetry;
|
|
4863
|
+
if (!ossFileMapFlag) {
|
|
4864
|
+
rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
|
|
4865
|
+
populate: planCtxPopulate({ command: "install" }),
|
|
4866
|
+
caller,
|
|
4867
|
+
traceId
|
|
4868
|
+
});
|
|
4869
|
+
installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
|
|
4870
|
+
}
|
|
4871
|
+
const ossFileMap = resolveOssFileMap({
|
|
3214
4872
|
ossFileMapFlag,
|
|
3215
4873
|
installOssFileMap
|
|
3216
|
-
}), {
|
|
3217
|
-
role,
|
|
3218
|
-
name,
|
|
3219
|
-
dir
|
|
3220
4874
|
});
|
|
3221
|
-
|
|
4875
|
+
let success = true;
|
|
4876
|
+
let error;
|
|
4877
|
+
try {
|
|
4878
|
+
await downloadResource(tag, ossFileMap, {
|
|
4879
|
+
role,
|
|
4880
|
+
name,
|
|
4881
|
+
dir
|
|
4882
|
+
});
|
|
4883
|
+
} catch (e) {
|
|
4884
|
+
success = false;
|
|
4885
|
+
error = e;
|
|
4886
|
+
}
|
|
4887
|
+
if (success) console.log(JSON.stringify({ ok: true }));
|
|
4888
|
+
await reportRun("download-resource", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
|
|
4889
|
+
success,
|
|
4890
|
+
result: {
|
|
4891
|
+
tag,
|
|
4892
|
+
role,
|
|
4893
|
+
name,
|
|
4894
|
+
dir
|
|
4895
|
+
},
|
|
4896
|
+
error
|
|
4897
|
+
});
|
|
4898
|
+
if (error) throw error;
|
|
3222
4899
|
break;
|
|
3223
4900
|
}
|
|
3224
4901
|
default:
|
|
@@ -3226,10 +4903,12 @@ async function main() {
|
|
|
3226
4903
|
node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
|
|
3227
4904
|
node_process.default.exit(1);
|
|
3228
4905
|
}
|
|
4906
|
+
console.error(`${mode}: end totalMs=${Date.now() - t0}`);
|
|
3229
4907
|
}
|
|
3230
4908
|
main().catch((err) => {
|
|
3231
4909
|
const msg = err instanceof Error ? err.message : String(err);
|
|
3232
|
-
console.error(
|
|
4910
|
+
console.error(`${mode ?? "<no-mode>"}: error message=${msg}`);
|
|
4911
|
+
node_process.default.stderr.write(`Error: ${msg}\n`);
|
|
3233
4912
|
node_process.default.exit(1);
|
|
3234
4913
|
});
|
|
3235
4914
|
//#endregion
|