@lark-apaas/openclaw-scripts-diagnose-cli 0.1.4-alpha.0 → 0.1.4-alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.cjs +2238 -493
  2. 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 _lark_apaas_http_client = require("@lark-apaas/http-client");
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-alpha.2";
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
- var _ModelProviderRule;
470
- let ModelProviderRule = class ModelProviderRule extends DiagnoseRule {
471
- static {
472
- _ModelProviderRule = this;
473
- }
474
- static DEFAULT_API_KEY = {
475
- source: "file",
476
- provider: "miaoda-provider",
477
- id: "value"
478
- };
479
- static DEFAULT_X_API_KEY_HEADER = {
480
- source: "file",
481
- provider: "miaoda-secret-provider",
482
- id: "/models_providers_miaoda_headers_x_api_key"
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
- static DEFAULT_API = "openai-completions";
485
- static getExpectedBaseUrl(vars) {
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 = this.getExpected(ctx.vars);
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 = this.getExpected(ctx.vars);
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 = _ModelProviderRule = __decorate([Rule({
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
- var _GatewayRule;
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 !== _GatewayRule.DEFAULT_PORT) return {
972
+ if (gw.port !== DEFAULT_PORT) return {
907
973
  pass: false,
908
- message: "gateway.port mismatch: got " + gw.port + ", expected " + _GatewayRule.DEFAULT_PORT
974
+ message: "gateway.port mismatch: got " + gw.port + ", expected 18789"
909
975
  };
910
- if (gw.mode !== _GatewayRule.DEFAULT_MODE) return {
976
+ if (gw.mode !== DEFAULT_MODE) return {
911
977
  pass: false,
912
- message: "gateway.mode mismatch: got " + gw.mode + ", expected " + _GatewayRule.DEFAULT_MODE
978
+ message: "gateway.mode mismatch: got " + gw.mode + ", expected local"
913
979
  };
914
- if (gw.bind !== _GatewayRule.DEFAULT_BIND) return {
980
+ if (gw.bind !== DEFAULT_BIND) return {
915
981
  pass: false,
916
- message: "gateway.bind mismatch: got " + gw.bind + ", expected " + _GatewayRule.DEFAULT_BIND
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 !== _GatewayRule.DEFAULT_AUTH_MODE) return {
990
+ if (authObj.mode !== DEFAULT_AUTH_MODE) return {
925
991
  pass: false,
926
- message: "gateway.auth.mode mismatch: got " + authObj.mode + ", expected " + _GatewayRule.DEFAULT_AUTH_MODE
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, _GatewayRule.DEFAULT_AUTH_TOKEN)) return {
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 = _GatewayRule.DEFAULT_TRUSTED_PROXIES.filter((p) => !proxies.includes(p));
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"], _GatewayRule.DEFAULT_PORT);
966
- setNestedValue(ctx.config, ["gateway", "mode"], _GatewayRule.DEFAULT_MODE);
967
- setNestedValue(ctx.config, ["gateway", "bind"], _GatewayRule.DEFAULT_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
- ], _GatewayRule.DEFAULT_AUTH_MODE);
1038
+ ], DEFAULT_AUTH_MODE);
973
1039
  setNestedValue(ctx.config, [
974
1040
  "gateway",
975
1041
  "auth",
976
1042
  "token"
977
- ], _GatewayRule.DEFAULT_AUTH_TOKEN);
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 _GatewayRule.DEFAULT_TRUSTED_PROXIES) if (!seen.has(p)) {
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 = _GatewayRule = __decorate([Rule({
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 SecretsRule = class SecretsRule extends DiagnoseRule {
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
- SecretsRule = __decorate([Rule({
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
- })], SecretsRule);
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/check.ts
1389
- function runCheck(input) {
1390
- const result = { failedRules: {
1391
- standard: [],
1392
- ai: [],
1393
- reset: []
1394
- } };
1395
- const disabledSet = new Set(input.disabledRules || []);
1396
- const rules = getAllRules();
1397
- const failedKeys = /* @__PURE__ */ new Set();
1398
- let configParsed = false;
1399
- let ctx = {
1400
- config: {},
1401
- configPath: input.configPath,
1402
- vars: input.vars,
1403
- providerDeps: {
1404
- usesMiaodaProvider: false,
1405
- usesMiaodaSecretProvider: false
1406
- },
1407
- templateVars: input.templateVars
1408
- };
1409
- for (const rule of rules) {
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
- const failedSet = new Set(input.failedRules || []);
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
- return {
1523
- success: false,
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
- * Central directory for all ephemeral diagnose/reset artifacts: task status
1532
- * files (`reset-<taskId>.json`) and human-readable step logs
1533
- * (`reset-<taskId>.log`). Having everything under one dir makes debugging a
1534
- * stuck reset much easier `ls /tmp/openclaw-diagnose/` shows every recent
1535
- * run, and each run's log is right next to its state.
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
- const DIAGNOSE_DIR = "/tmp/openclaw-diagnose";
1538
- function resetResultFile(taskId) {
1539
- return `${DIAGNOSE_DIR}/reset-${taskId}.json`;
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
- function resetLogFile(taskId) {
1542
- return `${DIAGNOSE_DIR}/reset-${taskId}.log`;
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
- * Shared CLI log file. Every log line the CLI emits — whether through
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
- * `/tmp` is ephemeral on sandbox restart; we rely on that for rotation
1561
- * (no size-based rotation implemented).
1562
- */
1563
- const CLI_LOG_FILE = "/tmp/openclaw-diagnose/cli.log";
1564
- /** Append one line to the shared cli.log. Swallows any fs error —
1565
- * logging must never break the business flow. */
1566
- function appendCliLog(line) {
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
- * Why console.error and not console.log: the CLI's stdout carries the
1580
- * structured JSON result protocol consumed by sandbox_console and other
1581
- * callers any log line on stdout would corrupt JSON parsing. Rules,
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 installStderrMirror() {
1586
- if (stderrMirrorInstalled) return;
1587
- stderrMirrorInstalled = true;
1588
- const original = console.error.bind(console);
1589
- console.error = (...args) => {
1590
- original(...args);
1591
- const body = args.map((a) => typeof a === "string" ? a : safeStringify(a)).join(" ");
1592
- appendCliLog(`[${(/* @__PURE__ */ new Date()).toISOString()}] ${body}\n`);
1593
- };
1594
- }
1595
- function safeStringify(v) {
1596
- try {
1597
- return JSON.stringify(v);
1598
- } catch {
1599
- return String(v);
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
- * Rename src dst, falling back to `mv` (which handles cross-device copy)
1619
- * when the kernel returns EXDEV.
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
- * Sandbox filesystems can put sibling paths on different "devices" from
1622
- * rename(2)'s point of view: bind mounts, overlayfs copy-up, and
1623
- * mount-point children inside a single directory all trip EXDEV. Seen in
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
- * Behavior:
1629
- * - Happy path hits rename(2) atomic, single syscall, microseconds.
1630
- * - EXDEV path shells out to `mv`, which does rename() then copy+unlink
1631
- * on failure. Non-atomic but correct; callers already have rollback
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 moveSafe(src, dst) {
1638
- try {
1639
- node_fs.default.renameSync(src, dst);
1640
- } catch (e) {
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
- * Run a shell command, re-throwing with stderr attached on failure.
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
- (0, node_child_process.execSync)(cmd, { stdio: [
1659
- "ignore",
1660
- "ignore",
1661
- "pipe"
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
- const stderr = e?.stderr;
1665
- const stderrStr = (typeof stderr === "string" ? stderr : stderr?.toString("utf8") ?? "").trim();
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,121 @@ function startAsyncReset(ctxBase64) {
1799
2686
  return { taskId };
1800
2687
  }
1801
2688
  //#endregion
2689
+ //#region src/oss/fetchWithDiag.ts
2690
+ /** Methods retried automatically on transient transport errors. POST and
2691
+ * PATCH are deliberately excluded: a transient ECONNRESET / ETIMEDOUT
2692
+ * after the request body has been written might mean the server
2693
+ * received and processed it (just couldn't reply) — auto-retrying
2694
+ * would cause duplicate side effects. AWS / GCP / Aliyun SDKs all use
2695
+ * the same allowlist. */
2696
+ const IDEMPOTENT_METHODS = new Set([
2697
+ "GET",
2698
+ "HEAD",
2699
+ "OPTIONS",
2700
+ "DELETE",
2701
+ "PUT"
2702
+ ]);
2703
+ /** Errno codes treated as transient — almost always a stale connection
2704
+ * or momentary network blip that succeeds on retry. The list mirrors
2705
+ * what AWS / GCP / Aliyun SDKs retry by default. Only used for fetch
2706
+ * rejection cause; HTTP 4xx/5xx responses pass through unchanged
2707
+ * (caller decides whether the application-level status is retryable). */
2708
+ const TRANSIENT_CODES = new Set([
2709
+ "ECONNRESET",
2710
+ "ETIMEDOUT",
2711
+ "EPIPE",
2712
+ "ECONNREFUSED",
2713
+ "EHOSTUNREACH",
2714
+ "ENETUNREACH",
2715
+ "EAI_AGAIN"
2716
+ ]);
2717
+ async function fetchWithDiag(url, opts = {}) {
2718
+ const maxRetries = opts.maxRetries ?? 2;
2719
+ const method = (opts.init?.method ?? "GET").toUpperCase();
2720
+ const retrySafe = opts.retryNonIdempotent || IDEMPOTENT_METHODS.has(method);
2721
+ let lastErr;
2722
+ for (let attempt = 0; attempt <= maxRetries; attempt++) try {
2723
+ return await fetchOnce(url, opts);
2724
+ } catch (e) {
2725
+ lastErr = e;
2726
+ const code = extractCauseCode(e);
2727
+ if (!(code !== void 0 && TRANSIENT_CODES.has(code)) || attempt === maxRetries) throw e;
2728
+ if (!retrySafe) {
2729
+ console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code} but method=${method} is non-idempotent, NOT retrying`);
2730
+ throw e;
2731
+ }
2732
+ const delay = 200 * Math.pow(2, attempt) + Math.floor(Math.random() * 100);
2733
+ console.error(`fetch ${opts.label ?? originOf(url)}: transient ${code}, retrying in ${delay}ms (attempt ${attempt + 1}/${maxRetries})`);
2734
+ await new Promise((r) => setTimeout(r, delay));
2735
+ }
2736
+ throw lastErr;
2737
+ }
2738
+ /** One fetch attempt with timeout. Throws on transport / abort failures
2739
+ * (caught by the retry loop above) and on its own enriched message form. */
2740
+ async function fetchOnce(url, opts) {
2741
+ const label = opts.label ?? originOf(url);
2742
+ const timeoutMs = opts.timeoutMs ?? 3e4;
2743
+ const start = Date.now();
2744
+ const ac = new AbortController();
2745
+ const timer = timeoutMs > 0 && Number.isFinite(timeoutMs) ? setTimeout(() => ac.abort(), timeoutMs) : void 0;
2746
+ try {
2747
+ return await fetch(url, {
2748
+ ...opts.init,
2749
+ signal: ac.signal
2750
+ });
2751
+ } catch (e) {
2752
+ const durationMs = Date.now() - start;
2753
+ const causeStr = e.name === "AbortError" || ac.signal.aborted && timeoutMs > 0 ? `request aborted after ${timeoutMs}ms (timeout)` : describeCause(e.cause) || e.message;
2754
+ const enriched = /* @__PURE__ */ new Error(`fetch ${label} failed: ${causeStr} (url=${redactUrl(url)} durationMs=${durationMs})`);
2755
+ enriched.cause = e.cause ?? e;
2756
+ throw enriched;
2757
+ } finally {
2758
+ if (timer) clearTimeout(timer);
2759
+ }
2760
+ }
2761
+ /** Pull the errno-style code from the deepest cause we can reach. Used by
2762
+ * retry-classification only — error messages still get the human-readable
2763
+ * describeCause() rendering. */
2764
+ function extractCauseCode(e) {
2765
+ let cur = e.cause ?? e;
2766
+ for (let depth = 0; depth < 5 && cur; depth++) {
2767
+ const code = cur.code;
2768
+ if (typeof code === "string") return code;
2769
+ cur = cur.cause;
2770
+ }
2771
+ }
2772
+ /** Walk the Error.cause chain and produce a single-line summary like
2773
+ * `ENOTFOUND getaddrinfo ENOTFOUND oss.example.com`. */
2774
+ function describeCause(c, depth = 0) {
2775
+ if (!c || depth > 3) return "";
2776
+ if (c instanceof Error) {
2777
+ const code = c.code;
2778
+ const head = code ? `${code} ${c.message}` : c.message;
2779
+ const inner = describeCause(c.cause, depth + 1);
2780
+ return inner ? `${head} <- ${inner}` : head;
2781
+ }
2782
+ return String(c);
2783
+ }
2784
+ /** Strip query string — OSS signed URLs put the auth token there. Keeps
2785
+ * protocol/host/path so the operator can tell *which* OSS bucket / object
2786
+ * was being fetched. */
2787
+ function redactUrl(raw) {
2788
+ try {
2789
+ const u = new URL(raw);
2790
+ const tail = u.search ? "?<redacted>" : "";
2791
+ return `${u.protocol}//${u.host}${u.pathname}${tail}`;
2792
+ } catch {
2793
+ return "<invalid-url>";
2794
+ }
2795
+ }
2796
+ function originOf(raw) {
2797
+ try {
2798
+ return new URL(raw).host;
2799
+ } catch {
2800
+ return "unknown-host";
2801
+ }
2802
+ }
2803
+ //#endregion
1802
2804
  //#region src/oss/fetchManifest.ts
1803
2805
  const MANIFEST_PREFIX = "builtin/manifests/openclaw/recommended/";
1804
2806
  const MANIFEST_SUFFIX = ".json";
@@ -1810,9 +2812,23 @@ async function fetchManifest(ossFileMap, tag) {
1810
2812
  const availStr = available.length ? available.join(", ") : "(none)";
1811
2813
  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
2814
  }
1813
- const res = await fetch(url);
1814
- if (!res.ok) throw new Error(`fetch manifest failed: HTTP ${res.status} ${res.statusText}`);
1815
- return await res.json();
2815
+ const label = `openclaw manifest tag=${tag}`;
2816
+ const res = await fetchWithDiag(url, { label });
2817
+ if (!res.ok) {
2818
+ const body = await readPreview(res);
2819
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (body ? ` body=${body}` : ""));
2820
+ }
2821
+ return await res.json();
2822
+ }
2823
+ /** Best-effort body preview for HTTP-error diagnostics. OSS errors are
2824
+ * XML; first 256 chars usually contain the <Code> + <Message> we care
2825
+ * about. Failures here are swallowed (logging-only, not load-bearing). */
2826
+ async function readPreview(res) {
2827
+ try {
2828
+ return (await res.text()).slice(0, 256);
2829
+ } catch {
2830
+ return "";
2831
+ }
1816
2832
  }
1817
2833
  async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1818
2834
  const cacheRoot = opts.cacheRoot ?? "/tmp/openclaw-diagnose/resources";
@@ -1827,9 +2843,19 @@ async function downloadWithCache(pkg, ossFileMap, opts = {}) {
1827
2843
  const expected = pkg.integrity.slice(7);
1828
2844
  const tmpFile = node_path.default.join(destDir, `.tmp.${process.pid}.${node_crypto.default.randomBytes(4).toString("hex")}`);
1829
2845
  try {
1830
- const res = await fetch(url);
1831
- if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
1832
- if (!res.body) throw new Error(`download failed: empty body for ${pkg.ossKey}`);
2846
+ const label = `download ${pkg.ossKey}`;
2847
+ const res = await fetchWithDiag(url, {
2848
+ label,
2849
+ timeoutMs: 6e4
2850
+ });
2851
+ if (!res.ok) {
2852
+ let preview = "";
2853
+ try {
2854
+ preview = (await res.text()).slice(0, 256);
2855
+ } catch {}
2856
+ throw new Error(`${label}: HTTP ${res.status} ${res.statusText}` + (preview ? ` body=${preview}` : ""));
2857
+ }
2858
+ if (!res.body) throw new Error(`${label}: empty body`);
1833
2859
  const hasher = node_crypto.default.createHash("sha512");
1834
2860
  const source = node_stream.Readable.fromWeb(res.body);
1835
2861
  async function* teeAndHash(src) {
@@ -2354,16 +3380,6 @@ function writeSecretsAndRestart(vars, resetData, configDir, log) {
2354
3380
  log(`restart.sh done in ${Date.now() - t}ms`);
2355
3381
  } else log(`no restart.sh at ${restartScript}, skip`);
2356
3382
  }
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
3383
  async function runReset(input, taskId, resultFile) {
2368
3384
  const startedAt = (/* @__PURE__ */ new Date()).toISOString();
2369
3385
  const { configPath, vars, resetData } = input;
@@ -2371,6 +3387,7 @@ async function runReset(input, taskId, resultFile) {
2371
3387
  const stagedDir = node_path.default.join(DIAGNOSE_DIR, `reset-${taskId}-template`);
2372
3388
  let currentStep = 0;
2373
3389
  let stepStartedAt = Date.now();
3390
+ const stepResults = [];
2374
3391
  const log = makeLogger(resetLogFile(taskId));
2375
3392
  log(`=== reset started, taskId=${taskId}, pid=${process.pid} ===`);
2376
3393
  log(`configPath=${configPath}, configDir=${configDir}, stagedDir=${stagedDir}`);
@@ -2379,7 +3396,12 @@ async function runReset(input, taskId, resultFile) {
2379
3396
  const err = "resetData.ossFileMap missing or empty";
2380
3397
  log(`ERROR: ${err}`);
2381
3398
  markFailed(resultFile, 0, err, startedAt);
2382
- process.exit(1);
3399
+ return {
3400
+ success: false,
3401
+ steps: stepResults,
3402
+ error: err,
3403
+ failedStep: 0
3404
+ };
2383
3405
  }
2384
3406
  let openclawTag;
2385
3407
  if (resetData.openclawTag) openclawTag = resetData.openclawTag;
@@ -2389,7 +3411,12 @@ async function runReset(input, taskId, resultFile) {
2389
3411
  const err = e.message;
2390
3412
  log(`ERROR: ${err}`);
2391
3413
  markFailed(resultFile, 0, err, startedAt);
2392
- process.exit(1);
3414
+ return {
3415
+ success: false,
3416
+ steps: stepResults,
3417
+ error: err,
3418
+ failedStep: 0
3419
+ };
2393
3420
  }
2394
3421
  log(`openclawTag=${openclawTag}`);
2395
3422
  process.on("uncaughtException", (err) => {
@@ -2402,9 +3429,19 @@ async function runReset(input, taskId, resultFile) {
2402
3429
  markFailed(resultFile, currentStep, `unhandled rejection: ${reason}`, startedAt);
2403
3430
  process.exit(1);
2404
3431
  });
2405
- /** Advance to the next step, updating the progress file and logging a boundary. */
3432
+ /** Advance to the next step, recording the previous step's success
3433
+ * duration and updating the on-disk progress file. */
2406
3434
  const step = (n) => {
2407
- if (currentStep > 0) log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${Date.now() - stepStartedAt}ms`);
3435
+ if (currentStep > 0) {
3436
+ const dur = Date.now() - stepStartedAt;
3437
+ log(`step ${currentStep} "${STEPS[currentStep - 1]}" done in ${dur}ms`);
3438
+ stepResults.push({
3439
+ step: currentStep,
3440
+ name: STEPS[currentStep - 1],
3441
+ status: "ok",
3442
+ durationMs: dur
3443
+ });
3444
+ }
2408
3445
  currentStep = n;
2409
3446
  stepStartedAt = Date.now();
2410
3447
  log(`--- step ${n}/${TOTAL_STEPS}: ${STEPS[n - 1]} ---`);
@@ -2430,14 +3467,38 @@ async function runReset(input, taskId, resultFile) {
2430
3467
  await step8InstallExtensions(openclawTag, ossFileMap, log);
2431
3468
  step(9);
2432
3469
  writeSecretsAndRestart(vars, resetData, configDir, log);
2433
- log(`step 9 "${STEPS[8]}" done in ${Date.now() - stepStartedAt}ms`);
3470
+ const lastDur = Date.now() - stepStartedAt;
3471
+ log(`step 9 "${STEPS[8]}" done in ${lastDur}ms`);
3472
+ stepResults.push({
3473
+ step: 9,
3474
+ name: STEPS[8],
3475
+ status: "ok",
3476
+ durationMs: lastDur
3477
+ });
2434
3478
  log("=== reset completed successfully ===");
2435
3479
  markDone(resultFile, startedAt);
3480
+ return {
3481
+ success: true,
3482
+ steps: stepResults
3483
+ };
2436
3484
  } catch (e) {
2437
3485
  const err = e.message;
2438
- log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${Date.now() - stepStartedAt}ms: ${err}\n${e.stack ?? ""}`);
3486
+ const dur = Date.now() - stepStartedAt;
3487
+ log(`ERROR in step ${currentStep} "${STEPS[currentStep - 1] ?? "init"}" after ${dur}ms: ${err}\n${e.stack ?? ""}`);
3488
+ stepResults.push({
3489
+ step: currentStep,
3490
+ name: STEPS[currentStep - 1] ?? "init",
3491
+ status: "fail",
3492
+ durationMs: dur,
3493
+ error: err
3494
+ });
2439
3495
  markFailed(resultFile, currentStep, err, startedAt);
2440
- process.exit(1);
3496
+ return {
3497
+ success: false,
3498
+ steps: stepResults,
3499
+ error: err,
3500
+ failedStep: currentStep
3501
+ };
2441
3502
  } finally {
2442
3503
  try {
2443
3504
  node_fs.default.rmSync(stagedDir, {
@@ -2504,38 +3565,24 @@ function resolveOssFileMap(args) {
2504
3565
  throw new Error("ossFileMap missing: provide --oss_file_map flag, ctx.install.ossFileMap, or resetData.ossFileMap");
2505
3566
  }
2506
3567
  //#endregion
2507
- //#region src/innerapi/fetchCtx.ts
3568
+ //#region src/innerapi/client.ts
2508
3569
  /**
2509
- * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
2510
- *
2511
- * Mirrors the proven pattern in
2512
- * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
3570
+ * Shared innerapi HTTP client + path constants for `openclaw.*` calls.
2513
3571
  *
2514
3572
  * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
2515
3573
  * openclaw sandbox).
2516
3574
  * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
2517
3575
  * plugin auto-attaches the sandbox's identity JWT loaded from the
2518
3576
  * rootfs token file. Same auth that the miaoda extension already uses.
2519
- * - POST `/api/v1/studio/innerapi/integration_apis/call`
2520
- * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
2521
- * the server-side APICall dispatches by `apiName` to
2522
- * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
2523
- * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
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.
3577
+ * - POST `/api/v1/studio/innerapi/integration_apis/call` with body
3578
+ * `{ apiName, bizType, input }` is the universal dispatcher; specific
3579
+ * apiName values (e.g. `openclaw.get_doctor_ctx`,
3580
+ * `openclaw.report_doctor_result`) are owned by individual call-site
3581
+ * modules.
2533
3582
  */
2534
3583
  const INNERAPI_CALL_PATH = "/api/v1/studio/innerapi/integration_apis/call";
2535
- const API_NAME = "openclaw.get_doctor_ctx";
2536
3584
  const BIZ_TYPE = "openclaw";
2537
3585
  const API_TIMEOUT_MS = 3e4;
2538
- const MAX_LOG_BODY = 500;
2539
3586
  let clientInstance = null;
2540
3587
  function getHttpClient() {
2541
3588
  if (!clientInstance) {
@@ -2552,6 +3599,35 @@ function getHttpClient() {
2552
3599
  }
2553
3600
  return clientInstance;
2554
3601
  }
3602
+ //#endregion
3603
+ //#region src/innerapi/fetchCtx.ts
3604
+ /**
3605
+ * CLI-side client for studio_server's `openclaw.get_doctor_ctx` inner API.
3606
+ *
3607
+ * Mirrors the proven pattern in
3608
+ * `packages/openclaw/extensions/miaoda/src/shared/innerapi-client.ts`:
3609
+ *
3610
+ * - `baseURL` from env `FORCE_AUTHN_INNERAPI_DOMAIN` (injected into every
3611
+ * openclaw sandbox).
3612
+ * - `platform: { enabled, tokenProvider: { type: 'file' } }` — the platform
3613
+ * plugin auto-attaches the sandbox's identity JWT loaded from the
3614
+ * rootfs token file. Same auth that the miaoda extension already uses.
3615
+ * - POST `/api/v1/studio/innerapi/integration_apis/call`
3616
+ * body = { apiName: 'openclaw.get_doctor_ctx', input: {}, bizType: 'openclaw' }
3617
+ * — the server-side APICall dispatches by `apiName` to
3618
+ * `GetDoctorCtxAPICall.Execute` whose `Name()` returns that string.
3619
+ * - Response envelope: { status_code, error_msg?, data: { success, output, ... } }.
3620
+ * `status_code` is a *string* ('0' = success).
3621
+ * Actual DoctorCtx lives in `data.output`.
3622
+ * - `x-tt-logid` header is logged on every failure path for cross-service
3623
+ * traceability.
3624
+ *
3625
+ * On HTTP 401 (sandbox identity token expired/invalid) we `process.exit(77)`
3626
+ * instead of throwing — the outer catch in `index.ts` cannot then mask auth
3627
+ * failure as a generic "Error: ...". Caller (e.g. sandbox_console) sees the
3628
+ * exit code and can refresh the token + retry.
3629
+ */
3630
+ const API_NAME$1 = "openclaw.get_doctor_ctx";
2555
3631
  /**
2556
3632
  * Fetch the sandbox's DoctorCtx by calling the innerapi's generic
2557
3633
  * `integration_apis/call` dispatcher with apiName=openclaw.get_doctor_ctx.
@@ -2559,14 +3635,19 @@ function getHttpClient() {
2559
3635
  * Throws on HTTP (non-401) / decode / business errors. On 401 calls
2560
3636
  * `process.exit(77)` directly.
2561
3637
  */
2562
- async function fetchCtxViaInnerApi() {
3638
+ async function fetchCtxViaInnerApi(opts = {}) {
2563
3639
  const client = getHttpClient();
3640
+ const input = {};
3641
+ if (opts.populate !== void 0) input.populate = opts.populate;
3642
+ if (opts.caller) input.caller = opts.caller;
3643
+ if (opts.traceId) input.traceId = opts.traceId;
2564
3644
  const body = {
2565
- apiName: API_NAME,
2566
- input: {},
3645
+ apiName: API_NAME$1,
3646
+ input,
2567
3647
  bizType: BIZ_TYPE
2568
3648
  };
2569
3649
  const start = Date.now();
3650
+ console.error(`fetchCtx: start populate=${JSON.stringify(opts.populate ?? {})}${opts.caller ? ` caller=${opts.caller}` : ""}${opts.traceId ? ` traceId=${opts.traceId}` : ""}`);
2570
3651
  const headers = { "Content-Type": "application/json" };
2571
3652
  const ttEnv = process.env.X_TT_ENV;
2572
3653
  if (ttEnv) headers["x-tt-env"] = ttEnv;
@@ -2596,7 +3677,7 @@ async function fetchCtxViaInnerApi() {
2596
3677
  }
2597
3678
  let preview = "";
2598
3679
  try {
2599
- preview = (await response.text()).slice(0, MAX_LOG_BODY);
3680
+ preview = (await response.text()).slice(0, 500);
2600
3681
  } catch {}
2601
3682
  throw new Error(`fetchCtxViaInnerApi HTTP ${response.status} ${response.statusText} (logID: ${logId}, durationMs: ${durationMs})${preview ? ` body=${preview}` : ""}`);
2602
3683
  }
@@ -2610,6 +3691,7 @@ async function fetchCtxViaInnerApi() {
2610
3691
  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
3692
  const output = envelope.data?.output;
2612
3693
  if (!output || typeof output !== "object") throw new Error(`fetchCtxViaInnerApi empty/invalid output (logID: ${logId}, durationMs: ${durationMs})`);
3694
+ console.error(`fetchCtx: ok logId=${logId} durationMs=${durationMs} groups=[${Object.keys(output).join(",")}]`);
2613
3695
  return output;
2614
3696
  }
2615
3697
  //#endregion
@@ -2628,26 +3710,40 @@ async function fetchCtxViaInnerApi() {
2628
3710
  */
2629
3711
  function normalizeCtx(raw) {
2630
3712
  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") return {
2632
- app: fillApp(r.app),
2633
- install: {
2634
- openclawTag: r.install.openclawTag,
2635
- ossFileMap: r.install.ossFileMap ?? {}
2636
- },
2637
- secrets: {
2638
- secretsContent: r.secrets.secretsContent ?? "",
2639
- providerKeyContent: r.secrets.providerKeyContent ?? ""
2640
- },
2641
- reset: {
2642
- templateVars: r.reset.templateVars ?? {},
2643
- coreBackup: r.reset.coreBackup
2644
- }
2645
- };
3713
+ 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") {
3714
+ const templateVars = r.app.templateVars && typeof r.app.templateVars === "object" ? r.app.templateVars : r.reset.templateVars ?? {};
3715
+ 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;
3716
+ return {
3717
+ app: {
3718
+ ...fillApp(r.app),
3719
+ templateVars,
3720
+ recommendedOpenclawTag
3721
+ },
3722
+ install: {
3723
+ openclawTag: r.install.openclawTag,
3724
+ ossFileMap: r.install.ossFileMap ?? {}
3725
+ },
3726
+ secrets: {
3727
+ secretsContent: r.secrets.secretsContent ?? "",
3728
+ providerKeyContent: r.secrets.providerKeyContent ?? ""
3729
+ },
3730
+ reset: {
3731
+ templateVars: r.reset.templateVars ?? {},
3732
+ coreBackup: r.reset.coreBackup
3733
+ }
3734
+ };
3735
+ }
2646
3736
  const vars = r.vars ?? {};
2647
3737
  const resetData = r.resetData ?? {};
2648
3738
  const repairData = r.repairData ?? {};
3739
+ const legacyTemplateVars = vars.templateVars && typeof vars.templateVars === "object" ? vars.templateVars : resetData.templateVars ?? r.templateVars ?? {};
3740
+ 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
3741
  return {
2650
- app: fillApp(vars),
3742
+ app: {
3743
+ ...fillApp(vars),
3744
+ templateVars: legacyTemplateVars,
3745
+ recommendedOpenclawTag: legacyRecommendedTag
3746
+ },
2651
3747
  install: {
2652
3748
  openclawTag: r.install?.openclawTag ?? r.openclawTag,
2653
3749
  ossFileMap: r.install?.ossFileMap ?? resetData.ossFileMap ?? r.ossFileMap ?? {}
@@ -2700,11 +3796,15 @@ function fillApp(src) {
2700
3796
  function buildCheckInput(raw, configPathOverride) {
2701
3797
  const r = raw ?? {};
2702
3798
  if (r.configPath && r.vars) {
2703
- if (configPathOverride) return {
3799
+ const out = configPathOverride ? {
2704
3800
  ...r,
2705
3801
  configPath: configPathOverride
3802
+ } : { ...r };
3803
+ if (out.vars && !out.vars.templateVars) out.vars = {
3804
+ ...out.vars,
3805
+ templateVars: out.templateVars ?? {}
2706
3806
  };
2707
- return r;
3807
+ return out;
2708
3808
  }
2709
3809
  const ctx = normalizeCtx(raw);
2710
3810
  return {
@@ -2718,19 +3818,25 @@ function buildCheckInput(raw, configPathOverride) {
2718
3818
  baseURL: ctx.app.baseURL,
2719
3819
  expectedOrigins: ctx.app.expectedOrigins,
2720
3820
  providerFilePath: PROVIDER_FILE_PATH,
2721
- secretsFilePath: SECRETS_FILE_PATH
3821
+ secretsFilePath: SECRETS_FILE_PATH,
3822
+ templateVars: ctx.app.templateVars,
3823
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2722
3824
  },
2723
- templateVars: ctx.reset.templateVars
3825
+ templateVars: ctx.app.templateVars
2724
3826
  };
2725
3827
  }
2726
3828
  function buildRepairInput(raw, configPathOverride) {
2727
3829
  const r = raw ?? {};
2728
3830
  if (r.configPath && r.vars) {
2729
- if (configPathOverride) return {
3831
+ const out = configPathOverride ? {
2730
3832
  ...r,
2731
3833
  configPath: configPathOverride
3834
+ } : { ...r };
3835
+ if (out.vars && !out.vars.templateVars) out.vars = {
3836
+ ...out.vars,
3837
+ templateVars: out.templateVars ?? {}
2732
3838
  };
2733
- return r;
3839
+ return out;
2734
3840
  }
2735
3841
  const ctx = normalizeCtx(raw);
2736
3842
  return {
@@ -2744,23 +3850,29 @@ function buildRepairInput(raw, configPathOverride) {
2744
3850
  baseURL: ctx.app.baseURL,
2745
3851
  expectedOrigins: ctx.app.expectedOrigins,
2746
3852
  providerFilePath: PROVIDER_FILE_PATH,
2747
- secretsFilePath: SECRETS_FILE_PATH
3853
+ secretsFilePath: SECRETS_FILE_PATH,
3854
+ templateVars: ctx.app.templateVars,
3855
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2748
3856
  },
2749
3857
  repairData: {
2750
3858
  secretsContent: ctx.secrets.secretsContent,
2751
3859
  providerKeyContent: ctx.secrets.providerKeyContent
2752
3860
  },
2753
- templateVars: ctx.reset.templateVars
3861
+ templateVars: ctx.app.templateVars
2754
3862
  };
2755
3863
  }
2756
3864
  function buildResetInput(raw, configPathOverride) {
2757
3865
  const r = raw ?? {};
2758
3866
  if (r.configPath && r.vars && r.resetData) {
2759
- if (configPathOverride) return {
3867
+ const out = configPathOverride ? {
2760
3868
  ...r,
2761
3869
  configPath: configPathOverride
3870
+ } : { ...r };
3871
+ if (out.vars && !out.vars.templateVars) out.vars = {
3872
+ ...out.vars,
3873
+ templateVars: out.resetData?.templateVars ?? {}
2762
3874
  };
2763
- return r;
3875
+ return out;
2764
3876
  }
2765
3877
  const ctx = normalizeCtx(raw);
2766
3878
  return {
@@ -2774,7 +3886,9 @@ function buildResetInput(raw, configPathOverride) {
2774
3886
  baseURL: ctx.app.baseURL,
2775
3887
  expectedOrigins: ctx.app.expectedOrigins,
2776
3888
  providerFilePath: PROVIDER_FILE_PATH,
2777
- secretsFilePath: SECRETS_FILE_PATH
3889
+ secretsFilePath: SECRETS_FILE_PATH,
3890
+ templateVars: ctx.app.templateVars,
3891
+ recommendedOpenclawTag: ctx.app.recommendedOpenclawTag
2778
3892
  },
2779
3893
  resetData: {
2780
3894
  templateVars: ctx.reset.templateVars,
@@ -2788,30 +3902,381 @@ function buildResetInput(raw, configPathOverride) {
2788
3902
  }
2789
3903
  //#endregion
2790
3904
  //#region src/doctor.ts
3905
+ /**
3906
+ * Per-rule orchestration with telemetry-friendly outcomes.
3907
+ *
3908
+ * Flow per rule (in topological order, after disabledRules / dependsOn /
3909
+ * skipWhen filtering):
3910
+ *
3911
+ * 1. validate — exception → status='error', abort
3912
+ * 2. pass → status='pass'
3913
+ * 3. fail and !opts.fix → status='failed' (with v1.message)
3914
+ * 4. fail and opts.fix →
3915
+ * a. only standard-mode rules attempt repair; ai/reset stay 'failed'
3916
+ * b. repair — exception → status='error', abort
3917
+ * c. re-validate — exception → status='error', abort
3918
+ * d. v2.pass → status='fixed' (before = v1.message)
3919
+ * e. v2.fail → status='still-broken' (before+after)
3920
+ *
3921
+ * Aborted runs return a partial DoctorReport with `aborted: true`. The
3922
+ * caller should `console.log(JSON.stringify(report))` first, THEN
3923
+ * `process.exit(1)` so sweep / telemetry callers always get the partial
3924
+ * data plus the non-zero signal.
3925
+ *
3926
+ * Config writeback: if opts.fix and at least one rule transitioned
3927
+ * pass→fixed, the in-memory ctx.config is JSON-serialized and written
3928
+ * back to opts.configPath. On abort, no write — keep the on-disk file
3929
+ * matching whatever was successful before the failure.
3930
+ *
3931
+ * Secrets file / provider-key / restart-command writes are NOT performed
3932
+ * by doctor. Those belong to the standalone `repair` subcommand which
3933
+ * sandbox_console push uses. Doctor is for diagnosis + per-rule fix
3934
+ * with structured outcomes.
3935
+ */
2791
3936
  async function runDoctor(rawCtx, opts) {
2792
- if (opts.fix && opts.rules.length > 0) {
2793
- const repairInput = buildRepairInput(rawCtx, opts.configPath);
2794
- repairInput.failedRules = opts.rules;
2795
- repairInput.repairData = {
2796
- ...repairInput.repairData ?? {},
2797
- restartCommand: ""
2798
- };
2799
- return { repair: runRepair(repairInput) };
2800
- }
2801
- const check = runCheck(buildCheckInput(rawCtx, opts.configPath));
2802
- if (!opts.fix) return { failedRules: check.failedRules };
2803
- const repairInput = buildRepairInput(rawCtx, opts.configPath);
2804
- repairInput.failedRules = check.failedRules.standard;
3937
+ const results = [];
3938
+ console.error(`runDoctor: begin fix=${opts.fix} rules=[${opts.rules.join(",")}] configPath=${opts.configPath ?? "(default)"}`);
3939
+ const checkInput = buildCheckInput(rawCtx, opts.configPath);
3940
+ let configRaw;
3941
+ let config;
3942
+ try {
3943
+ configRaw = readFile(checkInput.configPath);
3944
+ } catch (e) {
3945
+ console.error(`runDoctor: read config failed at ${checkInput.configPath} message=${e.message}`);
3946
+ return finalize([{
3947
+ rule: "config_syntax_check",
3948
+ status: "error",
3949
+ message: "read config failed: " + e.message
3950
+ }], true);
3951
+ }
3952
+ try {
3953
+ config = loadJSON5().parse(configRaw);
3954
+ } catch (e) {
3955
+ console.error(`runDoctor: config JSON5 parse failed message=${e.message}`);
3956
+ results.push({
3957
+ rule: "config_syntax_check",
3958
+ status: opts.fix ? "still-broken" : "failed",
3959
+ message: "config JSON5 parse failed: " + e.message
3960
+ });
3961
+ return finalize(results, true);
3962
+ }
3963
+ const ctx = {
3964
+ config,
3965
+ configPath: checkInput.configPath,
3966
+ vars: checkInput.vars,
3967
+ providerDeps: analyzeProviderDeps(config)
3968
+ };
3969
+ const originalConfig = opts.showDiff && opts.fix ? deepClone(config) : null;
3970
+ const allRules = getAllRules();
3971
+ const onlyKeys = opts.rules.length > 0 ? new Set(opts.rules) : null;
3972
+ const skipRuleKeys = new Set(opts.skipRules ?? []);
3973
+ const disabledKeys = new Set([...checkInput.disabledRules ?? [], ...skipRuleKeys]);
3974
+ if (onlyKeys) {
3975
+ const registeredKeys = new Set(allRules.map((r) => r.meta.key));
3976
+ for (const k of opts.rules) if (!registeredKeys.has(k)) results.push({
3977
+ rule: k,
3978
+ status: "unknown"
3979
+ });
3980
+ }
3981
+ const failedKeys = /* @__PURE__ */ new Set();
3982
+ let configDirty = false;
3983
+ let aborted = false;
3984
+ const hasMiaoda = shouldCheckMiaodaRules(config);
3985
+ const plannedRules = allRules.filter((r) => !onlyKeys || onlyKeys.has(r.meta.key));
3986
+ console.error(`runDoctor: planned ${plannedRules.length} rules hasMiaoda=${hasMiaoda} providerDeps=${JSON.stringify(ctx.providerDeps)}`);
3987
+ outer: for (const rule of allRules) {
3988
+ const key = rule.meta.key;
3989
+ if (onlyKeys && !onlyKeys.has(key)) continue;
3990
+ if (disabledKeys.has(key)) {
3991
+ const reason = skipRuleKeys.has(key) ? "--skip-rule" : "disabledRules";
3992
+ console.error(`rule ${key}: skipped reason=${reason}`);
3993
+ results.push({
3994
+ rule: key,
3995
+ status: "skipped",
3996
+ message: reason
3997
+ });
3998
+ continue;
3999
+ }
4000
+ if (rule.meta.dependsOn?.some((d) => failedKeys.has(d))) {
4001
+ const blockers = rule.meta.dependsOn?.filter((d) => failedKeys.has(d)).join(",");
4002
+ console.error(`rule ${key}: skipped reason=dependsOn-failed blockers=${blockers}`);
4003
+ results.push({
4004
+ rule: key,
4005
+ status: "skipped",
4006
+ message: `dependsOn failed: ${blockers}`
4007
+ });
4008
+ continue;
4009
+ }
4010
+ if (rule.meta.skipWhen?.({
4011
+ hasMiaoda,
4012
+ deps: ctx.providerDeps
4013
+ })) {
4014
+ console.error(`rule ${key}: skipped reason=skipWhen`);
4015
+ results.push({
4016
+ rule: key,
4017
+ status: "skipped",
4018
+ message: "skipWhen"
4019
+ });
4020
+ continue;
4021
+ }
4022
+ const tValidate = Date.now();
4023
+ let v1;
4024
+ try {
4025
+ v1 = rule.validate(ctx);
4026
+ } catch (e) {
4027
+ const msg = e.message;
4028
+ console.error(`rule ${key}: validate -> error durationMs=${Date.now() - tValidate} message=${msg}`);
4029
+ results.push({
4030
+ rule: key,
4031
+ status: "error",
4032
+ message: "validate threw: " + msg
4033
+ });
4034
+ aborted = true;
4035
+ break outer;
4036
+ }
4037
+ if (v1.pass) {
4038
+ console.error(`rule ${key}: validate -> pass durationMs=${Date.now() - tValidate}`);
4039
+ results.push({
4040
+ rule: key,
4041
+ status: "pass"
4042
+ });
4043
+ continue;
4044
+ }
4045
+ if (!opts.fix) {
4046
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
4047
+ results.push({
4048
+ rule: key,
4049
+ status: "failed",
4050
+ message: v1.message,
4051
+ action: v1.action
4052
+ });
4053
+ failedKeys.add(key);
4054
+ continue;
4055
+ }
4056
+ if (rule.meta.repairMode !== "standard") {
4057
+ console.error(`rule ${key}: validate -> failed (no auto-repair, repairMode=${rule.meta.repairMode}) durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
4058
+ results.push({
4059
+ rule: key,
4060
+ status: "failed",
4061
+ message: v1.message,
4062
+ action: v1.action
4063
+ });
4064
+ failedKeys.add(key);
4065
+ continue;
4066
+ }
4067
+ console.error(`rule ${key}: validate -> failed durationMs=${Date.now() - tValidate} message=${v1.message ?? ""}`);
4068
+ const tRepair = Date.now();
4069
+ try {
4070
+ rule.repair(ctx);
4071
+ } catch (e) {
4072
+ const msg = e.message;
4073
+ console.error(`rule ${key}: repair -> error durationMs=${Date.now() - tRepair} message=${msg}`);
4074
+ results.push({
4075
+ rule: key,
4076
+ status: "error",
4077
+ message: "repair threw: " + msg,
4078
+ before: v1.message
4079
+ });
4080
+ aborted = true;
4081
+ break outer;
4082
+ }
4083
+ console.error(`rule ${key}: repair -> ok durationMs=${Date.now() - tRepair}`);
4084
+ configDirty = true;
4085
+ const tRevalidate = Date.now();
4086
+ let v2;
4087
+ try {
4088
+ v2 = rule.validate(ctx);
4089
+ } catch (e) {
4090
+ const msg = e.message;
4091
+ console.error(`rule ${key}: revalidate -> error durationMs=${Date.now() - tRevalidate} message=${msg}`);
4092
+ results.push({
4093
+ rule: key,
4094
+ status: "error",
4095
+ message: "post-repair validate threw: " + msg,
4096
+ before: v1.message
4097
+ });
4098
+ aborted = true;
4099
+ break outer;
4100
+ }
4101
+ if (v2.pass) {
4102
+ console.error(`rule ${key}: revalidate -> pass (fixed) durationMs=${Date.now() - tRevalidate}`);
4103
+ results.push({
4104
+ rule: key,
4105
+ status: "fixed",
4106
+ before: v1.message
4107
+ });
4108
+ } else {
4109
+ console.error(`rule ${key}: revalidate -> still-broken durationMs=${Date.now() - tRevalidate} after=${v2.message ?? ""}`);
4110
+ results.push({
4111
+ rule: key,
4112
+ status: "still-broken",
4113
+ before: v1.message,
4114
+ after: v2.message
4115
+ });
4116
+ failedKeys.add(key);
4117
+ }
4118
+ }
4119
+ let backupPath = null;
4120
+ if (configDirty && !aborted) {
4121
+ backupPath = backupConfigSync(ctx.configPath);
4122
+ const serialized = JSON.stringify(ctx.config, null, 2);
4123
+ try {
4124
+ writeFile(ctx.configPath, serialized);
4125
+ console.error(`runDoctor: writeback ok path=${ctx.configPath} bytes=${serialized.length}`);
4126
+ } catch (e) {
4127
+ const msg = e.message;
4128
+ console.error(`runDoctor: writeback failed path=${ctx.configPath} message=${msg}`);
4129
+ results.push({
4130
+ rule: "*config-writeback*",
4131
+ status: "error",
4132
+ message: "config write failed: " + msg
4133
+ });
4134
+ aborted = true;
4135
+ }
4136
+ } else if (configDirty && aborted) console.error("runDoctor: writeback skipped (aborted, leaving on-disk config untouched)");
4137
+ else console.error("runDoctor: writeback skipped (no rule transitioned to fixed)");
4138
+ console.error(`runDoctor: end aborted=${aborted} results=${results.length}`);
4139
+ if (originalConfig !== null) {
4140
+ const d = (0, json_diff.diffString)(originalConfig, ctx.config, { color: false });
4141
+ console.error(`runDoctor: diff backupPath=${backupPath ?? "(none)"} changed=${!!d}`);
4142
+ if (d) {
4143
+ process.stdout.write(`original: ${backupPath ?? "(none)"}\n`);
4144
+ process.stdout.write(`after fixed: ${ctx.configPath}\n`);
4145
+ process.stdout.write("diff:\n");
4146
+ process.stdout.write(d);
4147
+ if (!d.endsWith("\n")) process.stdout.write("\n");
4148
+ } else process.stdout.write("(no openclaw.json changes)\n");
4149
+ }
4150
+ return finalize(results, aborted);
4151
+ }
4152
+ /** Deep-clone a JSON-shaped value. Uses structuredClone where available
4153
+ * (Node 17+); falls back to JSON round-trip otherwise. The config we
4154
+ * clone here is JSON5-parsed so it's strictly tree-shaped — no
4155
+ * references, no functions — making both paths equivalent. */
4156
+ function deepClone(v) {
4157
+ if (typeof structuredClone === "function") return structuredClone(v);
4158
+ return JSON.parse(JSON.stringify(v));
4159
+ }
4160
+ function finalize(results, aborted) {
4161
+ const summary = {
4162
+ pass: 0,
4163
+ failed: 0,
4164
+ fixed: 0,
4165
+ stillBroken: 0,
4166
+ skipped: 0,
4167
+ error: 0,
4168
+ unknown: 0
4169
+ };
4170
+ for (const r of results) switch (r.status) {
4171
+ case "pass":
4172
+ summary.pass++;
4173
+ break;
4174
+ case "failed":
4175
+ summary.failed++;
4176
+ break;
4177
+ case "fixed":
4178
+ summary.fixed++;
4179
+ break;
4180
+ case "still-broken":
4181
+ summary.stillBroken++;
4182
+ break;
4183
+ case "skipped":
4184
+ summary.skipped++;
4185
+ break;
4186
+ case "error":
4187
+ summary.error++;
4188
+ break;
4189
+ case "unknown":
4190
+ summary.unknown++;
4191
+ break;
4192
+ }
2805
4193
  return {
2806
- check,
2807
- repair: runRepair(repairInput)
4194
+ results,
4195
+ summary,
4196
+ aborted
4197
+ };
4198
+ }
4199
+ //#endregion
4200
+ //#region src/innerapi/reportCliRun.ts
4201
+ /**
4202
+ * CLI-side client for studio_server's `openclaw.report_cli_run` inner
4203
+ * API — the unified telemetry sink for every CLI command.
4204
+ *
4205
+ * Best-effort: gated by `DoctorCtx.telemetry.reportCliRun` returned by
4206
+ * `get_doctor_ctx`. When the server hasn't rolled out the API yet the
4207
+ * flag is absent and the CLI never invokes this. When enabled, the CLI
4208
+ * fires-and-forgets one report per command after work is done.
4209
+ *
4210
+ * Failures here MUST NOT change exit code or otherwise affect the
4211
+ * command's data path — telemetry is auxiliary. Caller is expected to
4212
+ * `try/catch` and just `console.error` on rejection.
4213
+ *
4214
+ * Replaces the earlier `openclaw.report_doctor_result` API. The schema
4215
+ * generalises so check / repair / install-* / download-resource / reset
4216
+ * (worker) can all use the same envelope; per-command shape lives under
4217
+ * `result`.
4218
+ */
4219
+ const API_NAME = "openclaw.report_cli_run";
4220
+ async function reportCliRun(opts) {
4221
+ const client = getHttpClient();
4222
+ const input = {
4223
+ command: opts.command,
4224
+ runId: opts.runId,
4225
+ version: opts.version,
4226
+ invocation: opts.invocation,
4227
+ durationMs: opts.durationMs,
4228
+ success: opts.success
4229
+ };
4230
+ if (opts.caller) input.caller = opts.caller;
4231
+ if (opts.traceId) input.traceId = opts.traceId;
4232
+ if (opts.result !== void 0) input.result = opts.result;
4233
+ if (opts.error) input.error = opts.error;
4234
+ const body = {
4235
+ apiName: API_NAME,
4236
+ input,
4237
+ bizType: BIZ_TYPE
2808
4238
  };
4239
+ const headers = { "Content-Type": "application/json" };
4240
+ const ttEnv = process.env.X_TT_ENV;
4241
+ if (ttEnv) headers["x-tt-env"] = ttEnv;
4242
+ const start = Date.now();
4243
+ console.error(`reportCliRun: start command=${opts.command} success=${opts.success} version=${opts.version} invocation=${JSON.stringify(opts.invocation)} durationMs=${opts.durationMs}`);
4244
+ let response;
4245
+ try {
4246
+ response = await client.post(INNERAPI_CALL_PATH, body, { headers });
4247
+ } catch (e) {
4248
+ if (e instanceof _lark_apaas_http_client.HttpError && e.response) {
4249
+ const status = e.response.status;
4250
+ const logId = e.response.headers.get("x-tt-logid") ?? "";
4251
+ throw new Error(`reportCliRun HTTP ${status} ${e.response.statusText} (logID: ${logId})`);
4252
+ }
4253
+ const msg = e instanceof Error ? e.message : String(e);
4254
+ throw new Error(`reportCliRun network error: ${msg}`);
4255
+ }
4256
+ const logId = response.headers.get("x-tt-logid") ?? "";
4257
+ if (!response.ok) {
4258
+ let preview = "";
4259
+ try {
4260
+ preview = (await response.text()).slice(0, 500);
4261
+ } catch {}
4262
+ throw new Error(`reportCliRun HTTP ${response.status} ${response.statusText} (logID: ${logId})${preview ? ` body=${preview}` : ""}`);
4263
+ }
4264
+ let envelope;
4265
+ try {
4266
+ envelope = await response.json();
4267
+ } catch {
4268
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start} (body unparseable, treated as success)`);
4269
+ return;
4270
+ }
4271
+ 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 ?? ""}`);
4272
+ if (envelope.data && envelope.data.success === false) throw new Error(`reportCliRun business error (logID: ${logId}): ${envelope.error_msg ?? JSON.stringify(envelope.data)}`);
4273
+ console.error(`reportCliRun: ok logId=${logId} httpDurationMs=${Date.now() - start}`);
2809
4274
  }
2810
4275
  //#endregion
2811
4276
  //#region src/help.ts
2812
4277
  const BIN = "mclaw-diagnose";
2813
4278
  function versionBanner() {
2814
- return `v0.1.4-alpha.0`;
4279
+ return `v0.1.4-alpha.2`;
2815
4280
  }
2816
4281
  const COMMANDS = [
2817
4282
  {
@@ -2819,46 +4284,85 @@ const COMMANDS = [
2819
4284
  hidden: false,
2820
4285
  summary: "Diagnose openclaw config; apply repairs with --fix",
2821
4286
  help: `USAGE
2822
- ${BIN} doctor [--fix] [--rule=<key>]...
4287
+ ${BIN} doctor [--fix] [--show-diff] [--rule=<key>]... [--skip-rule=<key>]...
4288
+ [--caller=<n>] [--trace-id=<id>]
2823
4289
 
2824
4290
  DESCRIPTION
2825
- Fetches DoctorCtx via innerapi, then runs one of three modes depending
2826
- on the flags. Output is a single JSON object on stdout.
4291
+ Fetches DoctorCtx via innerapi, then orchestrates each rule:
4292
+
4293
+ validate (always)
4294
+ pass → status='pass'
4295
+ fail and !--fix → status='failed'
4296
+ fail and --fix
4297
+ repair → re-validate
4298
+ pass → status='fixed'
4299
+ still fail → status='still-broken'
2827
4300
 
2828
- MODES
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.
4301
+ Output is a single JSON object on stdout:
2834
4302
 
2835
- --fix Check + repair-all. First runs the rule engine,
2836
- then repairs every failing standard-mode rule.
2837
- Returns
2838
- { check: {...}, repair: {...} }
2839
- Use this as the default "fix everything" action.
4303
+ {
4304
+ "results": [{ "rule": "...", "status": "...", "message"?: "...", ...}],
4305
+ "summary": { "pass": N, "failed": N, "fixed": N, "stillBroken": N,
4306
+ "skipped": N, "error": N, "unknown": N },
4307
+ "aborted": false
4308
+ }
2840
4309
 
2841
- --fix --rule=<key>... Targeted repair. Skips the check pass entirely
2842
- and runs repair against the listed rule keys
2843
- only. Unknown keys are silently ignored.
2844
- Returns { repair: {...} } with only those
2845
- rules' outcomes. Use this when you already
2846
- know which rules need fixing.
4310
+ STATUSES
4311
+ pass Rule's validate passed; no repair attempted.
4312
+ failed Rule's validate failed; --fix was not given.
4313
+ fixed Rule's validate failed; repair ran; re-validate passed.
4314
+ still-broken Rule's validate failed; repair ran; re-validate still failed.
4315
+ skipped Rule excluded by disabledRules / --skip-rule / dependsOn /
4316
+ skipWhen.
4317
+ error validate or repair threw an exception. ABORTS the run —
4318
+ exit code becomes 1; partial 'results' still written to stdout.
4319
+ unknown --rule=<k> with k not registered. Reported, not aborting.
2847
4320
 
2848
4321
  OPTIONS
2849
- --fix Enable repair. See MODES above.
2850
- --rule=<key> Repair only this rule key. Repeatable. Only
2851
- meaningful together with --fix.
4322
+ --fix Enable repair. Without it, validate runs but failures
4323
+ are reported as 'failed' instead of being repaired.
4324
+ --show-diff With --fix: replace the JSON report on stdout with
4325
+ a plain-text section showing
4326
+ original: <backup path>
4327
+ after fixed: <openclaw.json path>
4328
+ diff:
4329
+ <unified-diff body, same format as \`json-diff\`>
4330
+ Interactive flag — sandbox_console's push path never
4331
+ sets it, so the JSON contract is preserved for that
4332
+ caller. No-op without --fix (no mutation, no diff).
4333
+ --rule=<key> Whitelist: restrict the run to this rule key (repeatable).
4334
+ Without --rule, every registered rule runs. With --rule,
4335
+ the planner narrows the innerapi vars fetch to just
4336
+ that rule's needs (see lazy-vars).
4337
+ --skip-rule=<key> Blacklist: skip the rule entirely — neither validate
4338
+ nor repair runs (repeatable). Outcome is 'skipped' with
4339
+ message='--skip-rule'. Stacks with the server's
4340
+ disabledRules. Skipped rules also subtract their vars
4341
+ from the planner's union.
4342
+ --caller=<name> Optional metadata; passed through verbatim to innerapi
4343
+ (fetchCtx + report_cli_run body.input.caller).
4344
+ E.g. "sweep_job", "user_yxy". Server uses for audit /
4345
+ correlation; CLI doesn't act on it.
4346
+ --trace-id=<id> Optional log-correlation id; passed through verbatim
4347
+ to innerapi body.input.traceId. Typically the upstream
4348
+ caller's logID (e.g. sandbox_console's request id).
2852
4349
 
2853
4350
  EXAMPLES
2854
- ${BIN} doctor # check only
2855
- ${BIN} doctor --fix # check then repair all
2856
- ${BIN} doctor --fix --rule=gateway # repair 'gateway' only
2857
- ${BIN} doctor --fix --rule=gateway --rule=jwt_token # repair multiple
4351
+ ${BIN} doctor # diagnose, no fixes
4352
+ ${BIN} doctor --fix # diagnose + fix everything
4353
+ ${BIN} doctor --fix --rule=gateway # fix only 'gateway'
4354
+ ${BIN} doctor --fix --skip-rule=feishu_channel # fix everything except feishu
4355
+ ${BIN} doctor --fix --skip-rule=feishu_channel --skip-rule=feishu_default_account
4356
+ # skip multiple rules
4357
+ ${BIN} doctor --fix --show-diff # see exactly what changed
4358
+ # (plain-text diff after
4359
+ # the JSON report)
2858
4360
 
2859
4361
  EXIT CODES
2860
- 0 success
2861
- 1 generic error
4362
+ 0 completed (results may include 'failed' / 'still-broken' — those
4363
+ are normal data outcomes, not run failures)
4364
+ 1 aborted because a rule's validate or repair threw. Partial
4365
+ results still on stdout.
2862
4366
  77 innerapi authentication failed (sandbox JWT expired/invalid)
2863
4367
  `
2864
4368
  },
@@ -3047,6 +4551,62 @@ function formatCommandHelp(name) {
3047
4551
  return cmd.help;
3048
4552
  }
3049
4553
  //#endregion
4554
+ //#region src/rule-engine/planner.ts
4555
+ /**
4556
+ * Compute the union of `Vars` field names that the **enabled** rules will
4557
+ * actually read. Sources from each rule's `@Rule({ usesVars: [...] })`
4558
+ * declaration; drift is caught at test time by `strict-vars.test.ts`.
4559
+ *
4560
+ * Inactive rules (skipped via skipWhen at runtime) are STILL counted —
4561
+ * skipWhen depends on parsed-config state we don't have at planning time.
4562
+ * Slight over-fetch in those cases, but no correctness issue.
4563
+ *
4564
+ * Result is stable-sorted for deterministic logging / test snapshots.
4565
+ */
4566
+ function planVarsFields(opts = {}) {
4567
+ const disabled = new Set(opts.disabled ?? []);
4568
+ const only = opts.onlyRules && opts.onlyRules.length > 0 ? new Set(opts.onlyRules) : null;
4569
+ const fields = /* @__PURE__ */ new Set();
4570
+ for (const rule of getAllRules()) {
4571
+ const key = rule.meta.key;
4572
+ if (disabled.has(key)) continue;
4573
+ if (only && !only.has(key)) continue;
4574
+ for (const f of rule.meta.usesVars ?? []) fields.add(f);
4575
+ }
4576
+ return [...fields].sort();
4577
+ }
4578
+ /**
4579
+ * Plan a `populate` selector for a given subcommand.
4580
+ *
4581
+ * Per-command group needs:
4582
+ *
4583
+ * doctor / check app (rule-driven)
4584
+ * repair app + secrets (writes secretsContent / providerKeyContent)
4585
+ * reset app + secrets + install + reset (the works)
4586
+ * install-* install only
4587
+ *
4588
+ * Empty result (`{}`) means "no group needed" — the CLI can skip the
4589
+ * `fetchCtxViaInnerApi` call entirely and run with a synthetic empty ctx.
4590
+ * Happens e.g. when the user pinned `--rule=<key>` to a vars-free rule on
4591
+ * `doctor`.
4592
+ */
4593
+ function planCtxPopulate(opts) {
4594
+ if (opts.command === "install") return { install: true };
4595
+ const populate = {};
4596
+ const appFields = planVarsFields({
4597
+ disabled: opts.disabled,
4598
+ onlyRules: opts.onlyRules
4599
+ });
4600
+ if (appFields.length > 0) populate.app = appFields;
4601
+ if (opts.command === "repair") populate.secrets = true;
4602
+ else if (opts.command === "reset") {
4603
+ populate.secrets = true;
4604
+ populate.install = true;
4605
+ populate.reset = true;
4606
+ }
4607
+ return populate;
4608
+ }
4609
+ //#endregion
3050
4610
  //#region src/index.ts
3051
4611
  const args = node_process.default.argv.slice(2);
3052
4612
  const mode = args.find((a) => !a.startsWith("-"));
@@ -3080,8 +4640,39 @@ function getMultiFlag(args, name) {
3080
4640
  const prefix = `--${name}=`;
3081
4641
  return args.filter((a) => a.startsWith(prefix)).map((a) => a.slice(prefix.length));
3082
4642
  }
4643
+ /**
4644
+ * Per-case telemetry helper: fires `openclaw.report_cli_run` once with
4645
+ * the command's outcome. Always called — the previous server-flag gate
4646
+ * was removed (server controls receipt by routing the apiName).
4647
+ *
4648
+ * Best-effort: a thrown reportCliRun is logged to cli.log and swallowed.
4649
+ * Never alters the data path's exit code — the user-visible work has
4650
+ * already finished by the time we report.
4651
+ *
4652
+ * `raw` is kept in the signature for backward symmetry with the doctor
4653
+ * case but is no longer consulted.
4654
+ */
4655
+ async function reportRun(command, rc, _raw, invocation, durationMs, outcome) {
4656
+ console.error(`${command}: telemetry calling report_cli_run`);
4657
+ try {
4658
+ await reportCliRun({
4659
+ command,
4660
+ runId: rc.runId,
4661
+ version: getVersion(),
4662
+ invocation,
4663
+ durationMs,
4664
+ caller: rc.caller,
4665
+ traceId: rc.traceId,
4666
+ success: outcome.success,
4667
+ result: outcome.result,
4668
+ error: outcome.error ? { message: outcome.error.message } : void 0
4669
+ });
4670
+ } catch (e) {
4671
+ console.error(`[telemetry] reportCliRun failed: ${e.message}`);
4672
+ }
4673
+ }
3083
4674
  async function main() {
3084
- installStderrMirror();
4675
+ installStderrMirror({ stderrEnabled: args.includes("-x") });
3085
4676
  const helpFlags = parseHelpFlags(args);
3086
4677
  if (mode && helpFlags.help) {
3087
4678
  const body = formatCommandHelp(mode);
@@ -3101,22 +4692,79 @@ async function main() {
3101
4692
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3102
4693
  node_process.default.exit(1);
3103
4694
  }
4695
+ const caller = getFlag(args, "caller");
4696
+ const traceId = getFlag(args, "trace-id");
4697
+ const rc = setRunContext({
4698
+ caller,
4699
+ traceId
4700
+ });
4701
+ const t0 = Date.now();
4702
+ console.error(`${mode}: begin argv=[${args.join(" ")}] version=${getVersion()} traceId=${traceId ?? "-"} caller=${caller ?? "-"} runIdGenerated=${rc.generated}`);
3104
4703
  switch (mode) {
3105
- case "check":
4704
+ case "check": {
4705
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4706
+ populate: planCtxPopulate({ command: "check" }),
4707
+ caller,
4708
+ traceId
4709
+ });
4710
+ const { legacy, report } = runCheckWithReport(buildCheckInput(raw));
4711
+ console.log(JSON.stringify(legacy));
4712
+ await reportRun("check", rc, raw, args.join(" "), Date.now() - t0, {
4713
+ success: !report.aborted,
4714
+ result: report,
4715
+ error: report.aborted ? /* @__PURE__ */ new Error("check run aborted") : void 0
4716
+ });
4717
+ break;
4718
+ }
3106
4719
  case "repair": {
3107
- const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi();
3108
- if (mode === "check") console.log(JSON.stringify(runCheck(buildCheckInput(raw))));
3109
- else console.log(JSON.stringify(runRepair(buildRepairInput(raw))));
4720
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4721
+ populate: planCtxPopulate({ command: "repair" }),
4722
+ caller,
4723
+ traceId
4724
+ });
4725
+ const { legacy, report } = runRepairWithReport(buildRepairInput(raw));
4726
+ console.log(JSON.stringify(legacy));
4727
+ await reportRun("repair", rc, raw, args.join(" "), Date.now() - t0, {
4728
+ success: legacy.success,
4729
+ result: report,
4730
+ error: legacy.success ? void 0 : new Error(legacy.error ?? "repair failed")
4731
+ });
3110
4732
  break;
3111
4733
  }
3112
4734
  case "doctor": {
3113
4735
  const fix = args.includes("--fix");
3114
4736
  const rules = getMultiFlag(args, "rule");
3115
- const result = await runDoctor(await fetchCtxViaInnerApi(), {
4737
+ const skipRules = getMultiFlag(args, "skip-rule");
4738
+ const showDiff = args.includes("--show-diff");
4739
+ const populate = planCtxPopulate({
4740
+ command: "doctor",
4741
+ onlyRules: fix && rules.length > 0 ? rules : void 0,
4742
+ disabled: skipRules
4743
+ });
4744
+ console.error(`doctor: populate=${JSON.stringify(populate)} fix=${fix} rules=[${rules.join(",")}] skipRules=[${skipRules.join(",")}]`);
4745
+ let raw;
4746
+ if (Object.keys(populate).length === 0) {
4747
+ console.error("doctor: skipping fetchCtx (empty populate)");
4748
+ raw = {};
4749
+ } else raw = await fetchCtxViaInnerApi({
4750
+ populate,
4751
+ caller,
4752
+ traceId
4753
+ });
4754
+ const result = await runDoctor(raw, {
3116
4755
  fix,
3117
- rules
4756
+ rules,
4757
+ skipRules,
4758
+ showDiff
3118
4759
  });
3119
- console.log(JSON.stringify(result));
4760
+ if (!(showDiff && fix)) console.log(JSON.stringify(result));
4761
+ await reportRun("doctor", rc, raw, args.join(" "), Date.now() - t0, {
4762
+ success: !result.aborted,
4763
+ result,
4764
+ error: result.aborted ? /* @__PURE__ */ new Error("doctor run aborted") : void 0
4765
+ });
4766
+ console.error(`doctor: end aborted=${result.aborted} totalMs=${Date.now() - t0} summary=${JSON.stringify(result.summary)}`);
4767
+ if (result.aborted) node_process.default.exit(1);
3120
4768
  break;
3121
4769
  }
3122
4770
  case "reset":
@@ -3125,7 +4773,11 @@ async function main() {
3125
4773
  let ctxBase64;
3126
4774
  if (ctxArg) ctxBase64 = ctxArg.slice(6);
3127
4775
  else {
3128
- const fetched = await fetchCtxViaInnerApi();
4776
+ const fetched = await fetchCtxViaInnerApi({
4777
+ populate: planCtxPopulate({ command: "reset" }),
4778
+ caller,
4779
+ traceId
4780
+ });
3129
4781
  ctxBase64 = Buffer.from(JSON.stringify(fetched), "utf-8").toString("base64");
3130
4782
  }
3131
4783
  console.log(JSON.stringify(startAsyncReset(ctxBase64)));
@@ -3136,7 +4788,22 @@ async function main() {
3136
4788
  node_process.default.exit(1);
3137
4789
  }
3138
4790
  const resultFile = resetResultFile(taskId);
3139
- await runReset(buildResetInput(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()), taskId, resultFile);
4791
+ const raw = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4792
+ populate: planCtxPopulate({ command: "reset" }),
4793
+ caller,
4794
+ traceId
4795
+ });
4796
+ const outcome = await runReset(buildResetInput(raw), taskId, resultFile);
4797
+ await reportRun("reset", rc, raw, args.join(" "), Date.now() - t0, {
4798
+ success: outcome.success,
4799
+ result: {
4800
+ taskId,
4801
+ steps: outcome.steps,
4802
+ failedStep: outcome.failedStep
4803
+ },
4804
+ error: outcome.success ? void 0 : new Error(outcome.error ?? "reset failed")
4805
+ });
4806
+ if (!outcome.success) node_process.default.exit(1);
3140
4807
  } else {
3141
4808
  console.error("Usage: reset --async [--ctx=<base64>] | reset --worker --task-id=<id> [--ctx=<base64>]");
3142
4809
  node_process.default.exit(1);
@@ -3159,12 +4826,34 @@ async function main() {
3159
4826
  }
3160
4827
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3161
4828
  let installOssFileMap;
3162
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3163
- await installOpenclaw(tag, resolveOssFileMap({
4829
+ let rawForTelemetry;
4830
+ if (!ossFileMapFlag) {
4831
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4832
+ populate: planCtxPopulate({ command: "install" }),
4833
+ caller,
4834
+ traceId
4835
+ });
4836
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4837
+ }
4838
+ const ossFileMap = resolveOssFileMap({
3164
4839
  ossFileMapFlag,
3165
4840
  installOssFileMap
3166
- }));
3167
- console.log(JSON.stringify({ ok: true }));
4841
+ });
4842
+ let success = true;
4843
+ let error;
4844
+ try {
4845
+ await installOpenclaw(tag, ossFileMap);
4846
+ } catch (e) {
4847
+ success = false;
4848
+ error = e;
4849
+ }
4850
+ if (success) console.log(JSON.stringify({ ok: true }));
4851
+ await reportRun("install-openclaw", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4852
+ success,
4853
+ result: { tag },
4854
+ error
4855
+ });
4856
+ if (error) throw error;
3168
4857
  break;
3169
4858
  }
3170
4859
  case "install-extension": {
@@ -3180,18 +4869,45 @@ async function main() {
3180
4869
  const skipConfigUpdate = args.includes("--skip-config-update");
3181
4870
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3182
4871
  let installOssFileMap;
3183
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3184
- await installExtension(tag, resolveOssFileMap({
4872
+ let rawForTelemetry;
4873
+ if (!ossFileMapFlag) {
4874
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4875
+ populate: planCtxPopulate({ command: "install" }),
4876
+ caller,
4877
+ traceId
4878
+ });
4879
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4880
+ }
4881
+ const ossFileMap = resolveOssFileMap({
3185
4882
  ossFileMapFlag,
3186
4883
  installOssFileMap
3187
- }), {
3188
- all,
3189
- names: names.length > 0 ? names : void 0,
3190
- homeBase,
3191
- configPath,
3192
- skipConfigUpdate
3193
4884
  });
3194
- console.log(JSON.stringify({ ok: true }));
4885
+ let success = true;
4886
+ let error;
4887
+ try {
4888
+ await installExtension(tag, ossFileMap, {
4889
+ all,
4890
+ names: names.length > 0 ? names : void 0,
4891
+ homeBase,
4892
+ configPath,
4893
+ skipConfigUpdate
4894
+ });
4895
+ } catch (e) {
4896
+ success = false;
4897
+ error = e;
4898
+ }
4899
+ if (success) console.log(JSON.stringify({ ok: true }));
4900
+ await reportRun("install-extension", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4901
+ success,
4902
+ result: {
4903
+ tag,
4904
+ all,
4905
+ names,
4906
+ skipConfigUpdate
4907
+ },
4908
+ error
4909
+ });
4910
+ if (error) throw error;
3195
4911
  break;
3196
4912
  }
3197
4913
  case "download-resource": {
@@ -3209,16 +4925,43 @@ async function main() {
3209
4925
  }
3210
4926
  const ossFileMapFlag = getFlag(args, "oss_file_map");
3211
4927
  let installOssFileMap;
3212
- if (!ossFileMapFlag) installOssFileMap = normalizeCtx(parseCtxFlag(args) ?? await fetchCtxViaInnerApi()).install.ossFileMap;
3213
- await downloadResource(tag, resolveOssFileMap({
4928
+ let rawForTelemetry;
4929
+ if (!ossFileMapFlag) {
4930
+ rawForTelemetry = parseCtxFlag(args) ?? await fetchCtxViaInnerApi({
4931
+ populate: planCtxPopulate({ command: "install" }),
4932
+ caller,
4933
+ traceId
4934
+ });
4935
+ installOssFileMap = normalizeCtx(rawForTelemetry).install.ossFileMap;
4936
+ }
4937
+ const ossFileMap = resolveOssFileMap({
3214
4938
  ossFileMapFlag,
3215
4939
  installOssFileMap
3216
- }), {
3217
- role,
3218
- name,
3219
- dir
3220
4940
  });
3221
- console.log(JSON.stringify({ ok: true }));
4941
+ let success = true;
4942
+ let error;
4943
+ try {
4944
+ await downloadResource(tag, ossFileMap, {
4945
+ role,
4946
+ name,
4947
+ dir
4948
+ });
4949
+ } catch (e) {
4950
+ success = false;
4951
+ error = e;
4952
+ }
4953
+ if (success) console.log(JSON.stringify({ ok: true }));
4954
+ await reportRun("download-resource", rc, rawForTelemetry, args.join(" "), Date.now() - t0, {
4955
+ success,
4956
+ result: {
4957
+ tag,
4958
+ role,
4959
+ name,
4960
+ dir
4961
+ },
4962
+ error
4963
+ });
4964
+ if (error) throw error;
3222
4965
  break;
3223
4966
  }
3224
4967
  default:
@@ -3226,10 +4969,12 @@ async function main() {
3226
4969
  node_process.default.stderr.write(formatTopLevelHelp(helpFlags.expert));
3227
4970
  node_process.default.exit(1);
3228
4971
  }
4972
+ console.error(`${mode}: end totalMs=${Date.now() - t0}`);
3229
4973
  }
3230
4974
  main().catch((err) => {
3231
4975
  const msg = err instanceof Error ? err.message : String(err);
3232
- console.error(`Error: ${msg}`);
4976
+ console.error(`${mode ?? "<no-mode>"}: error message=${msg}`);
4977
+ node_process.default.stderr.write(`Error: ${msg}\n`);
3233
4978
  node_process.default.exit(1);
3234
4979
  });
3235
4980
  //#endregion